Skip to content

Commit c3df095

Browse files
committed
feat(webview): thinking effort surfaces — header chip, composer toggle, in-chat display
1 parent 0ab4a60 commit c3df095

37 files changed

Lines changed: 1092 additions & 2 deletions

packages/types/src/vscode-extension-host.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,12 @@ export type ExtensionState = Pick<
336336
clineMessages: ClineMessage[]
337337
currentTaskId?: string
338338
currentTaskItem?: HistoryItem
339+
// DTE series 4/5: task-local thinking effort override for the current task.
340+
// Present only while a task-local override is active (set by the composer
341+
// toggle, the set_thinking_effort tool, or a parent orchestrator); undefined
342+
// otherwise, in which case the webview derives the display from settings ->
343+
// model default. Authoritative state stays extension-side.
344+
taskThinkingEffort?: { effort: string; source: string }
339345
currentTaskTodos?: TodoItem[] // Initial todos for the current task
340346
apiConfiguration: ProviderSettings
341347
uriScheme?: string
@@ -501,6 +507,7 @@ export interface WebviewMessage {
501507
| "openMention"
502508
| "cancelTask"
503509
| "cancelAutoApproval"
510+
| "setTaskThinkingEffort"
504511
| "updateVSCodeSetting"
505512
| "getVSCodeSetting"
506513
| "vsCodeSetting"
@@ -648,6 +655,11 @@ export interface WebviewMessage {
648655
| "themeFixtureProbeResponse"
649656
text?: string
650657
taskId?: string
658+
// DTE series 4/5: task-local thinking effort set from the composer toggle
659+
// (message type "setTaskThinkingEffort"). Task-local only; persisted settings
660+
// are never touched.
661+
effort?: string
662+
reason?: string
651663
editedMessageContent?: string
652664
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
653665
disabled?: boolean

src/core/webview/ClineProvider.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2649,6 +2649,11 @@ export class ClineProvider
26492649
const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands)
26502650
const cwd = this.cwd
26512651
const currentTask = this.getCurrentTask()
2652+
// DTE series 4/5: task-local thinking effort override for the current task
2653+
// (undefined while no override is active — the webview then derives the
2654+
// display from settings -> model default). The optional call keeps this
2655+
// tolerant of partial task doubles in extension tests.
2656+
const currentTaskRuntimeEffort = currentTask?.getRuntimeThinkingEffort?.()
26522657
let zooCodeState: {
26532658
zooCodeIsAuthenticated: boolean
26542659
zooCodeUserName: string | undefined
@@ -2706,6 +2711,9 @@ export class ClineProvider
27062711
currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined,
27072712
clineMessages: currentTask?.clineMessages || [],
27082713
currentTaskTodos: currentTask?.todoList || [],
2714+
taskThinkingEffort: currentTaskRuntimeEffort?.effort
2715+
? { effort: currentTaskRuntimeEffort.effort, source: currentTaskRuntimeEffort.source ?? "default" }
2716+
: undefined,
27092717
messageQueue: currentTask?.messageQueueService?.messages,
27102718
taskHistory: includeTaskHistory
27112719
? this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect, vi } from "vitest"
2+
3+
import { webviewMessageHandler } from "../webviewMessageHandler"
4+
5+
vi.mock("../../../i18n", () => ({
6+
t: vi.fn((key: string) => key),
7+
changeLanguage: vi.fn(),
8+
}))
9+
10+
vi.mock("vscode", () => ({
11+
window: {
12+
showErrorMessage: vi.fn(),
13+
showWarningMessage: vi.fn(),
14+
showInformationMessage: vi.fn(),
15+
},
16+
workspace: {
17+
workspaceFolders: undefined,
18+
getConfiguration: vi.fn(() => ({
19+
get: vi.fn(),
20+
update: vi.fn(),
21+
})),
22+
},
23+
ConfigurationTarget: {
24+
Global: 1,
25+
Workspace: 2,
26+
WorkspaceFolder: 3,
27+
},
28+
Uri: {
29+
parse: vi.fn((str) => ({ toString: () => str })),
30+
file: vi.fn((path) => ({ fsPath: path })),
31+
},
32+
}))
33+
34+
describe("webviewMessageHandler setTaskThinkingEffort (DTE series 4/5)", () => {
35+
const makeTask = (supportsReasoningEffort: unknown) => {
36+
const say = vi.fn(async (_say: string, _text?: string) => {})
37+
return {
38+
taskId: "test-task-id",
39+
api: { getModel: () => ({ id: "test-model", info: { supportsReasoningEffort } }) },
40+
setRuntimeThinkingEffort: vi.fn(),
41+
say,
42+
}
43+
}
44+
45+
const makeProvider = (task: unknown) => ({
46+
getCurrentTask: vi.fn(() => task),
47+
postStateToWebviewWithoutTaskHistory: vi.fn(async () => {}),
48+
})
49+
50+
const apply = (provider: ReturnType<typeof makeProvider>, message: Record<string, unknown>) =>
51+
webviewMessageHandler(provider as never, message as never)
52+
53+
it("applies a task-local effort for a supported level, records the chat line, and pushes state", async () => {
54+
const task = makeTask(["low", "medium", "high"])
55+
const provider = makeProvider(task)
56+
57+
await apply(provider, { type: "setTaskThinkingEffort", effort: "high" })
58+
59+
expect(task.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "you")
60+
const [say, text] = task.say.mock.calls[0]
61+
expect(say).toBe("tool")
62+
expect(text).toBe(JSON.stringify({ tool: "thinkingEffort", effort: "high", source: "you" }))
63+
expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1)
64+
})
65+
66+
it("accepts boolean/adaptive-class capability", async () => {
67+
const provider = makeProvider(makeTask(true))
68+
69+
await apply(provider, { type: "setTaskThinkingEffort", effort: "medium" })
70+
71+
expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1)
72+
})
73+
74+
it.each([
75+
["an unsupported level", ["low", "medium", "high"], { effort: "max" }],
76+
["an effort outside the canonical enum", ["low", "medium", "high"], { effort: "bogus" }],
77+
["a missing effort", ["low", "medium", "high"], {}],
78+
["a model without effort support", false, { effort: "high" }],
79+
])("ignores %s", async (_name, capability, message) => {
80+
const task = makeTask(capability)
81+
const provider = makeProvider(task)
82+
83+
await apply(provider, { type: "setTaskThinkingEffort", ...message })
84+
85+
expect(task.setRuntimeThinkingEffort).not.toHaveBeenCalled()
86+
expect(task.say).not.toHaveBeenCalled()
87+
expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled()
88+
})
89+
90+
it("ignores the message when there is no current task", async () => {
91+
const provider = makeProvider(undefined)
92+
93+
await apply(provider, { type: "setTaskThinkingEffort", effort: "high" })
94+
95+
expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled()
96+
})
97+
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
type Command as SlashCommand,
1717
type WebviewMessage,
1818
type EditQueuedMessagePayload,
19+
type ClineSayTool,
20+
reasoningEffortExtendedSchema,
1921
TelemetryEventName,
2022
RooCodeSettings,
2123
ExperimentId,
@@ -1655,6 +1657,39 @@ export const webviewMessageHandler = async (
16551657
// Cancel any pending auto-approval timeout for the current task
16561658
provider.getCurrentTask()?.cancelAutoApprovalTimeout()
16571659
break
1660+
case "setTaskThinkingEffort": {
1661+
// DTE series 4/5: task-local thinking effort set from the composer
1662+
// toggle. Task-local only — persisted settings are never touched.
1663+
// Defense in depth: the composer menu only offers model-supported
1664+
// levels, but the webview is never trusted blindly.
1665+
const setEffortTask = provider.getCurrentTask()
1666+
// Validate the webview-supplied effort against the canonical enum.
1667+
const setEffortParsed = reasoningEffortExtendedSchema.safeParse(message.effort)
1668+
if (setEffortTask && setEffortParsed.success) {
1669+
const setEffortValue = setEffortParsed.data
1670+
const capability = setEffortTask.api.getModel().info.supportsReasoningEffort
1671+
const supported = Array.isArray(capability)
1672+
? (capability as string[]).includes(setEffortValue)
1673+
: capability === true
1674+
if (supported) {
1675+
setEffortTask.setRuntimeThinkingEffort(setEffortValue, "you")
1676+
// Single in-chat line (same ChatRow case as model-initiated changes).
1677+
await setEffortTask.say(
1678+
"tool",
1679+
JSON.stringify({
1680+
tool: "thinkingEffort",
1681+
effort: setEffortValue,
1682+
source: "you",
1683+
} satisfies ClineSayTool),
1684+
undefined,
1685+
false,
1686+
)
1687+
// Push the authoritative display state to the webview.
1688+
await provider.postStateToWebviewWithoutTaskHistory()
1689+
}
1690+
}
1691+
break
1692+
}
16581693
case "allowedCommands": {
16591694
// Validate and sanitize the commands array
16601695
const commands = message.commands ?? []

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1564,7 +1564,11 @@ export const ChatRowContent = ({
15641564
)
15651565
) : (
15661566
<Trans
1567-
i18nKey="chat:thinkingEffort.applied"
1567+
i18nKey={
1568+
info.source === "you"
1569+
? "chat:thinkingEffort.appliedByUser"
1570+
: "chat:thinkingEffort.applied"
1571+
}
15681572
components={{
15691573
code: <code className="font-medium">{info.effort}</code>,
15701574
}}

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import Thumbnails from "../common/Thumbnails"
2828
import { ModeSelector } from "./ModeSelector"
2929
import { ApiConfigSelector } from "./ApiConfigSelector"
3030
import { AutoApproveDropdown } from "./AutoApproveDropdown"
31+
import { ThinkingEffortToggle } from "./ThinkingEffortToggle"
3132
import { MAX_IMAGES_PER_MESSAGE } from "./constants"
3233
import ContextMenu from "./ContextMenu"
3334
import { IndexingStatusBadge } from "./IndexingStatusBadge"
@@ -1311,6 +1312,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
13111312
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
13121313
onToggleLockApiConfig={handleToggleLockApiConfig}
13131314
/>
1315+
<ThinkingEffortToggle triggerClassName="min-w-[28px] flex-shrink-0" />
13141316
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
13151317
</div>
13161318
<div className={cn("flex flex-shrink-0 items-center gap-0.5 h-5 leading-none pr-2")}>

webview-ui/src/components/chat/TaskHeader.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ListChevronsDownUp,
99
ArrowLeft,
1010
ArrowRight,
11+
Brain,
1112
} from "lucide-react"
1213
import prettyBytes from "pretty-bytes"
1314

@@ -29,6 +30,8 @@ import { Mention } from "./Mention"
2930
import { TodoListDisplay } from "./TodoListDisplay"
3031
import { LucideIconButton } from "./LucideIconButton"
3132

33+
import { computeThinkingEffortDisplay } from "@/utils/thinkingEffort"
34+
3235
export interface TaskHeaderProps {
3336
task: ClineMessage
3437
tokensIn: number
@@ -63,7 +66,7 @@ const TaskHeader = ({
6366
todos,
6467
}: TaskHeaderProps) => {
6568
const { t } = useTranslation()
66-
const { apiConfiguration, currentTaskItem } = useExtensionState()
69+
const { apiConfiguration, currentTaskItem, experiments, taskThinkingEffort } = useExtensionState()
6770
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
6871
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
6972

@@ -86,6 +89,25 @@ const TaskHeader = ({
8689
// vscode-lm reports maxTokens: -1 (unlimited); a negative reserve must not distort the window math.
8790
const reservedForOutput = maxTokens && maxTokens > 0 ? maxTokens : 0
8891

92+
// DTE series 4/5: current effective thinking effort + source badge
93+
// (task-local override → settings → model default/adaptive).
94+
const thinkingEffortDisplay = useMemo(
95+
() =>
96+
computeThinkingEffortDisplay({
97+
experiments,
98+
apiConfiguration,
99+
model,
100+
taskThinkingEffort,
101+
}),
102+
[experiments, apiConfiguration, model, taskThinkingEffort],
103+
)
104+
const thinkingEffortSourceKey =
105+
thinkingEffortDisplay?.source === "you"
106+
? "chat:thinkingEffort.sourceYou"
107+
: thinkingEffortDisplay?.source === "auto"
108+
? "chat:thinkingEffort.sourceAuto"
109+
: "chat:thinkingEffort.sourceDefault"
110+
89111
const condenseButton = (
90112
<LucideIconButton
91113
title={t("chat:task.condenseContext")}
@@ -187,6 +209,21 @@ const TaskHeader = ({
187209
)}
188210
</div>
189211
<div className="flex items-center shrink-0 ml-2" onClick={(e) => e.stopPropagation()}>
212+
{thinkingEffortDisplay && (
213+
<StandardTooltip
214+
content={t("chat:thinkingEffort.chipTooltip", {
215+
effort: thinkingEffortDisplay.effort,
216+
source: t(thinkingEffortSourceKey),
217+
})}>
218+
<span className="flex items-center gap-1 rounded-md border border-vscode-panel-border bg-vscode-sideBar-background/60 px-1.5 py-0.5 text-[11px] text-vscode-descriptionForeground">
219+
<Brain className="size-3" />
220+
<span className="font-medium text-vscode-foreground/90">
221+
{thinkingEffortDisplay.effort}
222+
</span>
223+
<span>{t(thinkingEffortSourceKey)}</span>
224+
</span>
225+
</StandardTooltip>
226+
)}
190227
<StandardTooltip content={isTaskExpanded ? t("chat:task.collapse") : t("chat:task.expand")}>
191228
<button
192229
onClick={() => setIsTaskExpanded(!isTaskExpanded)}

0 commit comments

Comments
 (0)