diff --git a/components/ChatInput.streaming-thinking.test.mjs b/components/ChatInput.streaming-thinking.test.mjs new file mode 100644 index 000000000..24d30dd0a --- /dev/null +++ b/components/ChatInput.streaming-thinking.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile(new URL("./ChatInput.tsx", import.meta.url), "utf8"); +const controls = source.slice(source.indexOf("{isStreaming && onThinkingLevelChange &&")); + +test("shows the reasoning level of the running turn without offering a control", () => { + const readOnly = controls.slice(0, controls.indexOf("{!isStreaming && onThinkingLevelChange &&")); + assert.match(readOnly, //); + assert.doesNotMatch(readOnly, / { + const readOnly = controls.slice(0, controls.indexOf("{!isStreaming && onThinkingLevelChange &&")); + const dropdown = controls.slice(controls.indexOf("{!isStreaming && onThinkingLevelChange &&")); + const padding = /padding: isMobile \? "0 6px" : "8px 12px"/; + const height = /height: 32/; + for (const [label, markup] of [["read-only", readOnly], ["dropdown", dropdown]]) { + assert.match(markup, padding, `${label} horizontal padding`); + assert.match(markup, height, `${label} height`); + } + // The label collapses to the icon on mobile exactly like the editable control. + assert.match(readOnly, /\(!isMobile \|\| controlsMenuOpen\) &&/); +}); + +test("shows the level the runtime actually applies, not the selector placeholder", async () => { + const session = await readFile(new URL("../hooks/useAgentSession.ts", import.meta.url), "utf8"); + const start = session.slice(session.indexOf('case "agent_start":'), session.indexOf('case "agent_end":')); + // Choosing "auto" leaves pi's setting untouched, so the selector alone cannot be trusted. + assert.match(session, /if \(level === "auto"\) return;/); + assert.match(start, /fetch\(`\/api\/agent\/\$\{encodeURIComponent\(sessionIdRef\.current\)\}`\)/); + assert.match(start, /if \(!agentRunningRef\.current \|\| !d\.state\?\.thinkingLevel\) return;\s*setThinkingLevel\(d\.state\.thinkingLevel as ThinkingLevelOption\);/); +}); diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 1f91913cb..e30ab7b03 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -26,6 +26,7 @@ import { FolderIcon, getFileIcon } from "./FileIcons"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useI18n } from "@/hooks/useI18n"; import { useChatAppearance } from "@/hooks/useChatAppearance"; +import { ThinkingIcon } from "./ThinkingIcon"; import type { ToolPreset } from "@/lib/tool-presets"; import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; @@ -2268,6 +2269,25 @@ export const ChatInput = forwardRef(function ChatInput({ backdropFilter: "blur(10px)", } : null), }}> + {isStreaming && onThinkingLevelChange && ( + // The level cannot change mid-turn, so this is read-only: a button here + // would invite clicks that do nothing. It still answers the question + // that matters while a turn runs, which budget is this one spending. + + + {(!isMobile || controlsMenuOpen) && {thinkingDisplayLabel}} + + )} {!isStreaming && onThinkingLevelChange && (