Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/tui/collapse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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");
Expand Down
47 changes: 47 additions & 0 deletions src/tui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
7 changes: 4 additions & 3 deletions src/tui/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions src/tui/thinking-reveal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]) {
Expand Down
17 changes: 12 additions & 5 deletions src/tui/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/tui/turn-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions src/tui/turn-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Loading