Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -1726,14 +1735,22 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
/>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
<Show when={!mergedIntoThinking(item() as SessionMessageAssistantTool, message())}>
<ToolPart part={item() as SessionMessageAssistantTool} />
</Show>
</Match>
</Switch>
)}
</Show>
)
}

/** 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
Expand Down Expand Up @@ -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 (
<Show when={parts().length > 0}>
Expand All @@ -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)}
Expand All @@ -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()}
<Show when={props.completed && !expanded() && latest()}>: {latest()}</Show>
<Show when={props.completed && parts().length > 1}> · {parts().length} steps</Show>
<Show when={props.completed && duration()}> · {Locale.duration(duration())}</Show>
Expand Down
46 changes: 45 additions & 1 deletion packages/tui/src/routes/session/message-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import type {
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
} from "@opencode/client"
import { Spinner } from "../../component/spinner"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
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"

Expand Down Expand Up @@ -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)
Expand All @@ -62,6 +73,7 @@ export function ReasoningPart(props: {
done={isDone()}
title={inMinimal() && !expanded() ? summary().title : null}
duration={isDone() ? Locale.duration(duration()) : undefined}
pendingPatch={mergedPatch()}
/>
</box>
</box>
Expand Down Expand Up @@ -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 = () =>
Expand All @@ -117,7 +159,9 @@ function ReasoningHeader(props: {
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
<Spinner color={fg()}>
{(props.title ? "Thinking: " + props.title : "Thinking") + (props.pendingPatch ?? "")}
</Spinner>
</box>
</Match>
<Match when={true}>
Expand Down
Loading