Skip to content

Commit 450e4ba

Browse files
Merge pull request #920 from corbitsdev/cl-7758-coalesce-consecutive-same-tool-rows-and-stream-live-shell
Coalesce same-tool transcript rows and stream live shell output
2 parents 8947ee7 + 2f28a4a commit 450e4ba

27 files changed

Lines changed: 1373 additions & 30 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,18 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1515

1616
### Added
1717

18+
### Added
19+
1820
- Plan and counsel workers require substance in Findings (files/paths,
1921
acceptance criteria, non-goals, risks, ordered steps). Four headings
2022
with stub Findings salvage as `incomplete-report`, not an attachable
2123
plan. Implement and review envelope completeness is unchanged.
24+
- Consecutive same-tool transcript calls collapse into one row with a count
25+
chip (`· ×N`). Settled lanes use a past-tense head (`Grepped ×3 · "corbits"`).
26+
`spawn_agent` stays one row per dispatch; `manage_tasks` paints no row.
27+
- Pending `run_shell` rows stream up to three live output lines from a
28+
bounded 8 KiB feed, then a last-three preview and a non-zero `exit N` at
29+
settle.
2230

2331
### Fixed
2432

@@ -31,7 +39,6 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3139
ask is dropped. Do not poll `list_agents`.
3240

3341

34-
3542
## [0.3.21] - 2026-09-11
3643

3744
### Added

‎docs/TUI.md‎

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ down the left edge (`userBubbleLines` in `src/tui/stream.ts`). Each bubble
6363
keeps one empty bar row above and below its text so the operator's voice
6464
stays easy to find while scrolling through denser assistant and tool rows —
6565
the pad is part of the bubble itself, not an extra turn-boundary gap, and
66-
assistant/tool rows are unchanged.
66+
assistant rows are unchanged; tool rows are lanes (see Tool lanes below).
6767

6868
Parent live reasoning paints through the existing thinking row — never a
6969
third mid-turn stream lane. While `inference.thinking.delta` arrives,
@@ -75,6 +75,63 @@ same one row per turn (`reasoning-fold`); `inference.text.delta` grows the
7575
open assistant streaming row in place. Worker spawn_agent-row thinking is a
7676
separate path and is unchanged by this preview.
7777

