Skip to content

Commit 15d2b68

Browse files
committed
Retext non-markdown rows in place at frame cadence
1 parent 8b1ccd4 commit 15d2b68

9 files changed

Lines changed: 547 additions & 64 deletions

‎scripts/budgets.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@
169169
"src/tui/residuals.ts": 51,
170170
"src/tui/resume-seed.test.ts": 45,
171171
"src/tui/row-click.test.ts": 79,
172+
"src/tui/row-update-queue.ts": 53,
173+
"src/tui/row-update-perf.test.ts": 400,
172174
"src/tui/run-snapshot-kind.test.ts": 84,
173175
"src/tui/runner-exit-code.test.ts": 81,
174176
"src/tui/runner-host.test.ts": 811,
@@ -215,6 +217,7 @@
215217
"src/tui/shell/overlay-list.ts": 276,
216218
"src/tui/shell/palette.ts": 589,
217219
"src/tui/shell/prompt.ts": 405,
220+
"src/tui/shell/row-retext.ts": 137,
218221
"src/tui/shell/transcript.ts": 506,
219222
"src/tui/slash-popup-gate.test.ts": 1132,
220223
"src/tui/smoke.ts": 10,

‎src/tui/row-update-perf.test.ts‎

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
/**
2+
* Perf gate for CL-6791 P5-J3: non-markdown rows must update in place or at
3+
* frame cadence — N updates to a row within one frame apply at most once, and
4+
* no update destroys and rebuilds the row's paint subtree.
5+
*/
6+
import { describe, expect, test } from "bun:test";
7+
import {
8+
attachSessionBridge,
9+
createRecordingPort,
10+
type TaskProgressSession,
11+
} from "./runtime-bridge";
12+
import { createAppShell } from "./shell/index";
13+
import { transcriptRowChildren, streamRowCount, streamRowAt } from "./shell/transcript";
14+
import { toolResultRow } from "./mcp-view";
15+
import { withTestRenderer } from "./harness";
16+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
17+
import type { AppShell } from "./shell/internals.js";
18+
import type { StreamRow } from "./stream.js";
19+
20+
type ChromeModule = typeof import("./shell/chrome.js");
21+
type TeardownModule = typeof import("./teardown.js");
22+
23+
interface WorkCounts {
24+
destroys: number;
25+
builds: number;
26+
replaces: number;
27+
}
28+
29+
/**
30+
* Count real work, not calls: subtree destroys (teardown), node rebuilds
31+
* (createStreamRowRenderable) and row retexts (replaceStreamRowAt) — the
32+
* seams a destroy-rebuild would have to pass through.
33+
*/
34+
async function withCountedWork<R>(run: (work: WorkCounts) => Promise<R>): Promise<R> {
35+
const work: WorkCounts = { destroys: 0, builds: 0, replaces: 0 };
36+
return withMockedModuleDuring<TeardownModule, R>(
37+
import.meta.resolve("./teardown.js"),
38+
(real) => ({
39+
...real,
40+
destroySubtree: (node: unknown) => {
41+
work.destroys++;
42+
real.destroySubtree(node);
43+
},
44+
}),
45+
() =>
46+
withMockedModuleDuring<ChromeModule, R>(
47+
import.meta.resolve("./shell/chrome.js"),
48+
(real) => ({
49+
...real,
50+
replaceStreamRowAt: (shell: AppShell, index: number, row: StreamRow) => {
51+
work.replaces++;
52+
real.replaceStreamRowAt(shell, index, row);
53+
},
54+
createStreamRowRenderable: (
55+
...args: Parameters<typeof real.createStreamRowRenderable>
56+
) => {
57+
work.builds++;
58+
return real.createStreamRowRenderable(...args);
59+
},
60+
}),
61+
() => run(work),
62+
),
63+
);
64+
}
65+
66+
const SHELL_OPTS = {
67+
terminal: { columns: 100, rows: 24 },
68+
wireKeys: false,
69+
run: "idle",
70+
} as const;
71+
72+
function taskSession(over: Partial<TaskProgressSession>): TaskProgressSession {
73+
return {
74+
id: "task-1",
75+
status: "running",
76+
currentToolName: "grep",
77+
currentToolPreview: null,
78+
currentToolStartedAt: null,
79+
startedAt: 0,
80+
lastActivityAt: 0,
81+
...over,
82+
};
83+
}
84+
85+
describe("row update perf gates (J3)", () => {
86+
test("N diff-row updates within one frame apply once, with no destroy or rebuild", async () => {
87+
await withCountedWork(async (work) => {
88+
await withTestRenderer(
89+
async (h) => {
90+
const shell = createAppShell(h.renderer, SHELL_OPTS);
91+
const bridge = attachSessionBridge(shell, createRecordingPort());
92+
try {
93+
bridge.handle({ type: "inference.start", data: {} });
94+
const arguments_ = JSON.stringify({
95+
path: "src/a.ts",
96+
oldText: "x",
97+
newText: "y",
98+
});
99+
for (let i = 0; i < 5; i++) {
100+
bridge.handle({
101+
type: "inference.tool_call.end",
102+
data: { name: "edit_file", callId: `c${i}`, arguments: arguments_ },
103+
});
104+
}
105+
// The first call appends; the four repeats only fold into the
106+
// pending snapshot — nothing has repainted yet.
107+
expect(streamRowCount(shell)).toBe(1);
108+
expect(work.replaces).toBe(0);
109+
const destroysBeforeFrame = work.destroys;
110+
await h.renderOnce();
111+
expect(work.replaces).toBe(1);
112+
// The coalesced repeat changes the row's shape (a run header, no
113+
// diff body), so the single frame-time application may rebuild —
114+
// but only once, never once per repeat.
115+
expect(work.destroys - destroysBeforeFrame).toBeLessThanOrEqual(1);
116+
const row = streamRowAt(shell, 0);
117+
expect(row?.coalesced).toBe(true);
118+
expect(row?.outstanding).toBe(5);
119+
// An idle frame applies nothing further.
120+
await h.renderOnce();
121+
expect(work.replaces).toBe(1);
122+
} finally {
123+
bridge.dispose();
124+
shell.dispose();
125+
}
126+
},
127+
{ width: 100, height: 24 },
128+
);
129+
});
130+
});
131+
132+
test("tool elapsed ticks: unchanged clock applies nothing, changed clock once per frame", async () => {
133+
await withCountedWork(async (work) => {
134+
await withTestRenderer(
135+
async (h) => {
136+
const shell = createAppShell(h.renderer, SHELL_OPTS);
137+
let nowMs = 1_000;
138+
let tick: (() => void) | undefined;
139+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
140+
now: () => nowMs,
141+
schedule: (fn: () => void) => {
142+
tick = fn;
143+
return () => {};
144+
},
145+
});
146+
try {
147+
bridge.handle({ type: "inference.start", data: {} });
148+
bridge.handle({
149+
type: "inference.tool_call.end",
150+
data: { name: "bash", callId: "b1", arguments: { command: "sleep 5" } },
151+
});
152+
await h.renderOnce();
153+
const baseline = work.replaces;
154+
const destroys = work.destroys;
155+
156+
tick?.();
157+
await h.renderOnce();
158+
// The first tick changes the row (no stat yet -> "0:00").
159+
expect(work.replaces).toBe(baseline + 1);
160+
expect(work.destroys).toBe(destroys);
161+
162+
// Ticks within the same clock second leave the stat unchanged:
163+
// zero updates across any number of them.
164+
for (let i = 0; i < 4; i++) tick?.();
165+
await h.renderOnce();
166+
expect(work.replaces).toBe(baseline + 1);
167+
168+
nowMs += 5_000;
169+
for (let i = 0; i < 3; i++) tick?.();
170+
await h.renderOnce();
171+
expect(work.replaces).toBe(baseline + 2);
172+
expect(work.destroys).toBe(destroys);
173+
expect(streamRowAt(shell, 0)?.stat).toBe("0:05");
174+
} finally {
175+
bridge.dispose();
176+
shell.dispose();
177+
}
178+
},
179+
{ width: 100, height: 24 },
180+
);
181+
});
182+
});
183+
184+
test("N sentence-row progress updates within one frame apply once, with no destroy or rebuild", async () => {
185+
await withCountedWork(async (work) => {
186+
await withTestRenderer(
187+
async (h) => {
188+
const shell = createAppShell(h.renderer, SHELL_OPTS);
189+
const nowMs = 42_000;
190+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
191+
now: () => nowMs,
192+
});
193+
try {
194+
bridge.handle({ type: "inference.start", data: {} });
195+
bridge.handle({
196+
type: "inference.tool_call.end",
197+
data: {
198+
name: "spawn_agent",
199+
callId: "task-1",
200+
arguments: { description: "Review permission gate" },
201+
},
202+
});
203+
await h.renderOnce();
204+
const baseline = work.replaces;
205+
const destroys = work.destroys;
206+
207+
for (let i = 0; i < 4; i++) {
208+
bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]);
209+
}
210+
await h.renderOnce();
211+
expect(work.replaces).toBe(baseline + 1);
212+
expect(work.destroys).toBe(destroys);
213+
const row = streamRowAt(shell, 0);
214+
expect(row?.pending).toBe(true);
215+
expect(row?.stat).toContain("grep");
216+
217+
// Unchanged progress applies nothing further.
218+
bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]);
219+
await h.renderOnce();
220+
expect(work.replaces).toBe(baseline + 1);
221+
} finally {
222+
bridge.dispose();
223+
shell.dispose();
224+
}
225+
},
226+
{ width: 100, height: 24 },
227+
);
228+
});
229+
});
230+
231+
test("diff rows retext their lines in place when the shape is unchanged", async () => {
232+
await withCountedWork(async (work) => {
233+
await withTestRenderer(
234+
async (h) => {
235+
const shell = createAppShell(h.renderer, SHELL_OPTS);
236+
const { toolCallRow } = await import("./diff.js");
237+
const { appendStreamRow, replaceStreamRowAt } = await import("./shell/chrome.js");
238+
try {
239+
const diffRow = (newText: string): StreamRow => ({
240+
...toolCallRow({
241+
name: "edit_file",
242+
arguments: JSON.stringify({ path: "src/a.ts", oldText: "x", newText }),
243+
}),
244+
expanded: true,
245+
});
246+
appendStreamRow(shell, diffRow("y"));
247+
await h.renderOnce();
248+
const node = transcriptRowChildren(shell)[0];
249+
const builds = work.builds;
250+
const destroys = work.destroys;
251+
252+
for (let i = 0; i < 4; i++) {
253+
replaceStreamRowAt(shell, 0, diffRow(`z${i}`));
254+
}
255+
expect(work.destroys).toBe(destroys);
256+
expect(work.builds).toBe(builds);
257+
expect(transcriptRowChildren(shell)[0]).toBe(node);
258+
await h.renderOnce();
259+
expect(h.captureCharFrame()).toContain("z3");
260+
} finally {
261+
shell.dispose();
262+
}
263+
},
264+
{ width: 100, height: 24 },
265+
);
266+
});
267+
});
268+
269+
test("structured rows retext their table in place instead of rebuilding", async () => {
270+
await withCountedWork(async (work) => {
271+
await withTestRenderer(
272+
async (h) => {
273+
const shell = createAppShell(h.renderer, SHELL_OPTS);
274+
const LIST_1 = JSON.stringify({
275+
projects: [
276+
{ name: "Alpha", status: "In Progress", priority: "urgent" },
277+
{ name: "Beta", status: { name: "Done" }, priority: "low" },
278+
],
279+
});
280+
try {
281+
const { appendStreamRow, replaceStreamRowAt } = await import("./shell/chrome.js");
282+
const expandedRow = (content: string): StreamRow => ({
283+
...toolResultRow({ name: "mcp__linear__list_projects", content }),
284+
expanded: true,
285+
});
286+
appendStreamRow(shell, expandedRow(LIST_1));
287+
await h.renderOnce();
288+
const node = transcriptRowChildren(shell)[0];
289+
const builds = work.builds;
290+
const destroys = work.destroys;
291+
292+
for (let i = 0; i < 5; i++) {
293+
replaceStreamRowAt(shell, 0, expandedRow(LIST_1.replace("Alpha", `Alpha ${i}`)));
294+
}
295+
// Five updates, zero destroy-rebuilds: the same paint node
296+
// carries the new content, and only its cells changed.
297+
expect(work.destroys).toBe(destroys);
298+
expect(work.builds).toBe(builds);
299+
expect(transcriptRowChildren(shell)[0]).toBe(node);
300+
await h.renderOnce();
301+
expect(h.captureCharFrame()).toContain("Alpha 4");
302+
} finally {
303+
shell.dispose();
304+
}
305+
},
306+
{ width: 100, height: 24 },
307+
);
308+
});
309+
});
310+
});

