diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7f7caf551f63..c77b0138263c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -110,7 +110,16 @@ import { SessionLocationMissing } from "./location-missing" import { isRecord } from "../../util/record" import { createHistoryPrepend } from "./history" import { context, use, type PendingAction } from "./render-context" -import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts" +import { + INLINE_TOOL_ICON_WIDTH, + InlineToolRow, + ReasoningPart, + patchTarget, + pendingPatch, + reasoningContent, + reasoningPending, + TextPart, +} from "./message-parts" import { groupRefs } from "./grouping/session" export { InlineToolRow } from "./message-parts" @@ -1726,7 +1735,9 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string) /> - + + + )} @@ -1734,6 +1745,12 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string) ) } +/** The running patch row is hidden while a thinking spinner shows the merged progress line. */ +function mergedIntoThinking(part: SessionMessageAssistantTool, message: SessionMessageInfo | undefined) { + if (message?.type !== "assistant" || !reasoningPending(message)) return false + return pendingPatch(message)?.id === part.id +} + function SessionReasoningGroupView(props: { refs: PartRef[] completed: boolean @@ -1770,6 +1787,16 @@ function SessionReasoningGroupView(props: { return total + (start === undefined || end === undefined ? 0 : Math.max(0, end - start)) }, 0), ) + const mergedPatch = createMemo(() => { + if (props.completed) return "" + const last = props.refs.at(-1) + const message = last ? props.message(last.messageID) : undefined + if (message?.type !== "assistant") return "" + const part = pendingPatch(message) + if (!part) return "" + const target = patchTarget(part) + return target ? ` · Patch ${target}` : " · Patch" + }) return ( 0}> @@ -1793,7 +1820,7 @@ function SessionReasoningGroupView(props: { ) } complete={props.completed} - pending={latest() ? `Thinking: ${latest()}` : "Thinking"} + pending={(latest() ? `Thinking: ${latest()}` : "Thinking") + mergedPatch()} spinner={!props.completed} onMouseOver={() => setHover(true)} onMouseOut={() => setHover(false)} @@ -1802,7 +1829,7 @@ function SessionReasoningGroupView(props: { setExpanded((value) => !value) }} > - {props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"} + {(props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking") + mergedPatch()} : {latest()} 1}> · {parts().length} steps · {Locale.duration(duration())} diff --git a/packages/tui/src/routes/session/message-parts.tsx b/packages/tui/src/routes/session/message-parts.tsx index 97ddd09ea3b3..0fb40197b92c 100644 --- a/packages/tui/src/routes/session/message-parts.tsx +++ b/packages/tui/src/routes/session/message-parts.tsx @@ -5,6 +5,7 @@ import type { SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, + SessionMessageAssistantTool, } from "@opencode/client" import { Spinner } from "../../component/spinner" import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme" @@ -12,6 +13,7 @@ import { reasoningSummary } from "../../context/thinking" import { usePlugin } from "../../plugin/context" import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" +import { canonicalToolName } from "../../util/tool-display" import { use } from "./render-context" import { generateThinkingSyntax } from "./thinking-syntax" @@ -41,6 +43,15 @@ export function ReasoningPart(props: { return end === undefined ? 0 : Math.max(0, end - start) }) const summary = createMemo(() => reasoningSummary(content())) + // A running patch is absorbed into this header while both are active, so the + // timeline shows one merged progress line instead of two spinners. + const mergedPatch = createMemo(() => { + if (isDone()) return "" + const part = pendingPatch(props.message) + if (!part) return "" + const target = patchTarget(part) + return target ? ` · Patch ${target}` : " · Patch" + }) const toggle = () => { if (!inMinimal()) return setExpanded((prev) => !prev) @@ -62,6 +73,7 @@ export function ReasoningPart(props: { done={isDone()} title={inMinimal() && !expanded() ? summary().title : null} duration={isDone() ? Locale.duration(duration()) : undefined} + pendingPatch={mergedPatch()} /> @@ -95,12 +107,42 @@ export function reasoningContent(part: SessionMessageAssistantReasoning) { return part.text.replace("[REDACTED]", "").trim() } +function patchText(input: unknown) { + if (!input || typeof input !== "object" || !("patchText" in input)) return + return typeof input.patchText === "string" ? input.patchText : undefined +} + +/** The running patch tool in a message, if any. Thinking absorbs it into one progress line. */ +export function pendingPatch(message: SessionMessageAssistant) { + return message.content.find( + (part): part is SessionMessageAssistantTool => + part.type === "tool" && + canonicalToolName(part.name) === "patch" && + (part.state.status === "streaming" || part.state.status === "running"), + ) +} + +export function patchTarget(part: SessionMessageAssistantTool) { + const patch = patchText(part.state.input) + if (!patch) return "" + return patch.match(/\*\*\* (?:Add|Update|Delete) File: ([^\r\n]+)/)?.[1]?.trim() ?? "" +} + +/** Whether a message still shows a thinking spinner that a concurrent patch would duplicate. */ +export function reasoningPending(message: SessionMessageAssistant) { + if (message.time.completed !== undefined) return false + return message.content.some( + (part) => part.type === "reasoning" && part.time?.completed === undefined && reasoningContent(part) !== "", + ) +} + function ReasoningHeader(props: { toggleable: boolean open: boolean done: boolean title: string | null duration?: string + pendingPatch?: string }) { const theme = useTheme() const fg = () => @@ -117,7 +159,9 @@ function ReasoningHeader(props: { - {props.title ? "Thinking: " + props.title : "Thinking"} + + {(props.title ? "Thinking: " + props.title : "Thinking") + (props.pendingPatch ?? "")} +