Skip to content

Commit 33e380d

Browse files
committed
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.
1 parent bebe563 commit 33e380d

9 files changed

Lines changed: 113 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### TUI
17+
18+
- **Taller live chain-of-thought preview.** Parent reasoning still paints
19+
through the existing thinking row (one fold per turn, settle-to-opener +
20+
expand) — no separate mid-turn stream lane. The hard-capped live wrap rises
21+
from 3 to 10 inset lines (`LIVE_THINKING_MAX_LINES`) so mid-turn CoT is
22+
glanceable; reveal rate stays 28 chars/sec. Sub-agent Task-row thinking is
23+
unchanged. Assistant mid-turn text continues to grow the open streaming
24+
assistant row from `inference.text.delta`.
25+
1426
## [0.2.103] - 2026-08-23
1527

1628
### TUI

docs/TUI.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ stays easy to find while scrolling through denser assistant and tool rows —
6464
the pad is part of the bubble itself, not an extra turn-boundary gap, and
6565
assistant/tool rows are unchanged.
6666

67+
Parent live reasoning paints through the existing thinking row — never a
68+
third mid-turn stream lane. While `inference.thinking.delta` arrives,
69+
`thinkingLivePreviewLines` (`src/tui/thinking.ts`) wraps the newest revealed
70+
prose into a hard-capped inset paragraph (`LIVE_THINKING_MAX_LINES`, currently
71+
10) at a bounded reveal rate (`REVEAL_CHARS_PER_SEC`). When the turn moves on
72+
(assistant text, a tool call, or settle), the row collapses to its opening
73+
clause with the rest behind expand. Mid-turn thinking bursts fold onto that
74+
same one row per turn (`reasoning-fold`); `inference.text.delta` grows the
75+
open assistant streaming row in place. Sub-agent Task-row thinking is a
76+
separate path and is unchanged by this preview.
77+
6778
The prompt box's border carries the metadata that would otherwise cost a
6879
titlebar row: the model label sits right-aligned in the top rule as
6980
`profile · model · effort` (empty segments omitted), and a

src/tui/collapse.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
type RowLayout,
2222
type StreamRow,
2323
} from "./stream"
24-
import { thinkingLivePreviewLines, thinkingSettledLine } from "./thinking"
24+
import { thinkingLivePreviewLines, thinkingSettledLine, LIVE_THINKING_MAX_LINES } from "./thinking"
2525
import { describeView, toolArgsView } from "./tool-args"
2626

