From 33e380d87877c0803153ea81ab442e9352171154 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 17:42:49 -0700 Subject: [PATCH 1/2] Raise the live thinking preview to ten capped lines Parent mid-turn CoT stays on the existing thinking row with settle-to-opener and expand; assistant text still grows the open streaming row. No third stream lane, no unbounded dump, sub-agent Task thinking unchanged. --- CHANGELOG.md | 12 ++++++++ docs/TUI.md | 11 ++++++++ src/tui/collapse.test.ts | 4 +-- src/tui/runtime-bridge.test.ts | 49 +++++++++++++++++++++++++++++++++ src/tui/stream.ts | 7 +++-- src/tui/thinking-reveal.test.ts | 19 +++++++++++-- src/tui/thinking.ts | 17 ++++++++---- src/tui/turn-state.test.ts | 5 ++++ src/tui/turn-state.ts | 1 + 9 files changed, 113 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dbf17187..62f9151a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [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`. + ## [0.2.103] - 2026-08-23 ### TUI diff --git a/docs/TUI.md b/docs/TUI.md index 80e03b853..b89e66226 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -64,6 +64,17 @@ 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 071565440..45bce23c4 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 b08599f44..bd8346813 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -591,6 +591,55 @@ 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 101e577e7..7ef032ef3 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -405,9 +405,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 c1563cd6e..bbe970c07 100644 --- a/src/tui/thinking-reveal.test.ts +++ b/src/tui/thinking-reveal.test.ts @@ -7,7 +7,11 @@ 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 +87,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 7c5cb7aae..dc95704ce 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 41c26033d..9f2071a75 100644 --- a/src/tui/turn-state.test.ts +++ b/src/tui/turn-state.test.ts @@ -40,6 +40,11 @@ describe("turnStateFromEvent", () => { 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 c08193ebc..94d514671 100644 --- a/src/tui/turn-state.ts +++ b/src/tui/turn-state.ts @@ -588,6 +588,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": From 7c47a8141d852989bcef79c919b4911639b3e795 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 20:25:23 -0700 Subject: [PATCH 2/2] Apply prettier to files the main merge left unformatted Amp-Thread-ID: https://ampcode.com/threads/T-01a02c68-0d8d-777b-b717-81fb9a282023 Co-authored-by: Amp --- docs/TUI.md | 3 +-- src/tui/runtime-bridge.test.ts | 36 ++++++++++++++++------------------ 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index c27ab6b9d..20b2d8ea4 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -67,8 +67,7 @@ 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 +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 diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 6f5c14c88..81abdd935 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -592,47 +592,45 @@ describe("attachSessionBridge", () => { terminal: { columns: 80, rows: 24 }, wireKeys: false, run: "idle", - }) - const bridge = attachSessionBridge(shell, createRecordingPort()) + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); try { - bridge.handle({ type: "inference.start", data: {} }) + 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.") + 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) + expect(shell.streamLog.filter((r) => r.meta === "thinking")).toHaveLength(1); } finally { - bridge.dispose() - shell.dispose() + bridge.dispose(); + shell.dispose(); } }, { width: 80, height: 24 }, - ) - }) + ); + }); test("thinking deltas coalesce and never become plain system rows", async () => { await withTestRenderer(