78+
## Tool lanes
79+
80+
Transcript tool rows group by tool, not by sentence. A call for a tool the
81+
previous row already represents folds onto that row instead of opening a new
82+
one (`src/tui/tool-rows.ts`); the row narrates the newest call's subject and,
83+
while calls are in flight, carries a dim count chip (`· ×N`) after the
84+
subject. `spawn_agent` never folds — each dispatch is its own live anchor —
85+
and `manage_tasks` paints no transcript row at all.
86+
87+
When the lane settles (its last outstanding answer landed), the head rewrites
88+
to a past-tense count over the latest subject — `Grepped ×3 · "corbits"` —
89+
with the count taken over calls, never over payload items (a lane of three
90+
greps says `×3` even if the payloads returned forty matches in total; nothing
91+
here can substantiate a payload total). Each call's own answer stays behind
92+
the expand arrow. For an edit lane the per-call `+n/-n path` addenda remain
93+
in the expanded body, so folding never buries which file each call touched.
94+
Single-call rows (count <= 1) render exactly as they did before lanes.
95+
96+
The past tense is a map keyed by raw tool name (`src/tui/tool-formatter.ts`:
97+
`grep` → `Grepped`, `read_file` → `Read`, `write_file` → `Wrote`,
98+
`edit_file` → `Edited`, `run_shell` → `Ran`, `list_dir` → `Listed`,
99+
`search_files` → `Searched`, …); an unknown tool falls back to its display
100+
name.
101+
102+
Because a lane's row identity moves to the newest call, a lane also carries
103+
the call ids it absorbed (`memberIds`, newest appended). A
104+
result resolves its lane when its call id is the row's own id **or** one of
105+
its members — this is what pairs a resumed transcript's parallel batch
106+
(call, call, result, result) correctly. An id matching nothing still answers
107+
nothing: it is appended as its own row, never folded onto the newest
108+
same-name lane.
109+
110+
Alt+C on a lane copies the most recent call's full output (see the Alt+C
111+
bullet under Clipboard and mouse).
112+
113+
### Shell output lanes
114+
115+
A pending `run_shell` row shows the command head and elapsed clock as today,
116+
plus up to three dim tail lines of the command's live output and — when the
117+
feed window holds more than three lines — the same dim `⋯ +N lines` elision
118+
marker as the settle preview, `N` counting lines within the live window (the
119+
feed keeps only the most recent 8 KiB). The output
120+
travels through a polled bounded feed (`src/session/shell-output-feed.ts`),
121+
not a reactor event: the plugin appends chunks (capped at an 8 KiB tail per
122+
call, emitting at most once per 100 ms plus a final flush at settle), and
123+
the product host's sticky poll reads the feed snapshot every 200 ms and
124+
repaints the pending row frame-coalesced. Nothing from the feed is
125+
persisted. When the feed is not wired (tests, the demo shell), the row
126+
renders exactly as before — silent degradation.
127+
128+
At settle the live tail is replaced by a preview of the full output: its
129+
last three lines plus a dim `⋯ +N lines` elision marker when more were
130+
produced (the marker carries the count; the "N lines" stat is not painted
131+
on shell rows). A non-zero exit adds an `exit N` stat; a zero exit adds
132+
none. The existing expand idiom (Alt+E / click / the row arrow) reveals the
133+
full output, hiding the preview; the idiom and the arrow are unchanged.
134+
78135
The prompt box's border carries the metadata that would otherwise cost a
79136
titlebar row: the model label sits right-aligned in the top rule as
80137
`profile · model · effort` (empty segments omitted), and a
@@ -249,7 +306,10 @@ clocks.
249306
`runtime-bridge` paints each `spawn_agent` call as a transcript stream row for
250307
**spawn / final / fail anchors**. While the agents strip is sticky, sticky-poll
251308
`syncAgentProgress` rewrites are gated off so the transcript is not a dual live
252-
rail. Ordinary in-flight tool rows keep their own elapsed clock
309+
rail. A `spawn_agent` call never folds into a tool lane: each dispatch keeps
310+
its own transcript row for its whole lifetime, because the row is the live
311+
progress anchor, not just a call record. Ordinary in-flight tool rows keep
312+
their own elapsed clock
253313
(`syncToolElapsed`) without the current-tool suffix.
254314

255315
### Unprompted fleet reports
@@ -725,7 +785,9 @@ running its own selection. Two chords cover remaining copy needs:
725785
(`enterCopyMode`) that resolves through the system clipboard port
726786
(`src/tui/system-clipboard.ts` — a native helper binary per
727787
platform, `pbcopy`/`clip`/`wl-copy`/`xclip`/`xsel`, falling back to an OSC
728-
52 escape sequence when no helper is available, e.g. over SSH).
788+
52 escape sequence when no helper is available, e.g. over SSH). On a
789+
coalesced tool lane the copy resolves to the most recent call's full
790+
output; a single-call row copies its own output, exactly as before.
729791

730792
Arrow keys never scroll anything — inside the prompt they are caret motion
731793
or, at the buffer's edges, prompt-history recall; inside an open overlay's
@@ -787,6 +849,10 @@ terminal. It cannot observe:
787849

788850
- **Real paint.** Tests assert on the shell's in-memory row/rect state, not
789851
on what a terminal emulator actually draws to a screen buffer.
852+
- **The live shell tail's wall clock.** The feed itself is pure and the
853+
cadence is injectable, so the live tail is headless-testable by driving a
854+
fake feed and a fake clock through the same sync path the sticky poll
855+
uses; what the harness cannot see is real-time emission timing.
790856
- **Modifier reporting.** Whether a real terminal can report Shift+Enter,
791857
Alt+letter, or similar modifier combinations depends on the terminal
792858
negotiating the kitty keyboard protocol (or an equivalent) with the actual

‎src/agent/posix-tool-plugins.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import type { PermissionGate } from "../permission/gate.js";
3131
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
3232
import type { CompactionArchive } from "../session/compaction-archive.js";
33+
import type { ShellOutputFeedMap } from "../session/shell-output-feed.js";
3334