2727
const WIDE: RowLayout = { width: 96, multiAgent: false }
@@ -171,7 +171,7 @@ describe("reasoning collapses to a short wrapped preview", () => {
171171
test("while thinking it wraps a short preview instead of sideways-scrolling", () => {
172172
const painted = lines({ role: "system", meta: "thinking", text, streaming: true })
173173
expect(painted.length).toBeGreaterThanOrEqual(1)
174-
expect(painted.length).toBeLessThanOrEqual(3)
174+
expect(painted.length).toBeLessThanOrEqual(LIVE_THINKING_MAX_LINES)
175175
// Inset and dim is the whole of reasoning's chrome; it carries no rail.
176176
expect(painted.every((line) => !line.includes("┆"))).toBe(true)
177177
expect(painted.join("\n")).toContain("one commit")

src/tui/runtime-bridge.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,55 @@ describe("attachSessionBridge", () => {
591591
)
592592
})
593593

594+
test("inference.text.delta opens a live assistant streaming row mid-turn", async () => {
595+
await withTestRenderer(
596+
async (h) => {
597+
const shell = createAppShell(h.renderer, {
598+
terminal: { columns: 80, rows: 24 },
599+
wireKeys: false,
600+
run: "idle",
601+
})
602+
const bridge = attachSessionBridge(shell, createRecordingPort())
603+
try {
604+
bridge.handle({ type: "inference.start", data: {} })
605+
bridge.handle({
606+
type: "inference.thinking.delta",
607+
data: { token: "planning the reply" },
608+
})
609+
bridge.handle({
610+
type: "inference.tool_call.end",
611+
data: { name: "run_shell", callId: "c1", arguments: "{}" },
612+
})
613+
bridge.handle({
614+
type: "tool.done",
615+
data: { result: { callId: "c1", content: "ok", isError: false } },
616+
})
617+
bridge.handle({
618+
type: "inference.text.delta",
619+
data: { token: "Here is " },
620+
})
621+
bridge.handle({
622+
type: "inference.text.delta",
623+
data: { token: "the answer." },
624+
})
625+
626+
const assistant = shell.streamLog.filter((r) => r.role === "assistant")
627+
expect(assistant).toHaveLength(1)
628+
expect(assistant[0]?.streaming).toBe(true)
629+
expect(assistant[0]?.text).toBe("Here is the answer.")
630+
// Still one thinking row for the turn — no third mid-turn stream lane.
631+
expect(
632+
shell.streamLog.filter((r) => r.meta === "thinking"),
633+
).toHaveLength(1)
634+
} finally {
635+
bridge.dispose()
636+
shell.dispose()
637+
}
638+
},
639+
{ width: 80, height: 24 },
640+
)
641+
})
642+
594643
test("thinking deltas coalesce and never become plain system rows", async () => {
595644
await withTestRenderer(
596645
async (h) => {

src/tui/stream.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,9 +405,10 @@ function elapsedLabel(ms: number): string {
405405
}
406406

407407
/**
408-
* Reasoning body. While text arrives it wraps into a short inset paragraph of
409-
* the newest revealed prose (no sideways scroll). Once the turn moves on it
410-
* collapses to the opening clause; the rest is behind the expand key.
408+
* Reasoning body. While text arrives it wraps into a bounded inset paragraph of
409+
* the newest revealed prose (no sideways scroll; hard line cap). Once the turn
410+
* moves on it collapses to the opening clause; the rest is behind the expand
411+
* key.
411412
*
412413
* A row with no settled thought (a hydrated transcript, a fixture) has no
413414
* summary to collapse to and keeps the plain block.

src/tui/thinking-reveal.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import { describe, expect, test } from "bun:test"
99

10-
import { advanceRevealChars, thinkingLivePreviewLines } from "./thinking"
10+
import {
11+
advanceRevealChars,
12+
LIVE_THINKING_MAX_LINES,
13+
thinkingLivePreviewLines,
14+
} from "./thinking"
1115
import { withTestRenderer } from "./harness"
1216
import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"
1317
import { createAppShell } from "./shell"
@@ -83,12 +87,23 @@ describe("thinkingLivePreviewLines with a reveal position", () => {
8387

8488
test("omitting revealChars wraps whatever has arrived, capped to max lines", () => {
8589
const lines = thinkingLivePreviewLines(text, 10)
86-
expect(lines.length).toBeLessThanOrEqual(3)
90+
expect(lines.length).toBeLessThanOrEqual(LIVE_THINKING_MAX_LINES)
8791
expect(lines.length).toBeGreaterThan(0)
8892
expect(lines.every((line) => line.length <= 10)).toBe(true)
8993
expect(lines.join(" ")).toContain("running")
9094
})
9195

96+
test("a long burst fills more than three lines and still respects the hard cap", () => {
97+
const long = Array.from({ length: 40 }, (_, i) => `clause-${i}`).join(" ")
98+
const lines = thinkingLivePreviewLines(long, 20)
99+
expect(lines.length).toBeGreaterThan(3)
100+
expect(lines.length).toBeLessThanOrEqual(LIVE_THINKING_MAX_LINES)
101+
expect(lines.every((line) => line.length <= 20)).toBe(true)
102+
// Newest prose wins when the wrap exceeds the cap.
103+
expect(lines.join(" ")).toContain("clause-39")
104+
expect(lines.join(" ")).not.toContain("clause-0")
105+
})
106+
92107
test("sample frames across a few rates, printed for eyeballing", () => {
93108
const sample = "we need to check whether the cache key already accounts for the locale"
94109
for (const rate of [15, 20, 28, 40, 60]) {

src/tui/thinking.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
*
55
* Reasoning is not the answer, so it never owns the screen. Live text used to
66
* ride a single sideways-scrolling row; that was unreadable. Now the newest
7-
* revealed prose wraps into a few inset lines. Once the turn moves on the row
8-
* collapses to its opening clause — same expand path as before.
7+
* revealed prose wraps into a bounded inset paragraph (hard-capped — never an
8+
* unbounded dump). Once the turn moves on the row collapses to its opening
9+
* clause — same expand path as before.
910
*/
1011

1112
import { sliceToWidth, stringWidth, wrapLines } from "./view/height.js"
@@ -28,12 +29,18 @@ export function flattenReasoningText(text: string): string {
2829
* Characters per second the reveal position advances at while reasoning
2930
* streams. Picked by printing sample frames at 15/20/28/40/60 chars/sec and
3031
* reading them back: below ~20 the line feels laggy against a fast model,
31-
* above ~40 it is back to unreadable. 28 landed as fast-but-legible.
32+
* above ~40 it is back to unreadable. 28 landed as fast-but-legible and still
33+
* reads well against the taller live preview.
3234
*/
3335
export const REVEAL_CHARS_PER_SEC = 28
3436

35-
/** How many wrapped lines a live reasoning preview may claim. */
36-
export const LIVE_THINKING_MAX_LINES = 3
37+
/**
38+
* How many wrapped lines a live reasoning preview may claim. Hard bound — the
39+
* preview never paints unbounded CoT into the transcript. Raised into the
40+
* 8–12 band so mid-turn chain-of-thought is glanceable without inventing a
41+
* separate stream lane.
42+
*/
43+
export const LIVE_THINKING_MAX_LINES = 10
3744

3845
/**
3946
* Advance a reveal position toward the text that has actually arrived, capped

src/tui/turn-state.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ describe("turnStateFromEvent", () => {
4040
fold([{ type: "inference.start" }, { type: "inference.thinking.delta" }])
4141
.streamingType,
4242
).toBe("thinking")
43+
// Canonical bridge alias — fixtures may emit thinking.delta directly.
44+
expect(
45+
fold([{ type: "inference.start" }, { type: "thinking.delta" }])
46+
.streamingType,
47+
).toBe("thinking")
4348
})
4449

4550
test("text deltas accumulate a live token count, thinking deltas do not", () => {

src/tui/turn-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ export function turnStateFromEvent(
588588
return streaming(state, "text", nowMs, deltaText(event))
589589

590590
case "inference.thinking.delta":
591+
case "thinking.delta":
591592
return streaming(state, "thinking", nowMs, deltaText(event))
592593

593594
case "inference.tool_call.delta":

0 commit comments

Comments
 (0)