From b54618c7cef1c49f7028a2057e01396039da2e47 Mon Sep 17 00:00:00 2001 From: Theodore Blackman Date: Fri, 6 Mar 2026 21:00:13 -0800 Subject: [PATCH] feat: show model thinking/reasoning in task tree viewer When a sub-task's model is emitting chain-of-thought reasoning, the task tree viewer now shows a "thinking" entry at the end of the tool call list. Selecting it displays the reasoning text in the detail pane, streaming in live and showing the most recent tokens when content exceeds available space. Co-Authored-By: Claude Opus 4.6 --- .../cli/cmd/tui/component/task-tree-pane.tsx | 147 ++++++++++++++++-- 1 file changed, 132 insertions(+), 15 deletions(-) diff --git a/packages/voltcode/src/cli/cmd/tui/component/task-tree-pane.tsx b/packages/voltcode/src/cli/cmd/tui/component/task-tree-pane.tsx index 93d60b99e..68f6c9b76 100644 --- a/packages/voltcode/src/cli/cmd/tui/component/task-tree-pane.tsx +++ b/packages/voltcode/src/cli/cmd/tui/component/task-tree-pane.tsx @@ -36,6 +36,12 @@ interface ToolCallInfo { metadata?: Record } +interface ThinkingInfo { + text: string + isStreaming: boolean + startTime?: number +} + // Flatten jobs into a list for display function flattenJobs(jobs: JobTree[]): FlattenResult { if (jobs.length === 0) return { items: [], maxDepth: 0 } @@ -429,12 +435,13 @@ function ToolCallDetailView(props: { call: ToolCallInfo; tick: number; width: nu function SelectedTaskDetails(props: { item: FlattenedItem | null - sessionData: { prompt: string; toolCalls: ToolCallInfo[] } + sessionData: { prompt: string; toolCalls: ToolCallInfo[]; thinking: ThinkingInfo | null } recentToolCalls: ToolCallInfo[] focusSection: "tree" | "toolcalls" selectedToolIndex: number paneWidth: number isJobRoot: boolean + thinking: ThinkingInfo | null onToolCallClick?: (index: number) => void }) { const { theme } = useTheme() @@ -530,11 +537,80 @@ function SelectedTaskDetails(props: { ) }} + + {(thinking) => { + const thinkingIndex = () => props.recentToolCalls.length + const isSelected = () => props.focusSection === "toolcalls" && thinkingIndex() === props.selectedToolIndex + const bgColor = () => (isSelected() ? RGBA.fromInts(40, 40, 40, 255) : RGBA.fromInts(0, 0, 0, 0)) + const label = () => { + const preview = thinking().text.replace(/\n/g, " ").slice(0, Math.max(20, props.paneWidth - 30)) + return preview ? `${preview}${thinking().text.length > props.paneWidth - 30 ? "..." : ""}` : "" + } + return ( + props.onToolCallClick?.(thinkingIndex())} + > + + {thinking().isStreaming ? "◐" : "●"} + + + {"thinking".padEnd(12)} + + {label()} + + ) + }} + ) } +// Thinking/reasoning detail view — shows tail of CoT, streams in live +function ThinkingDetailView(props: { thinking: ThinkingInfo; tick: number; width: number; height: number }) { + const { theme } = useTheme() + + const lines = createMemo(() => { + // Force reactivity on tick so we re-render while streaming + void props.tick + const text = props.thinking.text + if (!text) return props.thinking.isStreaming ? ["(thinking...)"] : ["(no reasoning content)"] + + const lineWidth = Math.max(40, props.width - 2) + // Wrap all text into lines + const allLines = wrapText(text, lineWidth, 10000) + // Show only what fits in the available height, biased toward the end + const maxLines = Math.max(1, props.height - 3) + if (allLines.length <= maxLines) return allLines + return allLines.slice(-maxLines) + }) + + const duration = () => { + if (!props.thinking.startTime) return "" + return formatLiveDuration(props.thinking.startTime, props.thinking.isStreaming ? undefined : Date.now(), props.tick) + } + + return ( + + + + Thinking + {props.thinking.isStreaming ? " (streaming)" : ""} + + + {duration()} + + + + {(line) => {line}} + + + ) +} + export function TaskTreePane(props: TaskTreePaneProps) { const { data } = useTaskTree() const { theme } = useTheme() @@ -593,7 +669,7 @@ export function TaskTreePane(props: TaskTreePaneProps) { // For job-root, show only the root tool call if (!item || item.type === "job-root") { const job = item?.job - if (!job) return { prompt: "", toolCalls: [] as ToolCallInfo[] } + if (!job) return { prompt: "", toolCalls: [] as ToolCallInfo[], thinking: null as ThinkingInfo | null } // For job root, return just the job's tool call info const toolCall: ToolCallInfo = { @@ -606,7 +682,7 @@ export function TaskTreePane(props: TaskTreePaneProps) { output: job.toolOutput, metadata: {}, } - return { prompt: job.toolTitle, toolCalls: [toolCall] } + return { prompt: job.toolTitle, toolCalls: [toolCall], thinking: null as ThinkingInfo | null } } // For job-child, show the child session's tool calls @@ -629,8 +705,9 @@ export function TaskTreePane(props: TaskTreePaneProps) { } } - // Collect all tool calls from all assistant messages + // Collect all tool calls and find reasoning parts from all assistant messages const toolCalls: ToolCallInfo[] = [] + let thinking: ThinkingInfo | null = null for (const msg of messages) { if (msg.role === "assistant") { const parts = sync.data.part[msg.id] ?? [] @@ -649,11 +726,19 @@ export function TaskTreePane(props: TaskTreePaneProps) { metadata: toolPart.state.metadata, }) } + if (part.type === "reasoning") { + const rp = part as ReasoningPart + thinking = { + text: rp.text, + isStreaming: !rp.time?.end, + startTime: rp.time?.start, + } + } } } } - return { prompt, toolCalls } + return { prompt, toolCalls, thinking } }) // Get last 10 tool calls, newest at bottom @@ -707,14 +792,27 @@ export function TaskTreePane(props: TaskTreePaneProps) { } } + // Total selectable items in the tool call section (tool calls + optional thinking) + const toolSectionCount = createMemo(() => { + const count = recentToolCalls().length + const thinking = selectedSessionData().thinking + return thinking ? count + 1 : count + }) + + // Whether the selected tool index points to the thinking entry + const isThinkingSelected = createMemo(() => { + const thinking = selectedSessionData().thinking + return thinking && selectedToolIndex() === recentToolCalls().length + }) + // Tool call list navigation function moveToolCall(direction: number) { - const calls = recentToolCalls() - if (calls.length === 0) return + const total = toolSectionCount() + if (total === 0) return let next = selectedToolIndex() + direction if (next < 0) next = 0 - if (next >= calls.length) next = calls.length - 1 + if (next >= total) next = total - 1 setSelectedToolIndex(next) } @@ -742,8 +840,8 @@ export function TaskTreePane(props: TaskTreePaneProps) { if (evt.name === "tab") { evt.preventDefault() if (focusSection() === "tree") { - const calls = recentToolCalls() - if (calls.length > 0) { + const total = toolSectionCount() + if (total > 0) { setFocusSection("toolcalls") if (selectedToolIndex() < 0) setSelectedToolIndex(0) } @@ -796,8 +894,8 @@ export function TaskTreePane(props: TaskTreePaneProps) { const items = flattened().items if (items.length > 0) setSelectedIndex(items.length - 1) } else { - const calls = recentToolCalls() - if (calls.length > 0) setSelectedToolIndex(calls.length - 1) + const total = toolSectionCount() + if (total > 0) setSelectedToolIndex(total - 1) } } }) @@ -873,6 +971,7 @@ export function TaskTreePane(props: TaskTreePaneProps) { selectedToolIndex={selectedToolIndex()} paneWidth={paneWidth()} isJobRoot={isJobRootSelected()} + thinking={selectedSessionData().thinking} onToolCallClick={selectToolCall} /> @@ -883,10 +982,22 @@ export function TaskTreePane(props: TaskTreePaneProps) { Select a job or tool call to view details} + when={isThinkingSelected() && selectedSessionData().thinking} + fallback={ + Select a job or tool call to view details} + > + + + } > - + @@ -920,3 +1031,9 @@ interface ToolPart { time?: { start: number; end?: number } } } + +interface ReasoningPart { + type: "reasoning" + text: string + time?: { start: number; end?: number } +}