Skip to content

Commit 189d964

Browse files
Merge pull request #1467 from corbitsdev/cl-10640-tui-perf-timeline-culling-buffering
perf(tui): drive motion with Timeline and drop the sticky poll
2 parents 27b1f66 + 043d497 commit 189d964

25 files changed

Lines changed: 527 additions & 192 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,11 +381,13 @@ describe("background run_shell (shellGuardPlugin)", () => {
381381
},
382382
snapshot: () => feed.snapshot(),
383383
clear: () => feed.clear(),
384+
subscribe: () => () => undefined,
384385
};
385386
return {
386387
forCall: () => wrapped,
387388
get: () => wrapped,
388389
drop: () => undefined,
390+
subscribe: () => () => undefined,
389391
};
390392
},
391393
}).middleware,

‎src/session/shell-output-feed.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ describe("shell output feed", () => {
1616
expect(feed.snapshot()).toBe("");
1717
});
1818

19+
test("subscribe fires on append and clear, not on an empty clear", () => {
20+
const feed = createShellOutputFeed();
21+
let notices = 0;
22+
const stop = feed.subscribe(() => {
23+
notices += 1;
24+
});
25+
feed.append("hello");
26+
feed.clear();
27+
feed.clear();
28+
expect(notices).toBe(2);
29+
stop();
30+
feed.append("again");
31+
expect(notices).toBe(2);
32+
});
33+
1934
test("keeps the tail under a burst larger than the bound", () => {
2035
const feed = createShellOutputFeed(1024);
2136
const burst = Array.from(
@@ -57,4 +72,15 @@ describe("shell output feed map", () => {
5772
expect(feeds.get("a")).toBeUndefined();
5873
expect(feeds.get("b")?.snapshot()).toBe("beta\n");
5974
});
75+
76+
test("the map hears appends on feeds created after subscribe", () => {
77+
const feeds = createShellOutputFeedMap();
78+
let notices = 0;
79+
feeds.subscribe(() => {
80+
notices += 1;
81+
});
82+
feeds.forCall("a").append("one");
83+
feeds.forCall("b").append("two");
84+
expect(notices).toBe(2);
85+
});
6086
});

‎src/session/shell-output-feed.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** Bounded live-output tail of a running shell command. The shell-guard
2-
* plugin appends chunks; the TUI polls `snapshot()` on its sticky tick.
2+
* plugin appends chunks; subscribers repaint the transcript tail.
33
* Nothing is persisted. */
44

55
/** Tail of output a feed keeps before the oldest chunk is dropped. */
@@ -11,13 +11,17 @@ export interface ShellOutputFeed {
1111
snapshot(): string;
1212
/** Drop everything retained in this feed. */
1313
clear(): void;
14+
/** Fired after a non-empty append or a clear. */
15+
subscribe(listener: () => void): () => void;
1416
}
1517

1618
/** Per-call live tails so parallel run_shells cannot share or wipe a sibling. */
1719
export interface ShellOutputFeedMap {
1820
forCall(callId: string): ShellOutputFeed;
1921
get(callId: string): ShellOutputFeed | undefined;
2022
drop(callId: string): void;
23+
/** Fired when any call's feed appends or clears. */
24+
subscribe(listener: () => void): () => void;
2125
}
2226

2327
function byteLength(text: string): number {
@@ -29,6 +33,10 @@ export function createShellOutputFeed(
2933
): ShellOutputFeed {
3034
let chunks: string[] = [];
3135
let totalBytes = 0;
36+
const listeners = new Set<() => void>();
37+
const notify = (): void => {
38+
for (const listener of listeners) listener();
39+
};
3240
const recompute = (): void => {
3341
while (totalBytes > limitBytes && chunks.length > 1) {
3442
const dropped = chunks.shift();
@@ -52,24 +60,38 @@ export function createShellOutputFeed(
5260
chunks.push(chunk);
5361
totalBytes += byteLength(chunk);
5462
recompute();
63+
notify();
5564
},
5665
snapshot(): string {
5766
return chunks.join("");
5867
},
5968
clear(): void {
69+
if (chunks.length === 0) return;
6070
chunks = [];
6171
totalBytes = 0;
72+
notify();
73+
},
74+
subscribe(listener: () => void): () => void {
75+
listeners.add(listener);
76+
return () => {
77+
listeners.delete(listener);
78+
};
6279
},
6380
};
6481
}
6582

6683
export function createShellOutputFeedMap(): ShellOutputFeedMap {
6784
const feeds = new Map<string, ShellOutputFeed>();
85+
const listeners = new Set<() => void>();
86+
const notify = (): void => {
87+
for (const listener of listeners) listener();
88+
};
6889
return {
6990
forCall(callId: string): ShellOutputFeed {
7091
let feed = feeds.get(callId);
7192
if (feed === undefined) {
7293
feed = createShellOutputFeed();
94+
feed.subscribe(notify);
7395
feeds.set(callId, feed);
7496
}
7597
return feed;
@@ -80,5 +102,11 @@ export function createShellOutputFeedMap(): ShellOutputFeedMap {
80102
drop(callId: string): void {
81103
feeds.delete(callId);
82104
},
105+
subscribe(listener: () => void): () => void {
106+
listeners.add(listener);
107+
return () => {
108+
listeners.delete(listener);
109+
};
110+
},
83111
};
84112
}

‎src/tui/frame-timeline.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Renderer-owned motion clock. OpenTUI's Timeline engine calls requestLive
3+
* while a timeline is playing, so animation frames are not a setInterval.
4+
*/
5+
import {
6+
createTimeline,
7+
getTimelineEngine,
8+
type CliRenderer,
9+
} from "@opentui/core";
10+
11+
/** Start a looping timeline. `onFrame` receives the frame delta in ms. */
12+
export function startFrameTimeline(
13+
renderer: CliRenderer,
14+
onFrame: (deltaMs: number) => void,
15+
): () => void {
16+
const target = { cycle: 0 };
17+
let stopped = false;
18+
const timeline = createTimeline(
19+
{ duration: 1_000, loop: true, autoplay: true },
20+
renderer,
21+
);
22+
timeline.add(target, {
23+
cycle: 1,
24+
duration: 1_000,
25+
ease: "linear",
26+
loop: true,
27+
onUpdate: (animation) => {
28+
if (stopped) return;
29+
const deltaMs = animation.deltaTime;
30+
if (!(deltaMs > 0)) return;
31+
onFrame(deltaMs);
32+
},
33+
});
34+
return () => {
35+
if (stopped) return;
36+
stopped = true;
37+
timeline.pause();
38+
try {
39+
getTimelineEngine(renderer).unregister(timeline);
40+
} catch {
41+
// Renderer already destroyed or the engine detached with it.
42+
}
43+
};
44+
}

‎src/tui/landing.test.ts‎

Lines changed: 23 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
/** Landing anatomy: mark, centred prompt box, telemetry disclosure,
22
* starters — gone once the transcript has content. */
3-
import { afterEach, describe, expect, test } from "bun:test";
3+
import { describe, expect, test } from "bun:test";
44
import type { CapturedSpan } from "@opentui/core";
55
import { rgbToHex } from "@opentui/core";
66
import { defined } from "../../testkit/defined.js";
77
import { makePermissionItems, type Harness } from "./harness";
88
import {
99
appendStreamRow,
1010
applyLandingSuggestion,
11-
LANDING_IDLE_REPAINT_INTERVAL_MS,
1211
noticeText,
1312
paintChrome,
1413
setChromeZones,
@@ -17,7 +16,7 @@ import {
1716
toggleTasksPanel,
1817
} from "./shell/chrome";
1918
import { withAppShell } from "./test-helpers";
20-
import { isLanding } from "./shell/internals";
19+
import { isLanding, shellInternals } from "./shell/internals";
2120
import {
2221
setPromptModelLabel,
2322
setPromptWorkspace,
@@ -46,58 +45,8 @@ import { SNOW_CHAR } from "./mark-raster";
4645
const SIZE = { width: 80, height: 24 } as const;
4746
const NOTICE = "Anonymous usage telemetry is enabled. Disable in /settings.";
4847

49-
const nativeSetInterval = globalThis.setInterval;
50-
const nativeClearInterval = globalThis.clearInterval;
51-
5248
const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " ");
5349

54-
interface IdleTimerHandle {
55-
unref?: () => void;
56-
}
57-
58-
/** Intercepts the idle interval so tests can see a still-armed timer
59-
* regardless of cadence. */
60-
function wrapLandingIdleTimer(): {
61-
armed: IdleTimerHandle[];
62-
cleared: IdleTimerHandle[];
63-
} {
64-
const armed: IdleTimerHandle[] = [];
65-
const cleared: IdleTimerHandle[] = [];
66-
// Do not arm a real interval. Callers inject clocks or only inspect handles.
67-
globalThis.setInterval = ((
68-
handler: Parameters<typeof nativeSetInterval>[0],
69-
delay?: number,
70-
...args: unknown[]
71-
) => {
72-
if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) {
73-
const handle: IdleTimerHandle = {};
74-
armed.push(handle);
75-
return handle;
76-
}
77-
return nativeSetInterval.call(globalThis, handler, delay, ...args);
78-
}) as typeof nativeSetInterval;
79-
globalThis.clearInterval = ((
80-
handle: Parameters<typeof nativeClearInterval>[0],
81-
) => {
82-
cleared.push(handle as IdleTimerHandle);
83-
if (armed.includes(handle as IdleTimerHandle)) return;
84-
return nativeClearInterval.call(globalThis, handle);
85-
}) as typeof nativeClearInterval;
86-
return { armed, cleared };
87-
}
88-
89-
function soleLandingIdleHandle(
90-
armed: readonly IdleTimerHandle[],
91-
): IdleTimerHandle {
92-
const handle = armed[0];
93-
if (armed.length !== 1 || handle === undefined) {
94-
throw new Error(
95-
`expected exactly one ${LANDING_IDLE_REPAINT_INTERVAL_MS}ms interval, got ${armed.length}`,
96-
);
97-
}
98-
return handle;
99-
}
100-
10150
/** Newly added scroll-box children need a layout pass before they paint. */
10251
async function settle(h: Harness): Promise<void> {
10352
await h.renderOnce();
@@ -314,17 +263,12 @@ describe("landing screen", () => {
314263
);
315264
}, 15_000);
316265

317-
describe("landing idle timer", () => {
318-
afterEach(() => {
319-
globalThis.setInterval = nativeSetInterval;
320-
globalThis.clearInterval = nativeClearInterval;
321-
});
322-
323-
test("reduced-motion mount never arms the idle timer and never draws snow", async () => {
324-
const { armed } = wrapLandingIdleTimer();
266+
describe("landing idle motion", () => {
267+
test("reduced-motion mount never arms snow and never draws it", async () => {
325268
await withAppShell(
326269
async (shell, h) => {
327-
expect(armed).toHaveLength(0);
270+
expect(shellInternals(shell)?.landingIdleTimerCancel).toBeNull();
271+
expect(shellInternals(shell)?.markSnow).toBeNull();
328272
await settle(h);
329273
const first = markRows(h).join("\n");
330274
expect(first.includes(SNOW_CHAR)).toBe(false);
@@ -349,17 +293,20 @@ describe("landing screen", () => {
349293
);
350294
});
351295

352-
test("a deferred system notice does not clear the landing idle timer", async () => {
353-
const { armed, cleared } = wrapLandingIdleTimer();
296+
test("a deferred system notice does not stop landing snow", async () => {
354297
await withAppShell(
355298
async (shell) => {
356-
const handle = soleLandingIdleHandle(armed);
299+
expect(shellInternals(shell)?.landingIdleTimerCancel).toBeTypeOf(
300+
"function",
301+
);
357302
surfaceSystemNotice(
358303
shell,
359304
"mcp github did not connect (ECONNREFUSED) — its tools are unavailable; /mcp for detail",
360305
);
361306
expect(isLanding(shell)).toBe(true);
362-
expect(cleared).not.toContain(handle);
307+
expect(shellInternals(shell)?.landingIdleTimerCancel).toBeTypeOf(
308+
"function",
309+
);
363310
},
364311
{
365312
shell: {
@@ -369,14 +316,15 @@ describe("landing screen", () => {
369316
);
370317
});
371318

372-
test("appending a transcript row clears the landing idle timer", async () => {
373-
const { armed, cleared } = wrapLandingIdleTimer();
319+
test("appending a transcript row stops landing snow", async () => {
374320
await withAppShell(
375321
async (shell) => {
376-
const handle = soleLandingIdleHandle(armed);
322+
expect(shellInternals(shell)?.landingIdleTimerCancel).toBeTypeOf(
323+
"function",
324+
);
377325
appendStreamRow(shell, { role: "user", text: "first prompt" });
378326
expect(isLanding(shell)).toBe(false);
379-
expect(cleared).toContain(handle);
327+
expect(shellInternals(shell)?.landingIdleTimerCancel).toBeNull();
380328
},
381329
{
382330
shell: {
@@ -386,13 +334,14 @@ describe("landing screen", () => {
386334
);
387335
});
388336

389-
test("disposing the shell with no transcript clears the landing idle timer", async () => {
390-
const { armed, cleared } = wrapLandingIdleTimer();
337+
test("disposing the shell with no transcript stops landing snow", async () => {
391338
await withAppShell(
392339
async (shell) => {
393-
const handle = soleLandingIdleHandle(armed);
340+
expect(shellInternals(shell)?.landingIdleTimerCancel).toBeTypeOf(
341+
"function",
342+
);
394343
shell.dispose();
395-
expect(cleared).toContain(handle);
344+
expect(shellInternals(shell)?.landingIdleTimerCancel).toBeNull();
396345
},
397346
{
398347
shell: {

‎src/tui/mark-raster.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export interface MarkInput {
8585
readonly still: boolean;
8686
/** Suppresses snow regardless of `still`. Defaults to off. */
8787
readonly reducedMotion?: boolean;
88+
/**
89+
* Draw flakes into the glyph grid. Idle landing snow is a post-process
90+
* instead, so the text nodes stay still. Defaults to on.
91+
*/
92+
readonly snow?: boolean;
8893
/** Baked rasterization to composite; defaults to the compact grid. */
8994
readonly grid?: MarkGrid;
9095
}
@@ -118,6 +123,16 @@ function snowflakeAt(
118123
return Math.floor(wrapped) === row;
119124
}
120125

126+
/** Same flake field the mark raster uses, for the landing post-process. */
127+
export function isMarkSnowflake(
128+
row: number,
129+
col: number,
130+
seconds: number,
131+
rows: number,
132+
): boolean {
133+
return snowflakeAt(row, col, seconds, rows);
134+
}
135+
121136
/**
122137
* Composite one frame. The silhouette is drawn solid: a fully covered cell
123138
* is `█`, a partly covered one is the eighth block matching its coverage,
@@ -134,7 +149,7 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] {
134149
const fillLine = shape.rows * (1 - fillProg);
135150
// Not gated by `still`; fade-out drops the snow so it never outlasts
136151
// the mark it drifts over.
137-
const snowOn = alpha === 1 && !input.reducedMotion;
152+
const snowOn = alpha === 1 && !input.reducedMotion && input.snow !== false;
138153

139154
const grid: MarkCell[][] = [];
140155
for (let row = 0; row < shape.rows; row++) {

0 commit comments

Comments
 (0)