diff --git a/CHANGELOG.md b/CHANGELOG.md index 1529f6cca..7b4dd6c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### TUI + +- **Taller live chain-of-thought preview.** Parent reasoning still paints + through the existing thinking row (one fold per turn, settle-to-opener + + expand) — no separate mid-turn stream lane. The hard-capped live wrap rises + from 3 to 10 inset lines (`LIVE_THINKING_MAX_LINES`) so mid-turn CoT is + glanceable; reveal rate stays 28 chars/sec. Sub-agent Task-row thinking is + unchanged. Assistant mid-turn text continues to grow the open streaming + assistant row from `inference.text.delta`. + ### Fixed - **Codex Responses no longer sends `reasoning.summary: "auto"`.** ChatGPT diff --git a/docs/TUI.md b/docs/TUI.md index 235ac95b0..20b2d8ea4 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -64,6 +64,16 @@ stays easy to find while scrolling through denser assistant and tool rows — the pad is part of the bubble itself, not an extra turn-boundary gap, and assistant/tool rows are unchanged. +Parent live reasoning paints through the existing thinking row — never a +third mid-turn stream lane. While `inference.thinking.delta` arrives, +`thinkingLivePreviewLines` (`src/tui/thinking.ts`) wraps the newest revealed +prose into a hard-capped inset paragraph (`LIVE_THINKING_MAX_LINES`, currently 10) at a bounded reveal rate (`REVEAL_CHARS_PER_SEC`). When the turn moves on +(assistant text, a tool call, or settle), the row collapses to its opening +clause with the rest behind expand. Mid-turn thinking bursts fold onto that +same one row per turn (`reasoning-fold`); `inference.text.delta` grows the +open assistant streaming row in place. Sub-agent Task-row thinking is a +separate path and is unchanged by this preview. + The prompt box's border carries the metadata that would otherwise cost a titlebar row: the model label sits right-aligned in the top rule as `profile · model · effort` (empty segments omitted), and a diff --git a/src/tui/collapse.test.ts b/src/tui/collapse.test.ts index 0c9c60ab4..37d4b1876 100644 --- a/src/tui/collapse.test.ts +++ b/src/tui/collapse.test.ts @@ -21,7 +21,7 @@ import { type RowLayout, type StreamRow, } from "./stream"; -import { thinkingLivePreviewLines, thinkingSettledLine } from "./thinking"; +import { thinkingLivePreviewLines, thinkingSettledLine, LIVE_THINKING_MAX_LINES } from "./thinking"; import { describeView, toolArgsView } from "./tool-args"; const WIDE: RowLayout = { width: 96, multiAgent: false }; @@ -171,7 +171,7 @@ describe("reasoning collapses to a short wrapped preview", () => { test("while thinking it wraps a short preview instead of sideways-scrolling", () => { const painted = lines({ role: "system", meta: "thinking", text, streaming: true }); expect(painted.length).toBeGreaterThanOrEqual(1); - expect(painted.length).toBeLessThanOrEqual(3); + expect(painted.length).toBeLessThanOrEqual(LIVE_THINKING_MAX_LINES); // Inset and dim is the whole of reasoning's chrome; it carries no rail. expect(painted.every((line) => !line.includes("┆"))).toBe(true); expect(painted.join("\n")).toContain("one commit"); diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 15bc18b8f..81abdd935 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -585,6 +585,53 @@ describe("attachSessionBridge", () => { ); }); + test("inference.text.delta opens a live assistant streaming row mid-turn", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.thinking.delta", + data: { token: "planning the reply" }, + }); + bridge.handle({ + type: "inference.tool_call.end", + data: { name: "run_shell", callId: "c1", arguments: "{}" }, + }); + bridge.handle({ + type: "tool.done", + data: { result: { callId: "c1", content: "ok", isError: false } }, + }); + bridge.handle({ + type: "inference.text.delta", + data: { token: "Here is " }, + }); + bridge.handle({ + type: "inference.text.delta", + data: { token: "the answer." }, + }); + + const assistant = shell.streamLog.filter((r) => r.role === "assistant"); + expect(assistant).toHaveLength(1); + expect(assistant[0]?.streaming).toBe(true); + expect(assistant[0]?.text).toBe("Here is the answer."); + // Still one thinking row for the turn — no third mid-turn stream lane. + expect(shell.streamLog.filter((r) => r.meta === "thinking")).toHaveLength(1); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + test("thinking deltas coalesce and never become plain system rows", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/stream.ts b/src/tui/stream.ts index 05f87d5b6..9ce880f73 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -401,9 +401,10 @@ function elapsedLabel(ms: number): string { } /** - * Reasoning body. While text arrives it wraps into a short inset paragraph of - * the newest revealed prose (no sideways scroll). Once the turn moves on it - * collapses to the opening clause; the rest is behind the expand key. + * Reasoning body. While text arrives it wraps into a bounded inset paragraph of + * the newest revealed prose (no sideways scroll; hard line cap). Once the turn + * moves on it collapses to the opening clause; the rest is behind the expand + * key. * * A row with no settled thought (a hydrated transcript, a fixture) has no * summary to collapse to and keeps the plain block. diff --git a/src/tui/thinking-reveal.test.ts b/src/tui/thinking-reveal.test.ts index 302d86e1c..52649b4cf 100644 --- a/src/tui/thinking-reveal.test.ts +++ b/src/tui/thinking-reveal.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test"; -import { advanceRevealChars, thinkingLivePreviewLines } from "./thinking"; +import { advanceRevealChars, LIVE_THINKING_MAX_LINES, thinkingLivePreviewLines } from "./thinking"; import { withTestRenderer } from "./harness"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; import { createAppShell } from "./shell"; @@ -83,12 +83,23 @@ describe("thinkingLivePreviewLines with a reveal position", () => { test("omitting revealChars wraps whatever has arrived, capped to max lines", () => { const lines = thinkingLivePreviewLines(text, 10); - expect(lines.length).toBeLessThanOrEqual(3); + expect(lines.length).toBeLessThanOrEqual(LIVE_THINKING_MAX_LINES); expect(lines.length).toBeGreaterThan(0); expect(lines.every((line) => line.length <= 10)).toBe(true); expect(lines.join(" ")).toContain("running"); }); + test("a long burst fills more than three lines and still respects the hard cap", () => { + const long = Array.from({ length: 40 }, (_, i) => `clause-${i}`).join(" "); + const lines = thinkingLivePreviewLines(long, 20); + expect(lines.length).toBeGreaterThan(3); + expect(lines.length).toBeLessThanOrEqual(LIVE_THINKING_MAX_LINES); + expect(lines.every((line) => line.length <= 20)).toBe(true); + // Newest prose wins when the wrap exceeds the cap. + expect(lines.join(" ")).toContain("clause-39"); + expect(lines.join(" ")).not.toContain("clause-0"); + }); + test("sample frames across a few rates, printed for eyeballing", () => { const sample = "we need to check whether the cache key already accounts for the locale"; for (const rate of [15, 20, 28, 40, 60]) { diff --git a/src/tui/thinking.ts b/src/tui/thinking.ts index ec908b7ec..54756e2cd 100644 --- a/src/tui/thinking.ts +++ b/src/tui/thinking.ts @@ -4,8 +4,9 @@ * * Reasoning is not the answer, so it never owns the screen. Live text used to * ride a single sideways-scrolling row; that was unreadable. Now the newest - * revealed prose wraps into a few inset lines. Once the turn moves on the row - * collapses to its opening clause — same expand path as before. + * revealed prose wraps into a bounded inset paragraph (hard-capped — never an + * unbounded dump). Once the turn moves on the row collapses to its opening + * clause — same expand path as before. */ import { sliceToWidth, stringWidth, wrapLines } from "./view/height.js"; @@ -28,12 +29,18 @@ export function flattenReasoningText(text: string): string { * Characters per second the reveal position advances at while reasoning * streams. Picked by printing sample frames at 15/20/28/40/60 chars/sec and * reading them back: below ~20 the line feels laggy against a fast model, - * above ~40 it is back to unreadable. 28 landed as fast-but-legible. + * above ~40 it is back to unreadable. 28 landed as fast-but-legible and still + * reads well against the taller live preview. */ export const REVEAL_CHARS_PER_SEC = 28; -/** How many wrapped lines a live reasoning preview may claim. */ -export const LIVE_THINKING_MAX_LINES = 3; +/** + * How many wrapped lines a live reasoning preview may claim. Hard bound — the + * preview never paints unbounded CoT into the transcript. Raised into the + * 8–12 band so mid-turn chain-of-thought is glanceable without inventing a + * separate stream lane. + */ +export const LIVE_THINKING_MAX_LINES = 10; /** * Advance a reveal position toward the text that has actually arrived, capped diff --git a/src/tui/turn-state.test.ts b/src/tui/turn-state.test.ts index 81ef781ec..36a2a6118 100644 --- a/src/tui/turn-state.test.ts +++ b/src/tui/turn-state.test.ts @@ -38,6 +38,10 @@ describe("turnStateFromEvent", () => { expect( fold([{ type: "inference.start" }, { type: "inference.thinking.delta" }]).streamingType, ).toBe("thinking"); + // Canonical bridge alias — fixtures may emit thinking.delta directly. + expect(fold([{ type: "inference.start" }, { type: "thinking.delta" }]).streamingType).toBe( + "thinking", + ); }); test("text deltas accumulate a live token count, thinking deltas do not", () => { diff --git a/src/tui/turn-state.ts b/src/tui/turn-state.ts index 855a8f3bf..6d92e3a42 100644 --- a/src/tui/turn-state.ts +++ b/src/tui/turn-state.ts @@ -561,6 +561,7 @@ export function turnStateFromEvent( return streaming(state, "text", nowMs, deltaText(event)); case "inference.thinking.delta": + case "thinking.delta": return streaming(state, "thinking", nowMs, deltaText(event)); case "inference.tool_call.delta":