diff --git a/app/settings.css b/app/settings.css index 5ec9659212..3cd3b08bb4 100644 --- a/app/settings.css +++ b/app/settings.css @@ -634,20 +634,36 @@ .config-switch { position: relative; flex-shrink: 0; - width: 40px; - height: 22px; + width: 32px; + height: 18px; padding: 0; - border: 0; - border-radius: 11px; - background: var(--border); + border: 1px solid var(--border); + border-radius: 9px; + background: var(--bg-selected); cursor: pointer; - transition: background 0.18s; + transition: background 0.15s, border-color 0.15s; } .config-switch[aria-checked="true"] { + border-color: var(--accent); background: var(--accent); } +.config-switch:not(:disabled):hover { + border-color: var(--text-muted); +} + +.config-switch[aria-checked="true"]:not(:disabled):hover { + border-color: var(--accent-hover); + background: var(--accent-hover); +} + +.config-switch::before { + content: ""; + position: absolute; + inset: -5px -3px; +} + .config-switch:disabled { cursor: default; opacity: 0.55; @@ -659,18 +675,18 @@ .config-switch-knob { position: absolute; - top: 3px; - left: 3px; - width: 16px; - height: 16px; + top: 2px; + left: 2px; + width: 12px; + height: 12px; border-radius: 50%; - background: var(--bg); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.22); - transition: left 0.18s cubic-bezier(0.4, 0, 0.2, 1); + background: var(--text-muted); + transition: transform 0.15s, background 0.15s; } .config-switch[aria-checked="true"] .config-switch-knob { - left: 21px; + transform: translateX(14px); + background: var(--bg); } .config-switch:focus-visible { @@ -1031,6 +1047,7 @@ font-size: 10px; } +.settings-toggle-option, .settings-shell-option { display: flex; align-items: center; diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index b05fb81991..5ab8e4e248 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -4,9 +4,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, BlockingExtensionUiRequest, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage, UserMessage } from "@/lib/types"; import { normalizeCustomPanelLines } from "@/lib/ansi"; import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input"; -import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, isMessageGroupAnchor, splitFinalAssistantBlocks } from "@/lib/message-display"; +import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, isMessageGroupAnchor, splitFinalAssistantBlocks, splitThinkingBlocks } from "@/lib/message-display"; import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files"; -import { MessageView } from "./MessageView"; +import { MessageView, ThinkingBlock } from "./MessageView"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; import { ExtensionStatusBar } from "./ExtensionStatusBar"; @@ -172,23 +172,6 @@ function getUserInputText(message: AgentMessage): string | null { return text.length > 0 ? text : null; } -function countToolCalls(messages: AgentMessage[], indices: number[]): number { - let count = 0; - for (const idx of indices) { - const msg = messages[idx]; - if (msg?.role !== "assistant") continue; - count += countToolCallBlocks(getDisplayableAssistantBlocks(msg as AssistantMessage)); - } - return count; -} - -function hasDisplayableProcessMessage(message: AgentMessage): boolean { - if (message.role === "assistant") { - return getDisplayableAssistantBlocks(message as AssistantMessage).length > 0; - } - return message.role === "custom"; -} - function withAssistantBlocks( message: AssistantMessage, content: AssistantContentBlock[], @@ -846,47 +829,83 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi rendered.push(renderMessage(userIdx)); - const processIndices: number[] = []; - for (let processIdx = userIdx + 1; processIdx < finalAssistantIdx; processIdx++) { - processIndices.push(processIdx); - } - const visibleProcessIndices = processIndices.filter((processIdx) => hasDisplayableProcessMessage(messages[processIdx])); const finalAssistant = messages[finalAssistantIdx] as AssistantMessage; const finalSplit = splitFinalAssistantBlocks(finalAssistant); - const finalProcessMessage = finalSplit.processBlocks.length > 0 - ? withAssistantBlocks(finalAssistant, finalSplit.processBlocks, { omitUsage: true }) - : null; const finalAnswerMessage = finalSplit.answerBlocks.length > 0 || getAssistantErrorMessage(finalAssistant) ? withAssistantBlocks(finalAssistant, finalSplit.answerBlocks) : null; - const processCount = visibleProcessIndices.length + (finalProcessMessage ? 1 : 0); - if (processCount > 0) { - const processRefIdx = visibleProcessIndices - .map((processIdx) => visibleRefIndexByMessage.get(processIdx)) - .find((value): value is number => typeof value === "number") - ?? (finalAnswerMessage ? undefined : visibleRefIndexByMessage.get(finalAssistantIdx)); - const processGroup = ( - entryIds[index] === pendingSearchScroll.entryId) || (searchBlock && finalSplit.processBlocks.includes(searchBlock))))} - t={t} - toolCallCount={countToolCalls(messages, visibleProcessIndices) + countToolCallBlocks(finalSplit.processBlocks)} - > - {visibleProcessIndices.map((processIdx) => renderMessage(processIdx, { attachRef: false, keyPrefix: "process" }))} - {finalProcessMessage && renderMessage(finalAssistantIdx, { attachRef: false, keyPrefix: "process-final", messageOverride: finalProcessMessage, showTimestamp: false })} - - ); + let processViews: ReactNode[] = []; + let processToolCount = 0; + let processRefIdx: number | undefined; + let processKey = ""; + let revealProcess = false; + const flushProcess = () => { + if (processViews.length === 0) return; + const refIndex = processRefIdx; rendered.push(
{ messageRefs.current[processRefIdx] = el; }} + key={`process-group-${processKey}`} + ref={refIndex === undefined ? undefined : (el) => { messageRefs.current[refIndex] = el; }} > - {processGroup} + + {processViews} +
, ); + processViews = []; + processToolCount = 0; + processRefIdx = undefined; + revealProcess = false; + }; + + // Flush each process segment before its next thinking block so + // reasoning stays outside the fold without reordering the turn. + for (let processIdx = userIdx + 1; processIdx <= finalAssistantIdx; processIdx++) { + const processMessage = messages[processIdx]; + const messageKey = entryIds[processIdx] ?? processIdx; + if (processMessage.role === "custom") { + if (processViews.length === 0) processKey = String(messageKey); + revealProcess ||= Boolean(pendingSearchScroll && pendingSearchScroll.entryId === entryIds[processIdx]); + processViews.push(renderMessage(processIdx, { attachRef: false, keyPrefix: "process" })); + continue; + } + if (processMessage.role !== "assistant") continue; + const blocks = processIdx === finalAssistantIdx ? finalSplit.processBlocks : getDisplayableAssistantBlocks(processMessage); + const groups = splitThinkingBlocks(blocks); + const lastProcessGroup = groups.findLast((group) => !group.thinking); + for (const group of groups) { + const blockIndex = processMessage.content.indexOf(group.blocks[0]); + const key = `${messageKey}-${blockIndex}`; + if (group.thinking) { + flushProcess(); + const previousTimestamp = messages[processIdx - 1]?.timestamp; + const duration = processMessage.timestamp && previousTimestamp + ? Math.round((processMessage.timestamp - previousTimestamp) / 1000) + : 0; + const refIndex = visibleRefIndexByMessage.get(processIdx); + rendered.push( +
{ messageRefs.current[refIndex] = el; }}> + {group.blocks.map((block) => block.type === "thinking" && ( + 0 ? duration : undefined} /> + ))} +
, + ); + } else { + if (processViews.length === 0) processKey = key; + processRefIdx ??= visibleRefIndexByMessage.get(processIdx); + processToolCount += countToolCallBlocks(group.blocks); + revealProcess ||= Boolean(pendingSearchScroll && entryIds[processIdx] === pendingSearchScroll.entryId && (!searchBlock || group.blocks.includes(searchBlock))); + processViews.push(renderMessage(processIdx, { + attachRef: false, + keyPrefix: `process-${blockIndex}`, + messageOverride: withAssistantBlocks(processMessage, group.blocks, { omitUsage: processIdx === finalAssistantIdx || group !== lastProcessGroup }), + showTimestamp: false, + })); + } + } } + flushProcess(); if (finalAnswerMessage) { // Each tool call is stored as its own assistant entry, so the diff --git a/components/MessageView.test.mjs b/components/MessageView.test.mjs index 04886acadc..3d2f9f4497 100644 --- a/components/MessageView.test.mjs +++ b/components/MessageView.test.mjs @@ -10,6 +10,7 @@ const React = await jiti.import("react"); const { renderToStaticMarkup } = await jiti.import("react-dom/server"); const { MessageView, + ThinkingBlock, getTokenEstimateText, getToolCallInputText, replaceUserMessageText, @@ -27,6 +28,30 @@ function renderMessage(message, props = {}) { ); } +test("renders standalone thinking with the saved default and accessible disclosure state", () => { + const previousWindow = globalThis.window; + try { + for (const expanded of [false, true]) { + globalThis.window = { localStorage: { getItem: () => String(expanded) } }; + const html = renderToStaticMarkup(React.createElement( + I18nProvider, + null, + React.createElement(ThinkingBlock, { + block: { type: "thinking", thinking: "Independent reasoning" }, + blockIndex: 2, + duration: 3, + }), + )); + assert.match(html, new RegExp(`aria-expanded="${expanded}"`)); + assert.equal(html.includes("Independent reasoning"), expanded); + assert.match(html, /3s/); + } + } finally { + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + } +}); + test("marks only the matched text block after splitting thinking and the final answer", () => { const message = { role: "assistant", diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 5b8e7ae2a3..34e89f7155 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -9,6 +9,7 @@ import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, isEmptyThinkingBlock } from "@/lib/message-display"; import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch"; import { isEditToolName } from "@/lib/tool-names"; +import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference"; import { TurnWrittenFiles } from "./TurnWrittenFiles"; import type { WrittenFile } from "@/lib/turn-written-files"; import { skillExpansionToCommand } from "@/lib/slash-display"; @@ -874,7 +875,7 @@ function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent return {block.text}; } -function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { +export function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { block: ThinkingContent; duration?: number; sessionId?: string; @@ -882,30 +883,50 @@ function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { blockIndex: number; }) { const { t } = useI18n(); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(isThinkingExpandedByDefault); const [content, setContent] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const tRef = useRef(t); + tRef.current = t; - const toggle = async () => { - const nextExpanded = !expanded; - setExpanded(nextExpanded); - if (!nextExpanded || !block.deferred || content !== null) return; + // Keep already-mounted blocks in sync when the preference changes. + useEffect(() => { + const onChange = () => setExpanded(isThinkingExpandedByDefault()); + window.addEventListener(THINKING_EXPANDED_EVENT, onChange); + return () => window.removeEventListener(THINKING_EXPANDED_EVENT, onChange); + }, []); + + // Load deferred history content whenever the block is expanded. + // loadThinkingContent() memoizes in-flight promises and drops failed ones + // from its cache, so re-running this effect is cheap and a failed load can + // be retried by collapsing and expanding the block again. + useEffect(() => { + if (!expanded || !block.deferred || content !== null) return; if (!sessionId || !entryId) { - setError(t("i18n.thinkingUnavailable")); + setError(tRef.current("i18n.thinkingUnavailable")); return; } - + let cancelled = false; setLoading(true); setError(null); - try { - setContent(await loadThinkingContent(sessionId, entryId, blockIndex)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }; + loadThinkingContent(sessionId, entryId, blockIndex) + .then((value) => { + if (!cancelled) { + setContent(value); + setLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [expanded, block.deferred, content, sessionId, entryId, blockIndex]); return (
); } diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 2c3aa09d42..846e1b9906 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -20,6 +20,9 @@ export const enLocale: LocalePlugin = { "settings.themeLight": "Light", "settings.themeDark": "Dark", "settings.themeSystem": "System", + "settings.thinkingDisplay": "Thinking display", + "settings.thinkingDisplayDescription": "Choose whether model reasoning blocks are expanded when messages load.", + "settings.thinkingExpandedDefault": "Expand thinking blocks by default", "settings.languageDescription": "Choose the language used throughout the interface.", "settings.shellTool": "Shell tool", "settings.shellToolDescription": "Choose which shell the model uses for commands. Direct ! and !! commands still use Bash.", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index d977b50606..b4ab258771 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -20,6 +20,9 @@ export const zhCNLocale: LocalePlugin = { "settings.themeLight": "浅色", "settings.themeDark": "深色", "settings.themeSystem": "跟随系统", + "settings.thinkingDisplay": "思考过程显示", + "settings.thinkingDisplayDescription": "选择消息加载时模型思考块是否默认展开。", + "settings.thinkingExpandedDefault": "默认展开思考块", "settings.languageDescription": "选择整个界面使用的语言。", "settings.shellTool": "Shell 工具", "settings.shellToolDescription": "选择模型执行命令时使用的 Shell。直接输入的 ! 和 !! 命令仍使用 Bash。", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 204f381f0c..9357648e60 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -20,6 +20,9 @@ export const zhTWLocale: LocalePlugin = { "settings.themeLight": "淺色", "settings.themeDark": "深色", "settings.themeSystem": "跟隨系統", + "settings.thinkingDisplay": "思考過程顯示", + "settings.thinkingDisplayDescription": "選擇訊息載入時模型思考區塊是否預設展開。", + "settings.thinkingExpandedDefault": "預設展開思考區塊", "settings.languageDescription": "選擇整個介面使用的語言。", "settings.shellTool": "Shell 工具", "settings.shellToolDescription": "選擇模型執行命令時使用的 Shell。直接輸入的 ! 和 !! 命令仍使用 Bash。", diff --git a/lib/message-display.test.mjs b/lib/message-display.test.mjs index 6165549d6d..b518ff4061 100644 --- a/lib/message-display.test.mjs +++ b/lib/message-display.test.mjs @@ -101,6 +101,39 @@ test("keeps deferred historical thinking placeholders", async () => { ); }); +test("keeps thinking outside contiguous process segments in original order", async () => { + const { getDisplayableAssistantBlocks, splitThinkingBlocks, splitFinalAssistantBlocks } = await loadSubject(); + const message = assistant([ + { type: "thinking", thinking: "" }, + { type: "text", text: "Inspecting" }, + { type: "thinking", thinking: "", deferred: true }, + { type: "thinking", thinking: "Check the result" }, + { type: "toolCall", toolCallId: "call-1", toolName: "bash", input: {} }, + { type: "text", text: "Tool commentary" }, + { type: "thinking", thinking: "Conclude" }, + { type: "text", text: "Final answer" }, + ]); + const { processBlocks, answerBlocks } = splitFinalAssistantBlocks(message); + const groups = splitThinkingBlocks(processBlocks); + assert.deepEqual(groups.map(({ thinking, blocks }) => ({ + thinking, + indices: blocks.map((block) => message.content.indexOf(block)), + })), [ + { thinking: false, indices: [1] }, + { thinking: true, indices: [2, 3] }, + { thinking: false, indices: [4, 5] }, + { thinking: true, indices: [6] }, + ]); + assert.deepEqual([...groups.flatMap(({ blocks }) => blocks), ...answerBlocks], getDisplayableAssistantBlocks(message)); + + const simple = splitFinalAssistantBlocks(assistant([ + { type: "thinking", thinking: "Reasoning" }, + { type: "text", text: "Answer" }, + ])); + assert.equal(splitThinkingBlocks(simple.processBlocks).some(({ thinking }) => !thinking), false); + assert.deepEqual(splitThinkingBlocks([]), []); +}); + test("returns completed provider errors even when the message has no content", async () => { const { getAssistantErrorMessage } = await loadSubject(); const message = { diff --git a/lib/message-display.ts b/lib/message-display.ts index b64282c1a4..001e371fc7 100644 --- a/lib/message-display.ts +++ b/lib/message-display.ts @@ -50,3 +50,14 @@ export function splitFinalAssistantBlocks( export function countToolCallBlocks(blocks: AssistantContentBlock[]): number { return blocks.filter((block): block is ToolCallContent => block.type === "toolCall").length; } + +export function splitThinkingBlocks(blocks: AssistantContentBlock[]): { thinking: boolean; blocks: AssistantContentBlock[] }[] { + const groups: { thinking: boolean; blocks: AssistantContentBlock[] }[] = []; + for (const block of blocks) { + const thinking = block.type === "thinking"; + const previous = groups.at(-1); + if (previous?.thinking === thinking) previous.blocks.push(block); + else groups.push({ thinking, blocks: [block] }); + } + return groups; +} diff --git a/lib/thinking-expansion-preference.test.mjs b/lib/thinking-expansion-preference.test.mjs new file mode 100644 index 0000000000..bd49250135 --- /dev/null +++ b/lib/thinking-expansion-preference.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isThinkingExpandedByDefault, + setThinkingExpandedByDefault, + THINKING_EXPANDED_EVENT, +} from "./thinking-expansion-preference.ts"; + +function installWindow() { + const store = new Map(); + const listeners = new Set(); + const fakeWindow = { + localStorage: { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + }, + addEventListener: (_type, handler) => listeners.add(handler), + removeEventListener: (_type, handler) => listeners.delete(handler), + dispatchEvent: (event) => { + for (const handler of listeners) handler(event); + return true; + }, + }; + globalThis.window = fakeWindow; + return { fakeWindow, listeners }; +} + +test("defaults to collapsed when no preference is stored", () => { + installWindow(); + assert.equal(isThinkingExpandedByDefault(), false); +}); + +test("persists the preference and notifies listeners on change", () => { + installWindow(); + let notified = 0; + globalThis.window.addEventListener(THINKING_EXPANDED_EVENT, () => notified++); + + setThinkingExpandedByDefault(true); + assert.equal(isThinkingExpandedByDefault(), true); + assert.equal(notified, 1); + + setThinkingExpandedByDefault(false); + assert.equal(isThinkingExpandedByDefault(), false); + assert.equal(notified, 2); +}); + +test("returns false when window is unavailable (SSR)", () => { + const original = globalThis.window; + delete globalThis.window; + try { + assert.equal(isThinkingExpandedByDefault(), false); + } finally { + if (original !== undefined) globalThis.window = original; + } +}); diff --git a/lib/thinking-expansion-preference.ts b/lib/thinking-expansion-preference.ts new file mode 100644 index 0000000000..dc1c522d58 --- /dev/null +++ b/lib/thinking-expansion-preference.ts @@ -0,0 +1,15 @@ +const STORAGE_KEY = "pi-thinking-expanded"; + +// Broadcast so already-mounted ThinkingBlock instances update when the +// preference changes in the settings panel. +export const THINKING_EXPANDED_EVENT = "pi-thinking-expanded-changed"; + +export function isThinkingExpandedByDefault(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(STORAGE_KEY) === "true"; +} + +export function setThinkingExpandedByDefault(expanded: boolean): void { + window.localStorage.setItem(STORAGE_KEY, String(expanded)); + window.dispatchEvent(new Event(THINKING_EXPANDED_EVENT)); +}