3435
export interface CorePosixToolPluginsArgs {
3536
cwd: string;
@@ -47,6 +48,9 @@ export interface CorePosixToolPluginsArgs {
4748
// Live getter for the background-shell registry (run_shell background:true).
4849
// Omitted makes background runs fail closed in shell-guard.
4950
getBackgroundShellRegistry?: () => BackgroundShellRegistry | undefined;
51+
// Live getter for the per-call bounded shell-output feeds the transcript
52+
// polls for a running command's live tail. Omitted leaves the tail unwired.
53+
getShellOutputFeeds?: () => ShellOutputFeedMap | undefined;
5054
/** Primary-only evidence archive; workers omit this getter. */
5155
getEvidenceArchive?: () => CompactionArchive | undefined;
5256
}
@@ -86,6 +90,7 @@ export function buildCorePosixToolPlugins(
8690
getContextDir,
8791
shellEnv,
8892
getBackgroundShellRegistry,
93+
getShellOutputFeeds,
8994
getEvidenceArchive,
9095
} = args;
9196
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
@@ -119,6 +124,7 @@ export function buildCorePosixToolPlugins(
119124
...(getBackgroundShellRegistry !== undefined
120125
? { getBackgroundShellRegistry }
121126
: {}),
127+
...(getShellOutputFeeds !== undefined ? { getShellOutputFeeds } : {}),
122128
}),
123129
...(getEvidenceArchive !== undefined
124130
? [evidenceArchiveSearchPlugin(getEvidenceArchive)]

‎src/agent/tools.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
createBackgroundShellRegistry,
9191
type BackgroundShellExit,
9292
} from "../shell/background-shell.js";
93+
import { createShellOutputFeedMap } from "../session/shell-output-feed.js";
9394
import { createListDirTool } from "../util/list-dir.js";
9495
import {
9596
createExaMCPWebFetchTool,
@@ -289,6 +290,9 @@ export interface AgentToolset {
289290
callbacks: MCPConnectCallbacks,
290291
signal?: AbortSignal,
291292
) => Promise<void>;
293+
// Per-call bounded live-output tails of foreground shells, polled by the
294+
// transcript for each pending run_shell row's live lines.
295+
shellOutputFeed: ReturnType<typeof createShellOutputFeedMap>;
292296
// Connect one newly persisted server through the same lifecycle as startup MCP.
293297
connectMCPServer: (
294298
config: MCPServerConfig,
@@ -360,6 +364,10 @@ export async function createAgentToolset(
360364
: {}),
361365
});
362366
const shellCollect = createShellCollectTool(backgroundShells);
367+
// Per-call bounded live-output tails of foreground shells, polled by the TUI
368+
// for each pending run_shell row's live lines. Workers get a map too; nothing
369+
// reads it unless a transcript polls it (silent degradation).
370+
const shellOutputFeed = createShellOutputFeedMap();
363371
const sessionBlobReader =
364372
getBlobReader !== undefined
365373
? createLazyBlobReader(getBlobReader)
@@ -447,6 +455,7 @@ export async function createAgentToolset(
447455
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
448456
...(shellEnv !== undefined ? { shellEnv } : {}),
449457
getBackgroundShellRegistry: () => backgroundShells,
458+
getShellOutputFeeds: () => shellOutputFeed,
450459
}),
451460
});
452461