‎src/tui/row-update-queue.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Frame-coalesced tool-row updates (CL-6791 J3): the high-frequency row
3+
* repaints — elapsed clocks, agent progress, repeat-call coalescing —
4+
* accumulate here and apply once per renderer frame through the same flush
5+
* seam as the open streaming row (J1), instead of repainting the row per
6+
* event. Immediate seams (a result merging into its call) take the pending
7+
* row back out so they read and write the freshest state.
8+
*/
9+
import { replaceStreamRowAt } from "./shell/chrome.js";
10+
import { streamRowAt } from "./shell/transcript.js";
11+
import type { AppShell } from "./shell/internals.js";
12+
import type { StreamRow } from "./stream.js";
13+
import type { BridgeBag } from "./runtime-bridge.js";
14+
15+
/** Accumulated repaints by absolute row index; the latest snapshot wins. */
16+
export type PendingRowUpdates = Map<number, StreamRow>;
17+
18+
export function scheduleRowUpdate(bag: BridgeBag, index: number, row: StreamRow): void {
19+
bag.pendingRowUpdates.set(index, row);
20+
}
21+
22+
/** The freshest row an immediate seam should read for `index`, if any. */
23+
export function takePendingRowUpdate(bag: BridgeBag, index: number): StreamRow | undefined {
24+
const row = bag.pendingRowUpdates.get(index);
25+
bag.pendingRowUpdates.delete(index);
26+
return row;
27+
}
28+
29+
/** Drop updates a rollback truncated out of the log. */
30+
export function dropPendingRowUpdatesFrom(bag: BridgeBag, boundary: number): void {
31+
for (const index of bag.pendingRowUpdates.keys()) {
32+
if (index >= boundary) bag.pendingRowUpdates.delete(index);
33+
}
34+
}
35+
36+
/** Apply every accumulated row repaint; called once per renderer frame. */
37+
export function applyPendingRowUpdates(shell: AppShell, bag: BridgeBag): void {
38+
if (bag.pendingRowUpdates.size === 0) return;
39+
const entries = [...bag.pendingRowUpdates];
40+
bag.pendingRowUpdates.clear();
41+
for (const [index, pending] of entries) {
42+
const live = streamRowAt(shell, index);
43+
// Evicted by the retention cap or truncated: nothing left to repaint.
44+
if (live === undefined) continue;
45+
// An expand/collapse toggle between schedule and flush owns the flag.
46+
const row =
47+
live.expanded !== undefined && live.expanded !== pending.expanded
48+
? { ...pending, expanded: live.expanded }
49+
: pending;
50+
replaceStreamRowAt(shell, index, row);
51+
}
52+
}

0 commit comments

Comments
 (0)