Skip to content

Commit b6d7fd3

Browse files
committed
Merge branch 'main' into cl-6898-move-live-agents-into-a-chrome-strip-above-the-prompt
2 parents 369e3a5 + 590bd36 commit b6d7fd3

9 files changed

Lines changed: 103 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

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+
1626
### Fixed
1727

1828
- **Codex Responses no longer sends `reasoning.summary: "auto"`.** ChatGPT

docs/TUI.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ 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 10) at a bounded reveal rate (`REVEAL_CHARS_PER_SEC`). When the turn moves on
71+
(assistant text, a tool call, or settle), the row collapses to its opening
72+
clause with the rest behind expand. Mid-turn thinking bursts fold onto that
73+
same one row per turn (`reasoning-fold`); `inference.text.delta` grows the
74+
open assistant streaming row in place. Sub-agent Task-row thinking is a
75+
separate path and is unchanged by this preview.
76+
6777
The prompt box's border carries the metadata that would otherwise cost a
6878
titlebar row: the model label sits right-aligned in the top rule as
6979
`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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,53 @@ describe("attachSessionBridge", () => {
585585
);
586586
});
587587

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

src/tui/stream.ts

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

403403
/**
404-
* Reasoning body. While text arrives it wraps into a short inset paragraph of
405-
* the newest revealed prose (no sideways scroll). Once the turn moves on it
406-
* collapses to the opening clause; the rest is behind the expand key.
404+
* Reasoning body. While text arrives it wraps into a bounded inset paragraph of
405+
* the newest revealed prose (no sideways scroll; hard line cap). Once the turn
406+
* moves on it collapses to the opening clause; the rest is behind the expand
407+
* key.
407408
*
408409
* A row with no settled thought (a hydrated transcript, a fixture) has no
409410
* summary to collapse to and keeps the plain block.

src/tui/thinking-reveal.test.ts

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

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

10-
import { advanceRevealChars, thinkingLivePreviewLines } from "./thinking";
10+
import { advanceRevealChars, LIVE_THINKING_MAX_LINES, thinkingLivePreviewLines } from "./thinking";
1111
import { withTestRenderer } from "./harness";
1212
import { attachSessionBridge, createRecordingPort } from "./runtime-bridge";
1313
import { createAppShell } from "./shell";
@@ -83,12 +83,23 @@ describe("thinkingLivePreviewLines with a reveal position", () => {
8383

8484
test("omitting revealChars wraps whatever has arrived, capped to max lines", () => {
8585
const lines = thinkingLivePreviewLines(text, 10);
86-
expect(lines.length).toBeLessThanOrEqual(3);
86+
expect(lines.length).toBeLessThanOrEqual(LIVE_THINKING_MAX_LINES);
8787
expect(lines.length).toBeGreaterThan(0);
8888
expect(lines.every((line) => line.length <= 10)).toBe(true);
8989
expect(lines.join(" ")).toContain("running");
9090
});
9191

92+
test("a long burst fills more than three lines and still respects the hard cap", () => {
93+
const long = Array.from({ length: 40 }, (_, i) => `clause-${i}`).join(" ");
94+
const lines = thinkingLivePreviewLines(long, 20);
95+
expect(lines.length).toBeGreaterThan(3);
96+
expect(lines.length).toBeLessThanOrEqual(LIVE_THINKING_MAX_LINES);
97+
expect(lines.every((line) => line.length <= 20)).toBe(true);
98+
// Newest prose wins when the wrap exceeds the cap.
99+
expect(lines.join(" ")).toContain("clause-39");
100+
expect(lines.join(" ")).not.toContain("clause-0");
101+
});
102+
92103
test("sample frames across a few rates, printed for eyeballing", () => {
93104
const sample = "we need to check whether the cache key already accounts for the locale";
94105
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ describe("turnStateFromEvent", () => {
3838
expect(
3939
fold([{ type: "inference.start" }, { type: "inference.thinking.delta" }]).streamingType,
4040
).toBe("thinking");
41+
// Canonical bridge alias — fixtures may emit thinking.delta directly.
42+
expect(fold([{ type: "inference.start" }, { type: "thinking.delta" }]).streamingType).toBe(
43+
"thinking",
44+
);
4145
});
4246

4347
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
@@ -561,6 +561,7 @@ export function turnStateFromEvent(
561561
return streaming(state, "text", nowMs, deltaText(event));
562562

563563
case "inference.thinking.delta":
564+
case "thinking.delta":
564565
return streaming(state, "thinking", nowMs, deltaText(event));
565566

566567
case "inference.tool_call.delta":

0 commit comments

Comments
 (0)