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 ?? "")}
+