@@ -1179,6 +1188,7 @@ export async function createAgentToolset(
11791188
return {
11801189
dynamicRunner,
11811190
connectMCP,
1191+
shellOutputFeed,
11821192
connectMCPServer: publicConnectMCPServer,
11831193
disconnectMCPServer: publicDisconnectMCPServer,
11841194
hasMCPServer: (name) =>

‎src/plugins/shell-guard-plugin.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import { spawnSync, type ChildProcess } from "node:child_process";
1010
import { randomUUID } from "node:crypto";
1111

1212
import { createBackgroundShellRegistry } from "../shell/background-shell.js";
13+
import { createShellOutputFeed } from "../session/shell-output-feed.js";
14+
1315
import {
1416
BoundedShellOutput,
1517
MAX_SHELL_OUTPUT_BYTES,
18+
SHELL_FEED_EMIT_MS,
1619
advertiseShellGuardTimeout,
1720
resolveShellTimeoutMs,
1821
reapLiveChildren,
@@ -37,6 +40,35 @@ describe("runGuardedShell", () => {
3740
expect(output).toContain("hello");
3841
});
3942

43+
test("a rate-limited second write reaches the feed before the process exits", async () => {
44+
const feed = createShellOutputFeed();
45+
let finished = false;
46+
const running = runGuardedShell(
47+
{ command: "echo first; sleep 0.02; echo second; sleep 0.4" },
48+
neverAbort(),
49+
undefined,
50+
undefined,
51+
(text) => {
52+
feed.append(text);
53+
},
54+
).then((result) => {
55+
finished = true;
56+
return result;
57+
});
58+
const deadline = Date.now() + SHELL_FEED_EMIT_MS + 80;
59+
while (
60+
!feed.snapshot().includes("second") &&
61+
Date.now() < deadline &&
62+
!finished
63+
) {
64+
await Bun.sleep(10);
65+
}
66+
expect(finished).toBe(false);
67+
expect(feed.snapshot()).toContain("second");
68+
const result = await running;
69+
expect(result.exitCode).toBe(0);
70+
});
71+
4072
test("omitted timeout does not arm a timer", async () => {
4173
const start = Date.now();
4274
const { exitCode, timedOut, output } = await runGuardedShell(
@@ -309,6 +341,66 @@ describe("background run_shell (shellGuardPlugin)", () => {
309341
expect(registry.runningCount()).toBe(0);
310342
registry.disposeAll("test done");
311343
});
344+
345+
test("an unwired shell-output feed spawns fine and paints no tail", async () => {
346+
const handler = defined(
347+
shellGuardPlugin(process.cwd(), undefined, undefined, {}).middleware,
348+
)(fallback);
349+
const result = await handler(
350+
{ id: "fg2", name: "run_shell", arguments: { command: "echo hi" } },
351+
neverAbort(),
352+
);
353+
expect(result.isError).toBeUndefined();
354+
expect(String(result.content)).toContain("hi");
355+
});
356+
357+
test("a wired feed receives the output tail at cadence with a final flush", async () => {
358+
const feed = createShellOutputFeed();
359+
let emits = 0;
360+
const handler = defined(
361+
shellGuardPlugin(process.cwd(), undefined, undefined, {
362+
getShellOutputFeeds: () => {
363+
const wrapped = {
364+
append: (text: string) => {
365+
emits += 1;
366+
feed.append(text);
367+
},
368+
snapshot: () => feed.snapshot(),
369+
clear: () => feed.clear(),
370+
};
371+
return {
372+
forCall: () => wrapped,
373+
get: () => wrapped,
374+
drop: () => undefined,
375+
};
376+
},
377+
}).middleware,
378+
)(fallback);
379+
const result = await handler(
380+
{
381+
id: "fg3",
382+
name: "run_shell",
383+
arguments: {
384+
// Fifteen lines ~10 ms apart: far more chunk arrivals than one
385+
// cadence window per 100 ms can allow. Without the Date.now() gate
386+
// in emitPendingOutput every arrival emits (~16 emissions) and this
387+
// ceiling fails — the assertion is what pins the cadence.
388+
command:
389+
"i=1; while [ $i -le 15 ]; do echo line$i; sleep 0.01; i=$((i+1)); done",
390+
},
391+
},
392+
neverAbort(),
393+
);
394+
expect(result.isError).toBeUndefined();
395+
// The final flush lands the tail (including the last line) in the feed.
396+
expect(feed.snapshot()).toContain("line1");
397+
expect(feed.snapshot()).toContain("line15");
398+
// At most one emit per 100 ms of wall time (~300 ms with the pwd probe
399+
// trailer), plus the final flush. Still far below the ~16 arrivals, so a
400+
// broken cadence gate cannot pass.
401+
const elapsedMs = 350;
402+
expect(emits).toBeLessThanOrEqual(Math.ceil(elapsedMs / 100) + 1);
403+
});
312404
});
313405

314406
describe("advertiseShellGuardTimeout", () => {

0 commit comments

Comments
 (0)