From 8a32d45223423c248f6c18d94caa013d4440d0bb Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 00:59:34 +0000 Subject: [PATCH 01/10] chore(tui): plan grouped opaque reasoning lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #53. Plan only; this commit changes no code. Problem Each encrypted reasoning part (empty text, provider metadata set) renders its own "Thought · " line in ReasoningPartView. Approach 1. New pure module src/routes/session/opaque-reasoning.ts. - isOpaqueReasoning and reasoningText: the predicate ReasoningPartView already uses, moved so the grouping and the view share it. - groupOpaqueReasoning(parts): a run of 2+ opaque parts becomes one { type: "opaque-reasoning", parts } row at the first member's position. A lone opaque part passes through as the same object. Breakers are tool parts, visible text, and visible (non-opaque) reasoning. Parts that render no line (step markers, blank text, empty reasoning) are skipped and do not end a run. 2. AssistantMessage (index.tsx) renders groupOpaqueReasoning(props.parts). Its parts come from the sync store, so the live stream and the rendered history share one path. 3. OpaqueReasoningRunView renders one ReasoningHeader. Count is the number of parts in the run, including one still in flight. Duration is the sum of finished parts. Done when every part is done. While one is in flight the spinner keeps the same count and duration label. 4. The per-part done, unresolved, and duration logic moves into one helper used by ReasoningPartView and the run view, so the two cannot diverge. Tests (red before green) - test/routes/session/opaque-reasoning.test.ts: a run, a lone part, tool and text breaks, invisible parts, a tool error, mixed encrypted and plain reasoning, and a [REDACTED]-only placeholder. - test/routes/session/opaque-reasoning-render.test.tsx: AssistantMessage fed through the sync store. Covers a loaded run, parts arriving live, a tool break, and a message error ending the run. Decisions and non-goals - A run stays within one assistant message. A message's error renders after its parts, so it ends a run by position. - The summed duration is the sum of per-part durations, which is what the old separate lines showed. - Transcript export (util/transcript.ts) and the Luna session review (rest of #53) are out of scope. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA From c34a38770c0db4af08a1250e0dd85aa3d777b95a Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 01:10:56 +0000 Subject: [PATCH 02/10] feat(tui): group encrypted reasoning parts into runs Refs #53 groupOpaqueReasoning collapses each run of consecutive encrypted reasoning parts into one row. A tool part or visible text ends a run. Parts that render no line, such as step markers and blank text, are skipped without ending one. A lone encrypted part passes through as the same object, so it renders exactly as it does today. isOpaqueReasoning and reasoningText move here from ReasoningPartView, so the view and the grouping apply one rule. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- .../src/routes/session/opaque-reasoning.ts | 39 +++++ .../routes/session/opaque-reasoning.test.ts | 154 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 packages/tui/src/routes/session/opaque-reasoning.ts create mode 100644 packages/tui/test/routes/session/opaque-reasoning.test.ts diff --git a/packages/tui/src/routes/session/opaque-reasoning.ts b/packages/tui/src/routes/session/opaque-reasoning.ts new file mode 100644 index 000000000000..dd1fef62495b --- /dev/null +++ b/packages/tui/src/routes/session/opaque-reasoning.ts @@ -0,0 +1,39 @@ +import type { Part, ReasoningPart } from "@opencode-ai/sdk/v2" + +export type OpaqueReasoningRun = { type: "opaque-reasoning"; parts: ReasoningPart[] } + +// OpenRouter encrypts some reasoning blocks and marks them with a placeholder. +export function reasoningText(part: ReasoningPart) { + return part.text.replace("[REDACTED]", "").trim() +} + +// Encrypted reasoning from OpenAI Responses has provider metadata and no text, +// so it has nothing to show but a duration. +export function isOpaqueReasoning(part: ReasoningPart) { + return !reasoningText(part) && Boolean(part.metadata) +} + +// A run ends at a part that renders a line of its own. Parts that render no +// line are skipped without ending the run, so a step boundary between two +// encrypted parts still shows as one line. +export function groupOpaqueReasoning(parts: readonly Part[]): Array { + const breakers = parts.flatMap((part, index) => (breaksRun(part) ? [index] : [])) + const starts = [0, ...breakers.map((index) => index + 1)] + return starts.flatMap((start, index) => { + const end = breakers[index] ?? parts.length + return [...collapseRun(parts.slice(start, end)), ...parts.slice(end, end + 1)] + }) +} + +function breaksRun(part: Part) { + if (part.type === "tool") return true + if (part.type === "text") return part.text.trim() !== "" + if (part.type === "reasoning") return reasoningText(part) !== "" + return false +} + +function collapseRun(segment: readonly Part[]): Array { + const run = segment.flatMap((part) => (part.type === "reasoning" && isOpaqueReasoning(part) ? [part] : [])) + if (run.length < 2) return run + return [{ type: "opaque-reasoning", parts: run }] +} diff --git a/packages/tui/test/routes/session/opaque-reasoning.test.ts b/packages/tui/test/routes/session/opaque-reasoning.test.ts new file mode 100644 index 000000000000..c2e1fd759663 --- /dev/null +++ b/packages/tui/test/routes/session/opaque-reasoning.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test" +import type { Part, ReasoningPart } from "@opencode-ai/sdk/v2" +import { groupOpaqueReasoning, isOpaqueReasoning } from "../../../src/routes/session/opaque-reasoning" + +// Shapes follow session/processor.ts: encrypted reasoning receives no +// reasoning-delta text, and its provider metadata is written on reasoning-end. +const ids = { sessionID: "ses_test", messageID: "msg_test" } + +function encrypted(id: string): ReasoningPart { + return { + ...ids, + id, + type: "reasoning", + text: "", + metadata: { openai: { itemId: `rs_${id}` } }, + time: { start: 1, end: 2 }, + } +} + +function summarized(id: string, text: string): ReasoningPart { + return { ...ids, id, type: "reasoning", text, time: { start: 1, end: 2 } } +} + +function emptyReasoning(id: string): ReasoningPart { + return { ...ids, id, type: "reasoning", text: "", time: { start: 1, end: 2 } } +} + +function text(id: string, value: string): Part { + return { ...ids, id, type: "text", text: value } +} + +function tool(id: string): Part { + return { + ...ids, + id, + type: "tool", + callID: `call_${id}`, + tool: "bash", + state: { status: "completed", input: {}, output: "ok", title: "", metadata: {}, time: { start: 1, end: 2 } }, + } +} + +function failedTool(id: string): Part { + return { + ...ids, + id, + type: "tool", + callID: `call_${id}`, + tool: "apply_patch", + state: { status: "error", input: {}, error: "Patch failed", time: { start: 1, end: 2 } }, + } +} + +function stepStart(id: string): Part { + return { ...ids, id, type: "step-start" } +} + +function stepFinish(id: string): Part { + return { + ...ids, + id, + type: "step-finish", + reason: "tool-calls", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +describe("groupOpaqueReasoning", () => { + test("collapses a run of consecutive encrypted parts into one row", () => { + const parts = [encrypted("a"), encrypted("b"), encrypted("c"), encrypted("d")] + + expect(groupOpaqueReasoning(parts)).toEqual([{ type: "opaque-reasoning", parts }]) + }) + + test("keeps a lone encrypted part as the original part object", () => { + const part = encrypted("a") + + const rows = groupOpaqueReasoning([part]) + + expect(rows).toHaveLength(1) + expect(rows[0]).toBe(part) + }) + + test("a tool call ends the run, and the next encrypted part starts a new one", () => { + const a = encrypted("a") + const b = encrypted("b") + const call = tool("call") + const c = encrypted("c") + + expect(groupOpaqueReasoning([a, b, call, c])).toEqual([{ type: "opaque-reasoning", parts: [a, b] }, call, c]) + }) + + test("visible text ends the run, and the next encrypted part starts a new one", () => { + const a = encrypted("a") + const b = encrypted("b") + const answer = text("answer", "Here is the result.") + const c = encrypted("c") + const d = encrypted("d") + + expect(groupOpaqueReasoning([a, b, answer, c, d])).toEqual([ + { type: "opaque-reasoning", parts: [a, b] }, + answer, + { type: "opaque-reasoning", parts: [c, d] }, + ]) + }) + + test("a failed tool call ends the run", () => { + const a = encrypted("a") + const b = encrypted("b") + const failed = failedTool("failed") + const c = encrypted("c") + + expect(groupOpaqueReasoning([a, failed, b, c])).toEqual([a, failed, { type: "opaque-reasoning", parts: [b, c] }]) + }) + + test("parts that render no line do not end a run", () => { + const a = encrypted("a") + const b = encrypted("b") + + expect( + groupOpaqueReasoning([a, stepFinish("finish"), stepStart("start"), text("blank", " \n"), emptyReasoning("e"), b]), + ).toEqual([{ type: "opaque-reasoning", parts: [a, b] }]) + }) + + test("summarized reasoning ends the run and renders as its own part", () => { + const a = encrypted("a") + const b = encrypted("b") + const plan = summarized("plan", "Reading the config first") + const c = encrypted("c") + + expect(groupOpaqueReasoning([a, b, plan, c])).toEqual([{ type: "opaque-reasoning", parts: [a, b] }, plan, c]) + }) + + test("a [REDACTED] placeholder with metadata is still encrypted and joins the run", () => { + const a = encrypted("a") + const redacted: ReasoningPart = { ...encrypted("redacted"), text: "[REDACTED]" } + const b = encrypted("b") + + expect(groupOpaqueReasoning([a, redacted, b])).toEqual([{ type: "opaque-reasoning", parts: [a, redacted, b] }]) + }) + + test("returns no rows for no parts", () => { + expect(groupOpaqueReasoning([])).toEqual([]) + }) +}) + +describe("isOpaqueReasoning", () => { + test("requires provider metadata and no visible text", () => { + expect(isOpaqueReasoning(encrypted("a"))).toBe(true) + expect(isOpaqueReasoning(summarized("s", "Reading the config"))).toBe(false) + expect(isOpaqueReasoning(emptyReasoning("e"))).toBe(false) + }) +}) From 1fc92edb9020dd02b4164ba85b895015be78fd84 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 01:10:56 +0000 Subject: [PATCH 03/10] feat(tui): render each encrypted reasoning run as one updating line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #53 AssistantMessageView renders groupOpaqueReasoning over the message parts. The sync store feeds those parts for both the live stream and loaded history, so both get the same line. A run shows "Thought · ". The count includes a part still in flight, and the duration sums finished parts. While any part is open the line keeps the spinner. ReasoningPartView and the run view share reasoningState, so the done and unresolved rules cannot drift between a single part and a run. AssistantMessage is renamed to AssistantMessageView and exported for the render test. The old name clashed with the type-only AssistantMessage import once exported. It is an internal component with no other readers. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- packages/tui/src/routes/session/index.tsx | 103 ++++-- .../session/opaque-reasoning-render.test.tsx | 319 ++++++++++++++++++ 2 files changed, 389 insertions(+), 33 deletions(-) create mode 100644 packages/tui/test/routes/session/opaque-reasoning-render.test.tsx diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index dba2cd71bfc0..1a57c027a23b 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -70,6 +70,7 @@ import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { AssistantModelLabel } from "./message-model" +import { groupOpaqueReasoning, isOpaqueReasoning, reasoningText } from "./opaque-reasoning" import type { SessionStepMessage } from "../../util/model" import { formatTranscript } from "../../util/transcript" import { sessionEpilogue } from "../../util/presentation" @@ -1386,7 +1387,7 @@ export function Session() { /> - groupOpaqueReasoning(props.parts)) + const childShortcut = useCommandShortcut("session.child.first") const backgroundShortcut = useCommandShortcut("session.background") return ( <> - + {(part, index) => { + if (part.type === "opaque-reasoning") + return const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) return ( { - // OpenRouter encrypts some reasoning blocks; drop the placeholder. - return props.part.text.replace("[REDACTED]", "").trim() - }) - const opaque = createMemo(() => !content() && Boolean(props.part.metadata)) + const content = createMemo(() => reasoningText(props.part)) + const opaque = createMemo(() => isOpaqueReasoning(props.part)) const status = createMemo(() => assistantStatus(sync, props.message)) - const unresolved = createMemo( - () => - props.part.time.end === undefined && - props.message.time.completed === undefined && - props.message.error === undefined && - status() === "unknown", - ) - const isDone = createMemo( - () => - props.part.time.end !== undefined || - props.message.time.completed !== undefined || - props.message.error !== undefined || - status() === "failed" || - unresolved(), - ) + const state = createMemo(() => reasoningState(props.part, props.message, status())) const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") - const duration = createMemo(() => { - const end = props.part.time.end - return end === undefined ? 0 : Math.max(0, end - props.part.time.start) - }) const summary = createMemo(() => reasoningSummary(content())) const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme)) @@ -1761,10 +1746,10 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m @@ -1786,6 +1771,51 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m ) } +function OpaqueReasoningRunView(props: { parts: ReasoningPart[]; message: AssistantMessage }) { + const ctx = use() + const sync = useSync() + const status = createMemo(() => assistantStatus(sync, props.message)) + const states = createMemo(() => props.parts.map((part) => reasoningState(part, props.message, status()))) + const duration = createMemo(() => states().reduce((total, state) => total + state.duration, 0)) + + return ( + alwaysSeparate.add(el)} + paddingLeft={3} + marginTop={1} + flexDirection="column" + flexShrink={0} + > + state.done)} + unknown={states().some((state) => state.unresolved)} + title={null} + duration={Locale.duration(duration())} + encrypted + count={props.parts.length} + /> + + ) +} + +function reasoningState(part: ReasoningPart, message: AssistantMessage, status: ReturnType) { + const unresolved = + part.time.end === undefined && + message.time.completed === undefined && + message.error === undefined && + status === "unknown" + const done = + part.time.end !== undefined || + message.time.completed !== undefined || + message.error !== undefined || + status === "failed" || + unresolved + const end = part.time.end + return { done, unresolved, duration: end === undefined ? 0 : Math.max(0, end - part.time.start) } +} + function ReasoningHeader(props: { toggleable: boolean open: boolean @@ -1794,14 +1824,19 @@ function ReasoningHeader(props: { title: string | null duration?: string encrypted?: boolean + count?: number }) { const { theme } = useTheme() const fg = () => props.open ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity) : theme.warning + // A run shows its count and duration while it is still in flight, so the line + // updates as each part arrives. + const encryptedLabel = () => + `Thought${props.count ? ` ${props.count}` : ""}${props.duration ? ` · ${props.duration}` : ""}` const completed = () => { - if (props.encrypted) return `Thought${props.duration ? ` · ${props.duration}` : ""}` + if (props.encrypted) return encryptedLabel() const detail = [props.title, props.duration].filter(Boolean).join(" · ") return `${props.toggleable ? (props.open ? "- " : "+ ") : ""}Thought${detail ? `: ${detail}` : ""}` } @@ -1815,7 +1850,9 @@ function ReasoningHeader(props: { - {props.title ? "Thinking: " + props.title : "Thinking"} + + {props.count ? encryptedLabel() : props.title ? "Thinking: " + props.title : "Thinking"} + diff --git a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx new file mode 100644 index 000000000000..b783c7d4fdd4 --- /dev/null +++ b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx @@ -0,0 +1,319 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { expect, test } from "bun:test" +import { createSignal, For, onCleanup, Show } from "solid-js" +import { useRenderer } from "@opentui/solid" +import type { AssistantMessage, GlobalEvent, Part, ReasoningPart } from "@opencode-ai/sdk/v2" +import { resolve, TuiConfigProvider } from "../../../src/config" +import { LocalProvider } from "../../../src/context/local" +import { LocationProvider } from "../../../src/context/location" +import { RouteProvider } from "../../../src/context/route" +import { ThemeProvider } from "../../../src/context/theme" +import { useSync } from "../../../src/context/sync" +import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap" +import { AssistantMessageView, SessionContext } from "../../../src/routes/session" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" +import { directory } from "../../fixture/tui-sdk" +import { tmpdir } from "../../fixture/fixture" +import { SPINNER_FRAMES } from "../../../src/component/spinner" +import { mount, wait } from "../../cli/cmd/tui/sync-fixture" + +const sessionID = "ses_opaque_render" + +const assistant = { + sessionID, + role: "assistant" as const, + agent: "build", + modelID: "model", + providerID: "test", + mode: "build", + parentID: "msg_user", + path: { cwd: directory, root: directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, +} + +function message(id: string, extra: Partial = {}): AssistantMessage { + return { ...assistant, id, ...extra } +} + +function partID(messageID: string, index: number) { + return `prt_${messageID}_${String(index).padStart(3, "0")}` +} + +// Shapes follow session/processor.ts: encrypted reasoning has provider metadata and no text. +function encrypted(messageID: string, index: number, time: ReasoningPart["time"]): ReasoningPart { + return { + id: partID(messageID, index), + sessionID, + messageID, + type: "reasoning", + text: "", + metadata: { openai: { itemId: `rs_${index}` } }, + time, + } +} + +function text(messageID: string, index: number, value: string): Part { + return { id: partID(messageID, index), sessionID, messageID, type: "text", text: value } +} + +function tool(messageID: string, index: number): Part { + return { + id: partID(messageID, index), + sessionID, + messageID, + type: "tool", + callID: `call_${index}`, + tool: "bash", + state: { + status: "completed", + input: { command: "ls" }, + output: "ok", + title: "ls", + metadata: {}, + time: { start: 1, end: 2 }, + }, + } +} + +function global(payload: GlobalEvent["payload"]): GlobalEvent { + return { directory: "/tmp/other", project: "proj_test", payload } +} + +function partUpdated(part: Part) { + return global({ + id: crypto.randomUUID(), + type: "message.part.updated", + properties: { sessionID, part, time: 0 }, + }) +} + +// Mirrors the session route's message loop: each assistant message reads its parts from the sync store. +function Transcript(props: { messages: AssistantMessage[] }) { + const sync = useSync() + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = resolve({}, { terminalSuspend: false }) + onCleanup(registerOpencodeKeymap(keymap, renderer, config)) + const sessionContext = { + width: 100, + sessionID, + conceal: () => false, + thinkingMode: () => "show" as const, + showThinking: () => true, + showTimestamps: () => false, + showDetails: () => true, + showGenericToolOutput: () => false, + diffWrapMode: () => "word" as const, + providers: () => new Map(), + sync, + tui: config, + } + + return ( + + + + + + + + + + + + {(item) => ( + + )} + + + + + + + + + + + + ) +} + +function thoughtLines(frame: string) { + return frame.split("\n").filter((line) => line.includes("Thought")) +} + +// Polls the rendered frame until it shows what the test expects, bounded so a wrong frame still fails. +async function frameWhen(app: Awaited>["app"], ready: (frame: string) => boolean) { + const start = Date.now() + while (true) { + await app.renderOnce() + const frame = app.captureCharFrame() + if (ready(frame)) return frame + if (Date.now() - start > 2000) throw new Error(`timed out waiting for the expected frame:\n${frame}`) + await Bun.sleep(10) + } +} + +test("history: a loaded run of encrypted parts renders one count line", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const [shown, setShown] = createSignal(false) + const history = message("msg_history") + const parts = [ + encrypted("msg_history", 1, { start: 1000, end: 2000 }), + encrypted("msg_history", 2, { start: 3000, end: 5000 }), + encrypted("msg_history", 3, { start: 6000, end: 9000 }), + encrypted("msg_history", 4, { start: 9500, end: 11300 }), + ] + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ( + + + + )) + + try { + emit(global({ id: "evt_history_message", type: "message.updated", properties: { sessionID, info: history } })) + for (const part of parts) { + emit(partUpdated(part)) + } + await wait(() => sync.data.part[history.id]?.length === parts.length) + + setShown(true) + const frame = await frameWhen(app, (rendered) => thoughtLines(rendered).length > 0) + + const lines = thoughtLines(frame) + expect(lines).toHaveLength(1) + expect(lines[0]).toContain("Thought 4 · 7.8s") + } finally { + app.renderer.destroy() + } +}) + +test("live: a run updates in place as parts arrive", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const live = message("msg_live") + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + emit(global({ id: "evt_live_message", type: "message.updated", properties: { sessionID, info: live } })) + + const first = encrypted("msg_live", 1, { start: 1000, end: 2000 }) + emit(partUpdated(first)) + await wait(() => sync.data.part[live.id]?.length === 1) + const single = await frameWhen(app, (rendered) => thoughtLines(rendered).length > 0) + expect(thoughtLines(single)).toEqual([expect.stringContaining("Thought · 1.0s")]) + + const second = encrypted("msg_live", 2, { start: 3000, end: 5000 }) + emit(partUpdated(second)) + await wait(() => sync.data.part[live.id]?.length === 2) + const pair = thoughtLines(await frameWhen(app, (rendered) => rendered.includes("Thought 2 · 3.0s"))) + expect(pair).toHaveLength(1) + + emit(global({ id: "evt_live_busy", type: "session.status", properties: { sessionID, status: { type: "busy" } } })) + const third = encrypted("msg_live", 3, { start: 6000, end: 9000 }) + const inFlight = { ...third, time: { start: 6000 } } + emit(partUpdated(inFlight)) + await wait(() => sync.data.part[live.id]?.length === 3) + const pending = thoughtLines(await frameWhen(app, (rendered) => rendered.includes("Thought 3 · 3.0s"))) + expect(pending).toHaveLength(1) + expect(SPINNER_FRAMES.some((glyph) => pending[0]!.includes(glyph))).toBe(true) + + emit(partUpdated(third)) + await wait(() => { + const stored = sync.data.part[live.id]?.find((part) => part.id === third.id) + return stored?.type === "reasoning" && stored.time.end === 9000 + }) + const done = thoughtLines(await frameWhen(app, (rendered) => rendered.includes("Thought 3 · 6.0s"))) + expect(done).toHaveLength(1) + expect(SPINNER_FRAMES.some((glyph) => done[0]!.includes(glyph))).toBe(false) + } finally { + app.renderer.destroy() + } +}) + +test("a tool call and visible text each end the run and start a new line", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const turn = message("msg_breaks") + const parts: Part[] = [ + encrypted("msg_breaks", 1, { start: 1000, end: 2000 }), + encrypted("msg_breaks", 2, { start: 3000, end: 5000 }), + tool("msg_breaks", 3), + encrypted("msg_breaks", 4, { start: 6000, end: 10000 }), + encrypted("msg_breaks", 5, { start: 10500, end: 11000 }), + text("msg_breaks", 6, "Here is the answer."), + encrypted("msg_breaks", 7, { start: 12000, end: 12500 }), + ] + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + emit(global({ id: "evt_breaks_message", type: "message.updated", properties: { sessionID, info: turn } })) + for (const part of parts) { + emit(partUpdated(part)) + } + await wait(() => sync.data.part[turn.id]?.length === parts.length) + + const frame = await frameWhen( + app, + (rendered) => rendered.includes("Thought · 500ms") && rendered.includes("Here is the answer."), + ) + const lines = thoughtLines(frame) + expect(lines).toHaveLength(3) + expect(lines[0]).toContain("Thought 2 · 3.0s") + expect(lines[1]).toContain("Thought 2 · 4.5s") + expect(lines[2]).toContain("Thought · 500ms") + const all = frame.split("\n") + const firstRun = all.findIndex((line) => line.includes("Thought 2 · 3.0s")) + const secondRun = all.findIndex((line) => line.includes("Thought 2 · 4.5s")) + const lastRun = all.findIndex((line) => line.includes("Thought · 500ms")) + const answer = all.findIndex((line) => line.includes("Here is the answer.")) + expect(all.slice(firstRun + 1, secondRun).some((line) => line.includes("ls"))).toBe(true) + expect(secondRun).toBeLessThan(answer) + expect(answer).toBeLessThan(lastRun) + } finally { + app.renderer.destroy() + } +}) + +test("a message error ends the run, and the next message starts a new line", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const failed = message("msg_failed", { error: { name: "UnknownError", data: { message: "Upstream failed" } } }) + const next = message("msg_next") + const failedParts = [ + encrypted("msg_failed", 1, { start: 1000, end: 2000 }), + encrypted("msg_failed", 2, { start: 3000, end: 5000 }), + ] + const nextParts = [encrypted("msg_next", 1, { start: 6000, end: 7000 })] + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + for (const info of [failed, next]) { + emit(global({ id: `evt_${info.id}`, type: "message.updated", properties: { sessionID, info } })) + } + for (const part of [...failedParts, ...nextParts]) { + emit(partUpdated(part)) + } + await wait(() => sync.data.part[next.id]?.length === nextParts.length) + + const frame = await frameWhen(app, (rendered) => rendered.includes("Upstream failed")) + const lines = thoughtLines(frame) + expect(lines).toHaveLength(2) + expect(lines[0]).toContain("Thought 2 · 3.0s") + expect(lines[1]).toContain("Thought · 1.0s") + expect(frame.indexOf(lines[0])).toBeLessThan(frame.indexOf("Upstream failed")) + expect(frame.indexOf("Upstream failed")).toBeLessThan(frame.indexOf(lines[1])) + } finally { + app.renderer.destroy() + } +}) From 95b99901c2df12bd9ef5efa30861d008090b8268 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 01:12:57 +0000 Subject: [PATCH 04/10] test(tui): cover plain reasoning between encrypted runs Refs #53 A plain reasoning part between two encrypted runs keeps its own "Thought: " header and its body. The runs on either side stay separate lines. The test fails when the renderer ignores the grouping. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- .../session/opaque-reasoning-render.test.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx index b783c7d4fdd4..981a256879e5 100644 --- a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx +++ b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx @@ -285,6 +285,51 @@ test("a tool call and visible text each end the run and start a new line", async } }) +test("plain reasoning between two runs keeps its own header and body", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const turn = message("msg_plain") + const plain: ReasoningPart = { + id: partID("msg_plain", 3), + sessionID, + messageID: "msg_plain", + type: "reasoning", + text: "Checking the tests first.", + time: { start: 5000, end: 6000 }, + } + const parts: Part[] = [ + encrypted("msg_plain", 1, { start: 1000, end: 2000 }), + encrypted("msg_plain", 2, { start: 3000, end: 5000 }), + plain, + encrypted("msg_plain", 4, { start: 6500, end: 7000 }), + ] + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + emit(global({ id: "evt_plain_message", type: "message.updated", properties: { sessionID, info: turn } })) + for (const part of parts) { + emit(partUpdated(part)) + } + await wait(() => sync.data.part[turn.id]?.length === parts.length) + + const frame = await frameWhen( + app, + (rendered) => rendered.includes("Checking the tests first.") && rendered.includes("Thought · 500ms"), + ) + const lines = thoughtLines(frame) + expect(lines).toHaveLength(3) + expect(lines[0]).toContain("Thought 2 · 3.0s") + expect(lines[1]).toContain("Thought: 1.0s") + expect(lines[2]).toContain("Thought · 500ms") + const all = frame.split("\n") + const plainHeader = all.findIndex((line) => line.includes("Thought: 1.0s")) + const body = all.findIndex((line) => line.includes("Checking the tests first.")) + expect(body).toBeGreaterThan(plainHeader) + } finally { + app.renderer.destroy() + } +}) + test("a message error ends the run, and the next message starts a new line", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") From 3b26ab073d2fb70ed56e4118b42e3ab886567c62 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 01:14:39 +0000 Subject: [PATCH 05/10] test(tui): align reasoning fixture comments with the processor Refs #53 The fixture comments said provider metadata is written on reasoning-end. processor.ts sets it on reasoning-start, and finishReasoning adds time.end. Also drop two non-null assertions that oxlint reports as unnecessary. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- .../test/routes/session/opaque-reasoning-render.test.tsx | 7 ++++--- packages/tui/test/routes/session/opaque-reasoning.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx index 981a256879e5..5243fe1bfb52 100644 --- a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx +++ b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx @@ -43,7 +43,8 @@ function partID(messageID: string, index: number) { return `prt_${messageID}_${String(index).padStart(3, "0")}` } -// Shapes follow session/processor.ts: encrypted reasoning has provider metadata and no text. +// Shapes follow session/processor.ts: reasoning-start creates the part with empty +// text and the provider metadata, and finishReasoning adds time.end. function encrypted(messageID: string, index: number, time: ReasoningPart["time"]): ReasoningPart { return { id: partID(messageID, index), @@ -226,7 +227,7 @@ test("live: a run updates in place as parts arrive", async () => { await wait(() => sync.data.part[live.id]?.length === 3) const pending = thoughtLines(await frameWhen(app, (rendered) => rendered.includes("Thought 3 · 3.0s"))) expect(pending).toHaveLength(1) - expect(SPINNER_FRAMES.some((glyph) => pending[0]!.includes(glyph))).toBe(true) + expect(SPINNER_FRAMES.some((glyph) => pending[0].includes(glyph))).toBe(true) emit(partUpdated(third)) await wait(() => { @@ -235,7 +236,7 @@ test("live: a run updates in place as parts arrive", async () => { }) const done = thoughtLines(await frameWhen(app, (rendered) => rendered.includes("Thought 3 · 6.0s"))) expect(done).toHaveLength(1) - expect(SPINNER_FRAMES.some((glyph) => done[0]!.includes(glyph))).toBe(false) + expect(SPINNER_FRAMES.some((glyph) => done[0].includes(glyph))).toBe(false) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/routes/session/opaque-reasoning.test.ts b/packages/tui/test/routes/session/opaque-reasoning.test.ts index c2e1fd759663..dd1de5b296e4 100644 --- a/packages/tui/test/routes/session/opaque-reasoning.test.ts +++ b/packages/tui/test/routes/session/opaque-reasoning.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test" import type { Part, ReasoningPart } from "@opencode-ai/sdk/v2" import { groupOpaqueReasoning, isOpaqueReasoning } from "../../../src/routes/session/opaque-reasoning" -// Shapes follow session/processor.ts: encrypted reasoning receives no -// reasoning-delta text, and its provider metadata is written on reasoning-end. +// Shapes follow session/processor.ts: reasoning-start creates the part with empty +// text and the provider metadata, and finishReasoning adds time.end. const ids = { sessionID: "ses_test", messageID: "msg_test" } function encrypted(id: string): ReasoningPart { From db7e47964185d586329cc40f65ae8c3d9c4d8e31 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 01:20:04 +0000 Subject: [PATCH 06/10] docs: add changeset entry for #161 Refs #53 Records the TUI reasoning-run change in the fork changeset under Features. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- CHANGESET.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGESET.md b/CHANGESET.md index e45dfd8b8f21..4fe2614750ce 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -159,6 +159,7 @@ Newest first. Each entry names the upstream range brought in. ### Features +- **#161** The TUI collapses each run of consecutive encrypted reasoning parts from OpenAI Responses models, such as GPT-6 Luna and Sol through `swx-azure`, into one `Thought · ` line that updates as each part arrives, in the live stream and in loaded history. A tool call, visible text, or a message error starts a new line; non-encrypted reasoning renders as before (swxtchio/swx-opencode#53). _Upstreamable._ - **#73** V1 server prompt queue core (swxtchio/swx-opencode#68): every V1 prompt except `noReply` is admitted to the durable `session_prompt_queue` table before it becomes a user message. `prompt`, `prompt_async` and `command` take `delivery: "steer" | "queue"`; the default steer reaches the next step as before, while `queue` waits until the run would go idle and then runs as its own turn, one per boundary in admission order. Steers wait behind a pending compaction; an abort or error parks pending prompts until the next admission or wake; `/session/:id/queue` lists, withdraws (404 `QueueItemNotPending` once delivered), restores and re-delivers them, with `session.queue.updated` carrying the full list. A sync prompt answers with the final reply of the turn its prompt joined, recorded for it where the loop ends that turn: steers share the reply of the turn they steered, and a queued prompt gets its own turn's; an editor's withdraw that wins answers it with 409 `PromptWithdrawn`. Deliberately, a compaction whose summary turn stopped on an abort or error is not retried, where upstream V1 retried it on the next prompt with that prompt as its parent (summarising it instead of answering it); if the context is still too large, the next overflow check starts a new compaction. Also closes the V1 lost wakeup where a prompt admitted as a run finished joined it and was never answered. Known ordering-only residuals: a `noReply` prompt and the synthetic message a subtask writes to have its output summarised are stored directly rather than through the queue, so they can land out of admission order relative to pending items. _Fork-only; the lost-wakeup fix is upstreamable._ - **#64** Show each configured router member's request count and share so far in `llmrouter/auto` labels in the TUI and run CLI. (swxtchio/swx-opencode#60). _Fork-only._ - **#58** Price each routed step at its configured serving model and show per-model cost shares in `opencode stats`; `db export-usage` exposes per-served-model token/cost splits and derives record-level tokens, costs and summary `reportedCostTotal` from step-finish parts when present, falling back to message totals under the requested model for legacy rows. (swxtchio/swx-opencode#54). _Fork-only._ From 37d3cb5d6cf6b851d1a86bf3e4bcc62e549f709e Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 02:40:20 +0000 Subject: [PATCH 07/10] fix(tui): keep opaque run identity across updates Refs #53 The run row was rebuilt on every recompute, so the keyed For remounted the run view and dropped any selection on it. A run's row is now its first part, the same object on every recompute. Its members, count, and duration update reactively through `runs`. A lone encrypted part renders through the same run view, so its row keeps its identity when a second part arrives. Parts that are not folded into a run stay in order instead of being dropped by the grouping, so a part type the view does not render yet still reaches the view. Tests: a live regression checks that the run row and its selection survive a third appended part, and it fails on the previous code. Pure tests check that a run keeps its first part's object and that every other part keeps its place. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- packages/tui/src/routes/session/index.tsx | 45 ++++--- .../src/routes/session/opaque-reasoning.ts | 30 +++-- .../session/opaque-reasoning-render.test.tsx | 45 +++++++ .../routes/session/opaque-reasoning.test.ts | 120 ++++++++++++------ 4 files changed, 169 insertions(+), 71 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1a57c027a23b..5918a8aa9fa7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1599,26 +1599,31 @@ export function AssistantMessageView(props: { }) // Grouping is per message: an error renders after the parts, so it ends a run by position. - const rows = createMemo(() => groupOpaqueReasoning(props.parts)) + const grouped = createMemo(() => groupOpaqueReasoning(props.parts)) const childShortcut = useCommandShortcut("session.child.first") const backgroundShortcut = useCommandShortcut("session.background") return ( <> - + {(part, index) => { - if (part.type === "opaque-reasoning") - return const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) return ( - - + + + + } + > + grouped().runs.get(part.id) ?? []} message={props.message} /> ) }} @@ -1729,12 +1734,15 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme)) const toggle = () => { - if (!inMinimal() || opaque()) return + if (!inMinimal()) return setExpanded((prev) => !prev) } + // Encrypted parts render through the run view, so one part and a longer run share one component. + if (opaque()) return [props.part]} message={props.message} /> + return ( - + alwaysSeparate.add(el)} paddingLeft={3} @@ -1744,16 +1752,15 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m > - + ReasoningPart[]; message: AssistantMessage }) { const ctx = use() const sync = useSync() const status = createMemo(() => assistantStatus(sync, props.message)) - const states = createMemo(() => props.parts.map((part) => reasoningState(part, props.message, status()))) + const states = createMemo(() => props.members().map((part) => reasoningState(part, props.message, status()))) const duration = createMemo(() => states().reduce((total, state) => total + state.duration, 0)) return ( @@ -1794,7 +1801,7 @@ function OpaqueReasoningRunView(props: { parts: ReasoningPart[]; message: Assist title={null} duration={Locale.duration(duration())} encrypted - count={props.parts.length} + count={states().length > 1 ? states().length : undefined} /> ) diff --git a/packages/tui/src/routes/session/opaque-reasoning.ts b/packages/tui/src/routes/session/opaque-reasoning.ts index dd1fef62495b..c348fb4f5630 100644 --- a/packages/tui/src/routes/session/opaque-reasoning.ts +++ b/packages/tui/src/routes/session/opaque-reasoning.ts @@ -1,6 +1,11 @@ import type { Part, ReasoningPart } from "@opencode-ai/sdk/v2" -export type OpaqueReasoningRun = { type: "opaque-reasoning"; parts: ReasoningPart[] } +// A run's row is its first part, the same object on every recompute, so the view keyed on it persists while +// the run grows. The later members are not rows; they reach the view through `runs`. +export type OpaqueReasoningGroups = { + rows: Part[] + runs: Map +} // OpenRouter encrypts some reasoning blocks and marks them with a placeholder. export function reasoningText(part: ReasoningPart) { @@ -13,16 +18,21 @@ export function isOpaqueReasoning(part: ReasoningPart) { return !reasoningText(part) && Boolean(part.metadata) } -// A run ends at a part that renders a line of its own. Parts that render no -// line are skipped without ending the run, so a step boundary between two -// encrypted parts still shows as one line. -export function groupOpaqueReasoning(parts: readonly Part[]): Array { +// A run ends at a part that renders a line of its own. Every other part stays in +// order, so a part type the view does not render yet still reaches the view. +export function groupOpaqueReasoning(parts: readonly Part[]): OpaqueReasoningGroups { const breakers = parts.flatMap((part, index) => (breaksRun(part) ? [index] : [])) const starts = [0, ...breakers.map((index) => index + 1)] - return starts.flatMap((start, index) => { + const runs = new Map() + const rows = starts.flatMap((start, index) => { const end = breakers[index] ?? parts.length - return [...collapseRun(parts.slice(start, end)), ...parts.slice(end, end + 1)] + const segment = parts.slice(start, end) + const members = segment.filter(isOpaquePart) + const leader = members[0] + if (leader) runs.set(leader.id, members) + return [...segment.filter((part) => !isOpaquePart(part) || part === leader), ...parts.slice(end, end + 1)] }) + return { rows, runs } } function breaksRun(part: Part) { @@ -32,8 +42,6 @@ function breaksRun(part: Part) { return false } -function collapseRun(segment: readonly Part[]): Array { - const run = segment.flatMap((part) => (part.type === "reasoning" && isOpaqueReasoning(part) ? [part] : [])) - if (run.length < 2) return run - return [{ type: "opaque-reasoning", parts: run }] +function isOpaquePart(part: Part): part is ReasoningPart { + return part.type === "reasoning" && isOpaqueReasoning(part) } diff --git a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx index 5243fe1bfb52..2a5d10690c87 100644 --- a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx +++ b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx @@ -3,6 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { expect, test } from "bun:test" import { createSignal, For, onCleanup, Show } from "solid-js" import { useRenderer } from "@opentui/solid" +import { TextRenderable, type Renderable } from "@opentui/core" import type { AssistantMessage, GlobalEvent, Part, ReasoningPart } from "@opencode-ai/sdk/v2" import { resolve, TuiConfigProvider } from "../../../src/config" import { LocalProvider } from "../../../src/context/local" @@ -152,6 +153,16 @@ function thoughtLines(frame: string) { return frame.split("\n").filter((line) => line.includes("Thought")) } +// The rendered text node that shows a label. A row that is kept across an update is the same node after it. +function renderableWith(node: Renderable, label: string): Renderable | undefined { + for (const child of node.getChildren()) { + if (child instanceof TextRenderable && child.plainText.includes(label)) return child + const found = renderableWith(child, label) + if (found) return found + } + return undefined +} + // Polls the rendered frame until it shows what the test expects, bounded so a wrong frame still fails. async function frameWhen(app: Awaited>["app"], ready: (frame: string) => boolean) { const start = Date.now() @@ -331,6 +342,40 @@ test("plain reasoning between two runs keeps its own header and body", async () } }) +test("a live run keeps its row and selection when a third part arrives", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const live = message("msg_identity") + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + emit(global({ id: "evt_identity_message", type: "message.updated", properties: { sessionID, info: live } })) + emit(partUpdated(encrypted("msg_identity", 1, { start: 1000, end: 2000 }))) + emit(partUpdated(encrypted("msg_identity", 2, { start: 3000, end: 5000 }))) + await wait(() => sync.data.part[live.id]?.length === 2) + const label = "Thought 2 · 3.0s" + const frame = await frameWhen(app, (rendered) => rendered.includes(label)) + + const row = renderableWith(app.renderer.root, label) + expect(row).toBeDefined() + const y = frame.split("\n").findIndex((line) => line.includes(label)) + const x = frame.split("\n")[y].indexOf(label) + await app.mockMouse.drag(x, y, x + label.length - 1, y) + expect(app.renderer.hasSelection).toBe(true) + const selection = app.renderer.getSelectionContainer() + + emit(partUpdated(encrypted("msg_identity", 3, { start: 6000, end: 9000 }))) + await wait(() => sync.data.part[live.id]?.length === 3) + await frameWhen(app, (rendered) => rendered.includes("Thought 3 · 6.0s")) + + expect(renderableWith(app.renderer.root, "Thought 3 · 6.0s") === row).toBe(true) + expect(app.renderer.hasSelection).toBe(true) + expect(app.renderer.getSelectionContainer() === selection).toBe(true) + } finally { + app.renderer.destroy() + } +}) + test("a message error ends the run, and the next message starts a new line", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") diff --git a/packages/tui/test/routes/session/opaque-reasoning.test.ts b/packages/tui/test/routes/session/opaque-reasoning.test.ts index dd1de5b296e4..3a26bbf34bec 100644 --- a/packages/tui/test/routes/session/opaque-reasoning.test.ts +++ b/packages/tui/test/routes/session/opaque-reasoning.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test" import type { Part, ReasoningPart } from "@opencode-ai/sdk/v2" -import { groupOpaqueReasoning, isOpaqueReasoning } from "../../../src/routes/session/opaque-reasoning" +import { + groupOpaqueReasoning, + isOpaqueReasoning, + type OpaqueReasoningGroups, +} from "../../../src/routes/session/opaque-reasoning" // Shapes follow session/processor.ts: reasoning-start creates the part with empty // text and the provider metadata, and finishReasoning adds time.end. @@ -66,82 +70,116 @@ function stepFinish(id: string): Part { } } +function file(id: string): Part { + return { ...ids, id, type: "file", mime: "text/plain", url: "file:///notes.txt" } +} + +// Each row by its id, or as "run:" when the row leads a run. +function shape(groups: OpaqueReasoningGroups) { + return groups.rows.map((row) => { + const members = groups.runs.get(row.id) + return members ? `run:${members.map((part) => part.id).join(",")}` : row.id + }) +} + describe("groupOpaqueReasoning", () => { test("collapses a run of consecutive encrypted parts into one row", () => { const parts = [encrypted("a"), encrypted("b"), encrypted("c"), encrypted("d")] - expect(groupOpaqueReasoning(parts)).toEqual([{ type: "opaque-reasoning", parts }]) + const groups = groupOpaqueReasoning(parts) + + expect(shape(groups)).toEqual(["run:a,b,c,d"]) + expect(groups.runs.get("a")).toEqual(parts) }) - test("keeps a lone encrypted part as the original part object", () => { + test("a lone encrypted part is a run of one and keeps its object", () => { const part = encrypted("a") - const rows = groupOpaqueReasoning([part]) + const groups = groupOpaqueReasoning([part]) + + expect(groups.rows).toHaveLength(1) + expect(groups.rows[0]).toBe(part) + expect(groups.runs.get("a")).toEqual([part]) + }) + + test("a run keeps its first part's object as it grows", () => { + const first = encrypted("a") - expect(rows).toHaveLength(1) - expect(rows[0]).toBe(part) + const before = groupOpaqueReasoning([first, encrypted("b")]) + const after = groupOpaqueReasoning([first, encrypted("b"), encrypted("c")]) + + expect(before.rows[0]).toBe(first) + expect(after.rows[0]).toBe(first) + expect(after.runs.get("a")).toHaveLength(3) }) test("a tool call ends the run, and the next encrypted part starts a new one", () => { - const a = encrypted("a") - const b = encrypted("b") - const call = tool("call") - const c = encrypted("c") + const groups = groupOpaqueReasoning([encrypted("a"), encrypted("b"), tool("call"), encrypted("c")]) - expect(groupOpaqueReasoning([a, b, call, c])).toEqual([{ type: "opaque-reasoning", parts: [a, b] }, call, c]) + expect(shape(groups)).toEqual(["run:a,b", "call", "run:c"]) }) test("visible text ends the run, and the next encrypted part starts a new one", () => { - const a = encrypted("a") - const b = encrypted("b") - const answer = text("answer", "Here is the result.") - const c = encrypted("c") - const d = encrypted("d") - - expect(groupOpaqueReasoning([a, b, answer, c, d])).toEqual([ - { type: "opaque-reasoning", parts: [a, b] }, - answer, - { type: "opaque-reasoning", parts: [c, d] }, + const groups = groupOpaqueReasoning([ + encrypted("a"), + encrypted("b"), + text("answer", "Here is the result."), + encrypted("c"), + encrypted("d"), ]) + + expect(shape(groups)).toEqual(["run:a,b", "answer", "run:c,d"]) }) test("a failed tool call ends the run", () => { - const a = encrypted("a") - const b = encrypted("b") - const failed = failedTool("failed") - const c = encrypted("c") + const groups = groupOpaqueReasoning([encrypted("a"), failedTool("failed"), encrypted("b"), encrypted("c")]) - expect(groupOpaqueReasoning([a, failed, b, c])).toEqual([a, failed, { type: "opaque-reasoning", parts: [b, c] }]) + expect(shape(groups)).toEqual(["run:a", "failed", "run:b,c"]) }) - test("parts that render no line do not end a run", () => { - const a = encrypted("a") - const b = encrypted("b") + test("parts that render no line do not end a run, and stay in order", () => { + const groups = groupOpaqueReasoning([ + encrypted("a"), + stepFinish("finish"), + stepStart("start"), + text("blank", " \n"), + emptyReasoning("e"), + encrypted("b"), + ]) - expect( - groupOpaqueReasoning([a, stepFinish("finish"), stepStart("start"), text("blank", " \n"), emptyReasoning("e"), b]), - ).toEqual([{ type: "opaque-reasoning", parts: [a, b] }]) + expect(shape(groups)).toEqual(["run:a,b", "finish", "start", "blank", "e"]) + }) + + test("a part type the view does not render yet keeps its place", () => { + const groups = groupOpaqueReasoning([encrypted("a"), file("attachment"), encrypted("b"), tool("call")]) + + expect(shape(groups)).toEqual(["run:a,b", "attachment", "call"]) }) test("summarized reasoning ends the run and renders as its own part", () => { - const a = encrypted("a") - const b = encrypted("b") - const plan = summarized("plan", "Reading the config first") - const c = encrypted("c") + const groups = groupOpaqueReasoning([ + encrypted("a"), + encrypted("b"), + summarized("plan", "Reading the config first"), + encrypted("c"), + ]) - expect(groupOpaqueReasoning([a, b, plan, c])).toEqual([{ type: "opaque-reasoning", parts: [a, b] }, plan, c]) + expect(shape(groups)).toEqual(["run:a,b", "plan", "run:c"]) }) test("a [REDACTED] placeholder with metadata is still encrypted and joins the run", () => { - const a = encrypted("a") const redacted: ReasoningPart = { ...encrypted("redacted"), text: "[REDACTED]" } - const b = encrypted("b") - expect(groupOpaqueReasoning([a, redacted, b])).toEqual([{ type: "opaque-reasoning", parts: [a, redacted, b] }]) + const groups = groupOpaqueReasoning([encrypted("a"), redacted, encrypted("b")]) + + expect(shape(groups)).toEqual(["run:a,redacted,b"]) }) test("returns no rows for no parts", () => { - expect(groupOpaqueReasoning([])).toEqual([]) + const groups = groupOpaqueReasoning([]) + + expect(groups.rows).toEqual([]) + expect(groups.runs.size).toBe(0) }) }) From 87f26bfea4363adc783cbe8c571c236da3405138 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 03:07:00 +0000 Subject: [PATCH 08/10] fix(tui): keep the run label's node across spinner and completion Refs #53 The run row rendered its label through ReasoningHeader's Switch, which swaps the text node whenever the state changes. A selection on the "Thought N" label was lost when a part went in flight and then completed. The run row now renders its own header: one text node holds the label for the whole lifecycle, and the spinner is a sibling that appears only while a part is in flight. ReasoningHeader is back to its base form, so its Switch and the other spinners are unchanged. The live identity regression now appends a third part in flight and then completes it. It checks the label node and the selection at both steps. On the previous code it fails at the in-flight label check. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- packages/tui/src/routes/session/index.tsx | 43 +++++++++++-------- .../session/opaque-reasoning-render.test.tsx | 43 +++++++++++++------ 2 files changed, 55 insertions(+), 31 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 5918a8aa9fa7..529e7dfb65b4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1780,10 +1780,24 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m function OpaqueReasoningRunView(props: { members: () => ReasoningPart[]; message: AssistantMessage }) { const ctx = use() + const { theme } = useTheme() const sync = useSync() const status = createMemo(() => assistantStatus(sync, props.message)) const states = createMemo(() => props.members().map((part) => reasoningState(part, props.message, status()))) + const done = createMemo(() => states().every((state) => state.done)) + const unknown = createMemo(() => states().some((state) => state.unresolved)) const duration = createMemo(() => states().reduce((total, state) => total + state.duration, 0)) + const fg = () => + ctx.thinkingMode() !== "hide" + ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity) + : theme.warning + // The label is one text node for the whole lifecycle; only its content and the spinner beside it change. + // A selection on the label therefore survives the in-flight and completed transitions. + const label = () => { + if (unknown()) return "Thinking status unknown" + if (!done() && states().length === 1) return "Thinking" + return `Thought${states().length > 1 ? ` ${states().length}` : ""} · ${Locale.duration(duration())}` + } return ( ReasoningPart[]; message flexDirection="column" flexShrink={0} > - state.done)} - unknown={states().some((state) => state.unresolved)} - title={null} - duration={Locale.duration(duration())} - encrypted - count={states().length > 1 ? states().length : undefined} - /> + + + + + + {label()} + + ) } @@ -1831,19 +1843,14 @@ function ReasoningHeader(props: { title: string | null duration?: string encrypted?: boolean - count?: number }) { const { theme } = useTheme() const fg = () => props.open ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity) : theme.warning - // A run shows its count and duration while it is still in flight, so the line - // updates as each part arrives. - const encryptedLabel = () => - `Thought${props.count ? ` ${props.count}` : ""}${props.duration ? ` · ${props.duration}` : ""}` const completed = () => { - if (props.encrypted) return encryptedLabel() + if (props.encrypted) return `Thought${props.duration ? ` · ${props.duration}` : ""}` const detail = [props.title, props.duration].filter(Boolean).join(" · ") return `${props.toggleable ? (props.open ? "- " : "+ ") : ""}Thought${detail ? `: ${detail}` : ""}` } @@ -1857,9 +1864,7 @@ function ReasoningHeader(props: { - - {props.count ? encryptedLabel() : props.title ? "Thinking: " + props.title : "Thinking"} - + {props.title ? "Thinking: " + props.title : "Thinking"} diff --git a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx index 2a5d10690c87..40e3231b0b01 100644 --- a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx +++ b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx @@ -342,7 +342,7 @@ test("plain reasoning between two runs keeps its own header and body", async () } }) -test("a live run keeps its row and selection when a third part arrives", async () => { +test("a live run keeps its label and selection as a third part goes in flight and completes", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") const live = message("msg_identity") @@ -353,22 +353,41 @@ test("a live run keeps its row and selection when a third part arrives", async ( emit(partUpdated(encrypted("msg_identity", 1, { start: 1000, end: 2000 }))) emit(partUpdated(encrypted("msg_identity", 2, { start: 3000, end: 5000 }))) await wait(() => sync.data.part[live.id]?.length === 2) - const label = "Thought 2 · 3.0s" - const frame = await frameWhen(app, (rendered) => rendered.includes(label)) - - const row = renderableWith(app.renderer.root, label) - expect(row).toBeDefined() - const y = frame.split("\n").findIndex((line) => line.includes(label)) - const x = frame.split("\n")[y].indexOf(label) - await app.mockMouse.drag(x, y, x + label.length - 1, y) + const before = "Thought 2 · 3.0s" + const frame = await frameWhen(app, (rendered) => rendered.includes(before)) + + const label = renderableWith(app.renderer.root, before) + expect(label).toBeDefined() + const y = frame.split("\n").findIndex((line) => line.includes(before)) + const x = frame.split("\n")[y].indexOf(before) + await app.mockMouse.drag(x, y, x + before.length - 1, y) expect(app.renderer.hasSelection).toBe(true) const selection = app.renderer.getSelectionContainer() - emit(partUpdated(encrypted("msg_identity", 3, { start: 6000, end: 9000 }))) + // The third part arrives in flight: the line gains the spinner and keeps its label and selection. + emit( + global({ id: "evt_identity_busy", type: "session.status", properties: { sessionID, status: { type: "busy" } } }), + ) + const third = encrypted("msg_identity", 3, { start: 6000, end: 9000 }) + emit(partUpdated({ ...third, time: { start: 6000 } })) await wait(() => sync.data.part[live.id]?.length === 3) - await frameWhen(app, (rendered) => rendered.includes("Thought 3 · 6.0s")) + const inFlight = "Thought 3 · 3.0s" + const pending = await frameWhen(app, (rendered) => rendered.includes(inFlight)) + const pendingLine = pending.split("\n").find((line) => line.includes(inFlight)) ?? "" + expect(SPINNER_FRAMES.some((glyph) => pendingLine.includes(glyph))).toBe(true) + expect(renderableWith(app.renderer.root, inFlight) === label).toBe(true) + expect(app.renderer.hasSelection).toBe(true) + expect(app.renderer.getSelectionContainer() === selection).toBe(true) - expect(renderableWith(app.renderer.root, "Thought 3 · 6.0s") === row).toBe(true) + // The part completes: the same label and selection survive the spinner going away. + emit(partUpdated(third)) + await wait(() => { + const stored = sync.data.part[live.id]?.find((part) => part.id === third.id) + return stored?.type === "reasoning" && stored.time.end === 9000 + }) + const completed = "Thought 3 · 6.0s" + await frameWhen(app, (rendered) => rendered.includes(completed)) + expect(renderableWith(app.renderer.root, completed) === label).toBe(true) expect(app.renderer.hasSelection).toBe(true) expect(app.renderer.getSelectionContainer() === selection).toBe(true) } finally { From 093519c2b18c557d731fb08d8d1681fbc0ce3bec Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 04:15:50 +0000 Subject: [PATCH 09/10] refactor(tui): remove the unreachable encrypted branch from ReasoningHeader Refs #53 Encrypted reasoning renders through OpaqueReasoningRunView, and nothing passes `encrypted` to ReasoningHeader any more. The branch was a second path to the same label, so it goes. The header's Switch is unchanged. Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- packages/tui/src/routes/session/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 529e7dfb65b4..7768c1d51ad7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1842,7 +1842,6 @@ function ReasoningHeader(props: { unknown?: boolean title: string | null duration?: string - encrypted?: boolean }) { const { theme } = useTheme() const fg = () => @@ -1850,7 +1849,6 @@ function ReasoningHeader(props: { ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity) : theme.warning const completed = () => { - if (props.encrypted) return `Thought${props.duration ? ` · ${props.duration}` : ""}` const detail = [props.title, props.duration].filter(Boolean).join(" · ") return `${props.toggleable ? (props.open ? "- " : "+ ") : ""}Thought${detail ? `: ${detail}` : ""}` } From 30eea77f46b18db6d57aee034af2cfbfad024f14 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 13:44:51 +0000 Subject: [PATCH 10/10] refactor(tui): drop the second route for encrypted reasoning ReasoningPartView sent encrypted parts to the run view, which gave the run view a second caller. Production never reached it: AssistantMessageView renders every encrypted part through groupOpaqueReasoning, and the parts after a run's leader are not rendered at all. The only caller was the History and PreAssistantHistory harnesses in assistant-activity.test.tsx. They now render their reasoning parts through AssistantMessageView, inside the providers that route needs. Refs #173 Claude-Session: https://claude.ai/code/session_016iEmXXj5M8ARWwiuDHngYA --- packages/tui/src/routes/session/index.tsx | 6 +- .../session/assistant-activity.test.tsx | 60 +++++++++++++------ 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7768c1d51ad7..1e6ad8dfa762 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -70,7 +70,7 @@ import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { AssistantModelLabel } from "./message-model" -import { groupOpaqueReasoning, isOpaqueReasoning, reasoningText } from "./opaque-reasoning" +import { groupOpaqueReasoning, reasoningText } from "./opaque-reasoning" import type { SessionStepMessage } from "../../util/model" import { formatTranscript } from "../../util/transcript" import { sessionEpilogue } from "../../util/presentation" @@ -1726,7 +1726,6 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m const [expanded, setExpanded] = createSignal(false) const content = createMemo(() => reasoningText(props.part)) - const opaque = createMemo(() => isOpaqueReasoning(props.part)) const status = createMemo(() => assistantStatus(sync, props.message)) const state = createMemo(() => reasoningState(props.part, props.message, status())) const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") @@ -1738,9 +1737,6 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m setExpanded((prev) => !prev) } - // Encrypted parts render through the run view, so one part and a longer run share one component. - if (opaque()) return [props.part]} message={props.message} /> - return ( - - - - - - - - {props.currentRead && } - - - + + + + + + + + + + {props.currentRead && ( + + )} + + + + + ) @@ -198,12 +215,19 @@ function PreAssistantHistory(props: { - - - - - - + + + + + + + +