diff --git a/src/plugins/shell-guard-plugin.test.ts b/src/plugins/shell-guard-plugin.test.ts index c920598f9..49031b41b 100644 --- a/src/plugins/shell-guard-plugin.test.ts +++ b/src/plugins/shell-guard-plugin.test.ts @@ -381,11 +381,13 @@ describe("background run_shell (shellGuardPlugin)", () => { }, snapshot: () => feed.snapshot(), clear: () => feed.clear(), + subscribe: () => () => undefined, }; return { forCall: () => wrapped, get: () => wrapped, drop: () => undefined, + subscribe: () => () => undefined, }; }, }).middleware, diff --git a/src/session/shell-output-feed.test.ts b/src/session/shell-output-feed.test.ts index a3b051c69..588e0c8fb 100644 --- a/src/session/shell-output-feed.test.ts +++ b/src/session/shell-output-feed.test.ts @@ -16,6 +16,21 @@ describe("shell output feed", () => { expect(feed.snapshot()).toBe(""); }); + test("subscribe fires on append and clear, not on an empty clear", () => { + const feed = createShellOutputFeed(); + let notices = 0; + const stop = feed.subscribe(() => { + notices += 1; + }); + feed.append("hello"); + feed.clear(); + feed.clear(); + expect(notices).toBe(2); + stop(); + feed.append("again"); + expect(notices).toBe(2); + }); + test("keeps the tail under a burst larger than the bound", () => { const feed = createShellOutputFeed(1024); const burst = Array.from( @@ -57,4 +72,15 @@ describe("shell output feed map", () => { expect(feeds.get("a")).toBeUndefined(); expect(feeds.get("b")?.snapshot()).toBe("beta\n"); }); + + test("the map hears appends on feeds created after subscribe", () => { + const feeds = createShellOutputFeedMap(); + let notices = 0; + feeds.subscribe(() => { + notices += 1; + }); + feeds.forCall("a").append("one"); + feeds.forCall("b").append("two"); + expect(notices).toBe(2); + }); }); diff --git a/src/session/shell-output-feed.ts b/src/session/shell-output-feed.ts index 534e912e0..8886c0e00 100644 --- a/src/session/shell-output-feed.ts +++ b/src/session/shell-output-feed.ts @@ -1,5 +1,5 @@ /** Bounded live-output tail of a running shell command. The shell-guard - * plugin appends chunks; the TUI polls `snapshot()` on its sticky tick. + * plugin appends chunks; subscribers repaint the transcript tail. * Nothing is persisted. */ /** Tail of output a feed keeps before the oldest chunk is dropped. */ @@ -11,6 +11,8 @@ export interface ShellOutputFeed { snapshot(): string; /** Drop everything retained in this feed. */ clear(): void; + /** Fired after a non-empty append or a clear. */ + subscribe(listener: () => void): () => void; } /** Per-call live tails so parallel run_shells cannot share or wipe a sibling. */ @@ -18,6 +20,8 @@ export interface ShellOutputFeedMap { forCall(callId: string): ShellOutputFeed; get(callId: string): ShellOutputFeed | undefined; drop(callId: string): void; + /** Fired when any call's feed appends or clears. */ + subscribe(listener: () => void): () => void; } function byteLength(text: string): number { @@ -29,6 +33,10 @@ export function createShellOutputFeed( ): ShellOutputFeed { let chunks: string[] = []; let totalBytes = 0; + const listeners = new Set<() => void>(); + const notify = (): void => { + for (const listener of listeners) listener(); + }; const recompute = (): void => { while (totalBytes > limitBytes && chunks.length > 1) { const dropped = chunks.shift(); @@ -52,24 +60,38 @@ export function createShellOutputFeed( chunks.push(chunk); totalBytes += byteLength(chunk); recompute(); + notify(); }, snapshot(): string { return chunks.join(""); }, clear(): void { + if (chunks.length === 0) return; chunks = []; totalBytes = 0; + notify(); + }, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; }, }; } export function createShellOutputFeedMap(): ShellOutputFeedMap { const feeds = new Map(); + const listeners = new Set<() => void>(); + const notify = (): void => { + for (const listener of listeners) listener(); + }; return { forCall(callId: string): ShellOutputFeed { let feed = feeds.get(callId); if (feed === undefined) { feed = createShellOutputFeed(); + feed.subscribe(notify); feeds.set(callId, feed); } return feed; @@ -80,5 +102,11 @@ export function createShellOutputFeedMap(): ShellOutputFeedMap { drop(callId: string): void { feeds.delete(callId); }, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, }; } diff --git a/src/tui/frame-timeline.ts b/src/tui/frame-timeline.ts new file mode 100644 index 000000000..1d34fd29f --- /dev/null +++ b/src/tui/frame-timeline.ts @@ -0,0 +1,44 @@ +/** + * Renderer-owned motion clock. OpenTUI's Timeline engine calls requestLive + * while a timeline is playing, so animation frames are not a setInterval. + */ +import { + createTimeline, + getTimelineEngine, + type CliRenderer, +} from "@opentui/core"; + +/** Start a looping timeline. `onFrame` receives the frame delta in ms. */ +export function startFrameTimeline( + renderer: CliRenderer, + onFrame: (deltaMs: number) => void, +): () => void { + const target = { cycle: 0 }; + let stopped = false; + const timeline = createTimeline( + { duration: 1_000, loop: true, autoplay: true }, + renderer, + ); + timeline.add(target, { + cycle: 1, + duration: 1_000, + ease: "linear", + loop: true, + onUpdate: (animation) => { + if (stopped) return; + const deltaMs = animation.deltaTime; + if (!(deltaMs > 0)) return; + onFrame(deltaMs); + }, + }); + return () => { + if (stopped) return; + stopped = true; + timeline.pause(); + try { + getTimelineEngine(renderer).unregister(timeline); + } catch { + // Renderer already destroyed or the engine detached with it. + } + }; +} diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index a0d5997c5..2528f3650 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -1,6 +1,6 @@ /** Landing anatomy: mark, centred prompt box, telemetry disclosure, * starters — gone once the transcript has content. */ -import { afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import type { CapturedSpan } from "@opentui/core"; import { rgbToHex } from "@opentui/core"; import { defined } from "../../testkit/defined.js"; @@ -8,7 +8,6 @@ import { makePermissionItems, type Harness } from "./harness"; import { appendStreamRow, applyLandingSuggestion, - LANDING_IDLE_REPAINT_INTERVAL_MS, noticeText, paintChrome, setChromeZones, @@ -17,7 +16,7 @@ import { toggleTasksPanel, } from "./shell/chrome"; import { withAppShell } from "./test-helpers"; -import { isLanding } from "./shell/internals"; +import { isLanding, shellInternals } from "./shell/internals"; import { setPromptModelLabel, setPromptWorkspace, @@ -46,58 +45,8 @@ import { SNOW_CHAR } from "./mark-raster"; const SIZE = { width: 80, height: 24 } as const; const NOTICE = "Anonymous usage telemetry is enabled. Disable in /settings."; -const nativeSetInterval = globalThis.setInterval; -const nativeClearInterval = globalThis.clearInterval; - const stripSnow = (text: string) => text.replaceAll(SNOW_CHAR, " "); -interface IdleTimerHandle { - unref?: () => void; -} - -/** Intercepts the idle interval so tests can see a still-armed timer - * regardless of cadence. */ -function wrapLandingIdleTimer(): { - armed: IdleTimerHandle[]; - cleared: IdleTimerHandle[]; -} { - const armed: IdleTimerHandle[] = []; - const cleared: IdleTimerHandle[] = []; - // Do not arm a real interval. Callers inject clocks or only inspect handles. - globalThis.setInterval = (( - handler: Parameters[0], - delay?: number, - ...args: unknown[] - ) => { - if (delay === LANDING_IDLE_REPAINT_INTERVAL_MS) { - const handle: IdleTimerHandle = {}; - armed.push(handle); - return handle; - } - return nativeSetInterval.call(globalThis, handler, delay, ...args); - }) as typeof nativeSetInterval; - globalThis.clearInterval = (( - handle: Parameters[0], - ) => { - cleared.push(handle as IdleTimerHandle); - if (armed.includes(handle as IdleTimerHandle)) return; - return nativeClearInterval.call(globalThis, handle); - }) as typeof nativeClearInterval; - return { armed, cleared }; -} - -function soleLandingIdleHandle( - armed: readonly IdleTimerHandle[], -): IdleTimerHandle { - const handle = armed[0]; - if (armed.length !== 1 || handle === undefined) { - throw new Error( - `expected exactly one ${LANDING_IDLE_REPAINT_INTERVAL_MS}ms interval, got ${armed.length}`, - ); - } - return handle; -} - /** Newly added scroll-box children need a layout pass before they paint. */ async function settle(h: Harness): Promise { await h.renderOnce(); @@ -314,17 +263,12 @@ describe("landing screen", () => { ); }, 15_000); - describe("landing idle timer", () => { - afterEach(() => { - globalThis.setInterval = nativeSetInterval; - globalThis.clearInterval = nativeClearInterval; - }); - - test("reduced-motion mount never arms the idle timer and never draws snow", async () => { - const { armed } = wrapLandingIdleTimer(); + describe("landing idle motion", () => { + test("reduced-motion mount never arms snow and never draws it", async () => { await withAppShell( async (shell, h) => { - expect(armed).toHaveLength(0); + expect(shellInternals(shell)?.landingIdleTimerCancel).toBeNull(); + expect(shellInternals(shell)?.markSnow).toBeNull(); await settle(h); const first = markRows(h).join("\n"); expect(first.includes(SNOW_CHAR)).toBe(false); @@ -349,17 +293,20 @@ describe("landing screen", () => { ); }); - test("a deferred system notice does not clear the landing idle timer", async () => { - const { armed, cleared } = wrapLandingIdleTimer(); + test("a deferred system notice does not stop landing snow", async () => { await withAppShell( async (shell) => { - const handle = soleLandingIdleHandle(armed); + expect(shellInternals(shell)?.landingIdleTimerCancel).toBeTypeOf( + "function", + ); surfaceSystemNotice( shell, "mcp github did not connect (ECONNREFUSED) — its tools are unavailable; /mcp for detail", ); expect(isLanding(shell)).toBe(true); - expect(cleared).not.toContain(handle); + expect(shellInternals(shell)?.landingIdleTimerCancel).toBeTypeOf( + "function", + ); }, { shell: { @@ -369,14 +316,15 @@ describe("landing screen", () => { ); }); - test("appending a transcript row clears the landing idle timer", async () => { - const { armed, cleared } = wrapLandingIdleTimer(); + test("appending a transcript row stops landing snow", async () => { await withAppShell( async (shell) => { - const handle = soleLandingIdleHandle(armed); + expect(shellInternals(shell)?.landingIdleTimerCancel).toBeTypeOf( + "function", + ); appendStreamRow(shell, { role: "user", text: "first prompt" }); expect(isLanding(shell)).toBe(false); - expect(cleared).toContain(handle); + expect(shellInternals(shell)?.landingIdleTimerCancel).toBeNull(); }, { shell: { @@ -386,13 +334,14 @@ describe("landing screen", () => { ); }); - test("disposing the shell with no transcript clears the landing idle timer", async () => { - const { armed, cleared } = wrapLandingIdleTimer(); + test("disposing the shell with no transcript stops landing snow", async () => { await withAppShell( async (shell) => { - const handle = soleLandingIdleHandle(armed); + expect(shellInternals(shell)?.landingIdleTimerCancel).toBeTypeOf( + "function", + ); shell.dispose(); - expect(cleared).toContain(handle); + expect(shellInternals(shell)?.landingIdleTimerCancel).toBeNull(); }, { shell: { diff --git a/src/tui/mark-raster.ts b/src/tui/mark-raster.ts index 443d81676..fb917ab46 100644 --- a/src/tui/mark-raster.ts +++ b/src/tui/mark-raster.ts @@ -85,6 +85,11 @@ export interface MarkInput { readonly still: boolean; /** Suppresses snow regardless of `still`. Defaults to off. */ readonly reducedMotion?: boolean; + /** + * Draw flakes into the glyph grid. Idle landing snow is a post-process + * instead, so the text nodes stay still. Defaults to on. + */ + readonly snow?: boolean; /** Baked rasterization to composite; defaults to the compact grid. */ readonly grid?: MarkGrid; } @@ -118,6 +123,16 @@ function snowflakeAt( return Math.floor(wrapped) === row; } +/** Same flake field the mark raster uses, for the landing post-process. */ +export function isMarkSnowflake( + row: number, + col: number, + seconds: number, + rows: number, +): boolean { + return snowflakeAt(row, col, seconds, rows); +} + /** * Composite one frame. The silhouette is drawn solid: a fully covered cell * is `█`, a partly covered one is the eighth block matching its coverage, @@ -134,7 +149,7 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] { const fillLine = shape.rows * (1 - fillProg); // Not gated by `still`; fade-out drops the snow so it never outlasts // the mark it drifts over. - const snowOn = alpha === 1 && !input.reducedMotion; + const snowOn = alpha === 1 && !input.reducedMotion && input.snow !== false; const grid: MarkCell[][] = []; for (let row = 0; row < shape.rows; row++) { diff --git a/src/tui/mark-snow-effect.ts b/src/tui/mark-snow-effect.ts new file mode 100644 index 000000000..f4848b172 --- /dev/null +++ b/src/tui/mark-snow-effect.ts @@ -0,0 +1,65 @@ +/** + * Landing snow as a post-process. The mountain stays in the text nodes; + * flakes are drawn into the frame buffer so idle drift does not rebuild + * the mark on a timer. + */ +import { RGBA, type CliRenderer, type OptimizedBuffer } from "@opentui/core"; + +import type { LandingAbove } from "./landing.js"; +import { isMarkSnowflake, SNOW_CHAR } from "./mark-raster.js"; +import { UI } from "./theme.js"; + +export interface MarkSnowEffect { + /** Draw flakes only while the mountain is holding its filled frame. */ + setActive(active: boolean): void; + stop(): void; +} + +export function attachMarkSnow( + renderer: CliRenderer, + above: () => LandingAbove | null, +): MarkSnowEffect { + let active = false; + let seconds = 0; + const fg = RGBA.fromHex(UI.textFaint); + const bg = RGBA.fromHex(UI.ground); + const apply = (buffer: OptimizedBuffer, deltaTime: number): void => { + if (!active) return; + const mark = above(); + const grid = mark?.grid ?? null; + if (mark === null || grid === null) return; + if (deltaTime > 0) seconds += deltaTime / 1000; + const offset = mark.markRows.length - grid.rows; + for (let index = 0; index < mark.markRows.length; index++) { + const line = mark.markRows[index]; + if (line === undefined || !line.visible) continue; + const row = index - offset; + if (row < 0 || row >= grid.rows) continue; + const y = line.y; + const x = line.x; + if (y < 0 || y >= buffer.height || x >= buffer.width) continue; + const coverage = grid.coverage[row]; + for (let col = 0; col < grid.cols; col++) { + if ((coverage?.[col] ?? 0) !== 0) continue; + const cellX = x + col; + if (cellX < 0 || cellX >= buffer.width) continue; + if (!isMarkSnowflake(row, col, seconds, grid.rows)) continue; + buffer.setCell(cellX, y, SNOW_CHAR, fg, bg); + } + } + }; + renderer.addPostProcessFn(apply); + return { + setActive(next: boolean): void { + active = next; + }, + stop(): void { + active = false; + try { + renderer.removePostProcessFn(apply); + } catch { + // Renderer already destroyed. + } + }, + }; +} diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 980333d5e..f20aa6e61 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -6,6 +6,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, test } from "bun:test"; import { TextRenderable, type KeyEvent } from "@opentui/core"; import type { ConversationTurn } from "@intx/types/runtime"; +import { createShellOutputFeedMap } from "../session/shell-output-feed.js"; import { defined } from "../../testkit/defined.js"; import { createHarness, type Harness } from "./harness.js"; import { acceptOverlaySelection } from "./shell/overlay-host.js"; @@ -632,6 +633,45 @@ describe("mountProductHost", () => { } }); + test("a shell feed append paints the lane on the next frame, not a poll", async () => { + const feeds = createShellOutputFeedMap(); + const { host, emitter, renderOnce, captureCharFrame, destroyHarness } = + await mountHeadless({ + shellOutputFeed: (callId) => feeds.get(callId), + subscribeShellOutput: (listener) => feeds.subscribe(listener), + }); + try { + emitter.emit("event", { + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "sh-live", + arguments: { command: "make test" }, + }, + }); + const feed = feeds.forCall("sh-live"); + feed.append("compiling src/a.ts\n"); + feed.append("compiling src/b.ts\n"); + feed.append("compiling src/c.ts\n"); + feed.append("done\n"); + await renderOnce(); + const row = host.shell.streamLog[0]; + expect(row?.pending).toBe(true); + expect(row?.previewLines).toEqual([ + "compiling src/b.ts", + "compiling src/c.ts", + "done", + "⋯ +1 lines", + ]); + await renderOnce(); + expect(captureCharFrame()).toContain("compiling src/c.ts"); + expect(captureCharFrame()).toContain("⋯ +1 lines"); + } finally { + host.dispose(); + destroyHarness(); + } + }); + test("host teardown drains a marked outstanding operator gate and clears its strip", async () => { const { host, emitter, destroyHarness } = await mountHeadless(); try { diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 80e390f12..8bc50cd01 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -19,6 +19,8 @@ import { type TurnMonitorOptions, } from "./runtime-bridge.js"; import type { ShellOutputFeed } from "../session/shell-output-feed.js"; +import { agentLaneIsLive } from "../subagent/agent-progress.js"; +import { startFrameTimeline } from "./frame-timeline.js"; import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js"; import { wireGates, type NotifierPort } from "./gate-wire.js"; import { createSystemClipboard } from "./system-clipboard.js"; @@ -243,14 +245,15 @@ export interface ProductHostConfig { * unset falls back to the shell's demo fixture; production must supply * this to view real sessions. */ readonly onObserveRequest?: PaletteOnObserveRequest; - /** Live sub-agent sessions read on the chrome poll cadence to refresh - * outstanding `spawn_agent` rows (elapsed time, tool, stall state); - * omitted hosts paint bare pending rows. */ + /** Live sub-agent sessions. A Timeline refreshes outstanding `spawn_agent` + * rows while any lane is live; omitted hosts paint bare pending rows. */ readonly subAgentSessions?: () => readonly TaskProgressSession[]; - /** Per-call bounded shell-output feeds, polled on the sticky tick to - * paint a running command's live output tail onto its pending row. - * Omitted hosts paint pending rows without a tail. */ + /** Per-call bounded shell-output feeds. Appends repaint the pending row + * on the next frame. Omitted hosts paint pending rows without a tail. */ readonly shellOutputFeed?: (callId: string) => ShellOutputFeed | undefined; + /** Notified when any wired feed appends. Frame-coalesced with + * `shellOutputFeed`. */ + readonly subscribeShellOutput?: (listener: () => void) => () => void; /** Renderer factory override for headless mounting in tests; defaults to * the real `createCliRenderer`. */ readonly createRenderer?: () => Promise; @@ -444,6 +447,9 @@ export async function mountProductHost( // disposing the host; painting into freed buffers throws, so it stands // down. Sticky is tracked so a true→false edge still paints once — // otherwise the strip never clears when linger expires without a notify. + // Idle used to wake every 200ms (5 callbacks/s) even with nothing live. + // Clocks now run on a Timeline only while a lane or linger is on screen, + // and shell tails repaint from feed appends, one animation frame each. let stickyWasNeeded = chromeState !== null && agentsChromeNeedsSticky( @@ -451,50 +457,92 @@ export async function mountProductHost( Date.now(), agentsPanelLingerMs, ); - const stickyPoll = setInterval(() => { + let stopClock: (() => void) | null = null; + let shellFrame: number | null = null; + let unsubscribeShell: (() => void) | null = null; + + const sessionsLive = (): boolean => { + const sessions = config.subAgentSessions?.(); + if (sessions === undefined) return false; + return sessions.some((session) => agentLaneIsLive(session)); + }; + const stickyNeededNow = (): boolean => + chromeState !== null && + agentsChromeNeedsSticky( + chromeState.agents, + Date.now(), + agentsPanelLingerMs, + ); + const runClockTick = (): void => { if (disposed) return; try { - // The poll paints the strip, not the chrome; both edges go through - // the stickyWasNeeded latch below. - const stickyNeeded = - chromeState !== null && - agentsChromeNeedsSticky( - chromeState.agents, - Date.now(), - agentsPanelLingerMs, - ); + const stickyNeeded = stickyNeededNow(); if (stickyNeeded || stickyWasNeeded) { paintChrome(shell); } - // While the strip owns live clocks/linger, skip syncAgentProgress - // rewrites — anchors still arrive via events; only the sticky clock - // tick is frozen. if (config.subAgentSessions !== undefined && !stickyNeeded) { bridge.syncAgentProgress(config.subAgentSessions()); } - // Live shell tail: deduped in the bridge, so an unchanged snapshot is - // a no-op; this poll cadence (200 ms) is the paint cadence. - bridge.syncShellOutputs(config.shellOutputFeed); - // Elapsed clock, stall flip, and linger are wall-time, so repaint while - // sticky is needed; paintChromeZones re-enters setChromeZones, so gate - // on sticky, not every tick. The falling edge clears the zone when - // formatAgentsPanel returns null after linger. if (stickyNeeded || stickyWasNeeded) { paintChromeZones(); } else { publishProgramStatus(); } stickyWasNeeded = stickyNeeded; + if (!stickyNeeded && !sessionsLive()) { + stopClock?.(); + stopClock = null; + } } catch { - clearInterval(stickyPoll); + stopClock?.(); + stopClock = null; + } + }; + const armHostClock = (): void => { + if (disposed) return; + const keep = stickyNeededNow() || sessionsLive(); + if (!keep) { + if (stickyWasNeeded) runClockTick(); + stopClock?.(); + stopClock = null; + return; } - }, 200); - if (typeof stickyPoll.unref === "function") stickyPoll.unref(); + if (stopClock === null) { + stopClock = startFrameTimeline(renderer, () => { + runClockTick(); + }); + } + }; + const scheduleShellPaint = (): void => { + if (disposed || shellFrame !== null) return; + shellFrame = renderer.requestAnimationFrame(() => { + shellFrame = null; + if (disposed) return; + try { + bridge.syncShellOutputs(config.shellOutputFeed); + } catch { + // renderer already gone + } + }); + }; + if (config.subscribeShellOutput !== undefined) { + unsubscribeShell = config.subscribeShellOutput(() => { + scheduleShellPaint(); + }); + } + armHostClock(); function dispose(): void { if (disposed) return; disposed = true; - clearInterval(stickyPoll); + stopClock?.(); + stopClock = null; + unsubscribeShell?.(); + unsubscribeShell = null; + if (shellFrame !== null) { + renderer.cancelAnimationFrame(shellFrame); + shellFrame = null; + } config.eventEmitter.off("event", onEvent); disposeGates(); clearAllOperatorInputRequired(shell); @@ -917,6 +965,7 @@ export async function mountProductHost( setChrome: (state) => { chromeState = state ?? null; paintChromeZones(); + armHostClock(); }, setTitle: (title) => setHeader(shell, title), pushObserveRow: (row) => appendObserveStreamRow(shell, row), diff --git a/src/tui/provider/discovery.ts b/src/tui/provider/discovery.ts index 33013f278..3a4e83b38 100644 --- a/src/tui/provider/discovery.ts +++ b/src/tui/provider/discovery.ts @@ -13,7 +13,7 @@ import { providerListHeight, TYPE_MODEL_ID, } from "./choices.js"; -import { RAMP_TICK_MS, stopRamp } from "./surface.js"; +import { startRamp, stopRamp } from "./surface.js"; import type { DiscoveryFlows, SetupSelectors, @@ -52,7 +52,7 @@ export function createDiscoveryFlows( state.ollamaDiscoveryAbort = abort; state.ollamaDiscovery = "loading"; stopRamp(state); - state.rampTimer = setInterval(() => surface.paintStatus(), RAMP_TICK_MS); + startRamp(state, () => surface.paintStatus()); surface.paint(); state.discoverOllamaModels({ rootURL, signal: abort.signal }).then( (result) => { diff --git a/src/tui/provider/oauth.ts b/src/tui/provider/oauth.ts index c07bac226..3bd04e57f 100644 --- a/src/tui/provider/oauth.ts +++ b/src/tui/provider/oauth.ts @@ -10,7 +10,7 @@ import { resolveApiKeyInstanceName, } from "./choices.js"; import { OAUTH_PROFILE_HINT } from "./form.js"; -import { RAMP_TICK_MS, stopRamp } from "./surface.js"; +import { startRamp, stopRamp } from "./surface.js"; import type { AccountNameFlow, LoginFlow, @@ -218,7 +218,7 @@ export function createLoginFlow( failLogin(attempt, LOGIN_TIMEOUT_MESSAGE); }, state.loginTimeoutMs); stopRamp(state); - state.rampTimer = setInterval(() => surface.paintStatus(), RAMP_TICK_MS); + startRamp(state, () => surface.paintStatus()); surface.paint(); state diff --git a/src/tui/provider/setup.ts b/src/tui/provider/setup.ts index fe788593a..b97cf7ccc 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -63,7 +63,7 @@ import { } from "./oauth.js"; import { createSurface, - RAMP_TICK_MS, + startRamp, stopRamp, teardownSurface, } from "./surface.js"; @@ -318,7 +318,7 @@ export async function runProviderSetup( ); surface.paint(); stopRamp(state); - state.rampTimer = setInterval(() => surface.paintStatus(), RAMP_TICK_MS); + startRamp(state, () => surface.paintStatus()); // Track the phase locally so the rejection handler knows whether the // failure happened during the connection test (retryable and bypassable) diff --git a/src/tui/provider/surface.ts b/src/tui/provider/surface.ts index 24d1856af..d5ee0aa44 100644 --- a/src/tui/provider/surface.ts +++ b/src/tui/provider/surface.ts @@ -16,6 +16,7 @@ import { PRODUCT_NAME } from "../../branding.js"; import { ollamaDiscoveryFailureLine } from "../../provider/ollama.js"; import { TELEMETRY_NOTICE } from "../../telemetry/index.js"; import { wrapLines } from "../view/height.js"; +import { startFrameTimeline } from "../frame-timeline.js"; import { rampFor, rampLine } from "../ramp-glyphs.js"; import { destroySubtree } from "../teardown.js"; import { UI } from "../theme.js"; @@ -54,8 +55,20 @@ const TELEMETRY_ROWS = 3; * keys and JWT-shaped tokens clear that default. */ const FIELD_MAX_LENGTH = 16_384; -/** Ramp animation tick. Fast enough to read as motion at 30fps paint. */ -export const RAMP_TICK_MS = 120; + +/** Stop the shared ramp Timeline, if one is running. */ +export function stopRamp(state: SetupState): void { + state.rampTimer?.(); + state.rampTimer = null; +} + +/** Drive the status ramp from the renderer Timeline, not an interval. */ +export function startRamp(state: SetupState, paint: () => void): void { + stopRamp(state); + state.rampTimer = startFrameTimeline(state.renderer, () => { + paint(); + }); +} // "testing" covers the connection check; "saving" covers the settings write // that follows once it succeeds. @@ -64,13 +77,6 @@ const SUBMIT_PHASE_LABEL: Record = { saving: "writing settings", }; -/** Stop the shared ramp animation timer, if one is running. */ -export function stopRamp(state: SetupState): void { - if (state.rampTimer === null) return; - clearInterval(state.rampTimer); - state.rampTimer = null; -} - /** * Unmount the surface: stop in-flight flows, detach input handlers, destroy * the renderable tree. Only a renderer this mount created is destroyed — a diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts index e29431809..c40278bd5 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -167,7 +167,7 @@ export interface SetupState { submitPhase: SubmitPhase; submitError: string | null; saveAnywayOffered: boolean; - rampTimer: ReturnType | null; + rampTimer: (() => void) | null; readonly startLogin: OAuthLoginStarter; readonly listOAuthProfiles: OAuthProfileLister; readonly loginTimeoutMs: number; diff --git a/src/tui/render-loop.test.ts b/src/tui/render-loop.test.ts index cd19c36e4..131385fae 100644 --- a/src/tui/render-loop.test.ts +++ b/src/tui/render-loop.test.ts @@ -7,6 +7,7 @@ */ import { describe, expect, test } from "bun:test"; +import { ManualClock } from "@opentui/core/testing"; import { withTestRenderer } from "./harness"; import { RAMP_CYCLE_MS, rampPulse } from "./ramp-glyphs"; @@ -92,16 +93,14 @@ describe("monitor cadence", () => { bridge.handle({ type: "run", state: "busy" }); const tickMs = m.intervalMs(); expect(tickMs).not.toBeNull(); + // Stall and quota stay on the slow cadence. The ramp is sampled by + // Timeline frames (about a renderer frame), not by tightening this + // interval. 16ms steps of the same clock must still show every density. + expect(tickMs).toBeGreaterThanOrEqual(200); - // The comet crosses width + feather cells per cycle; a cadence that - // samples fewer times than that jumps cells instead of travelling. - expect(RAMP_CYCLE_MS / (tickMs ?? 1)).toBeGreaterThanOrEqual(14); - - // Every step the status slot's pulse has must actually get sampled; - // a cadence that skips steps turns the cycle into a stutter. const glyphs = new Set(); - for (let elapsed = 0; elapsed < RAMP_CYCLE_MS; elapsed += tickMs ?? 1) { - m.advance(tickMs ?? 1); + for (let elapsed = 0; elapsed < RAMP_CYCLE_MS; elapsed += 16) { + m.advance(16); glyphs.add( rampPulse({ phase: "working", @@ -116,6 +115,33 @@ describe("monitor cadence", () => { } }, WIDE); }); + + test("timeline frames advance the ramp when the stall schedule does not tick", async () => { + const clock = new ManualClock(); + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, SHELL_OPTS); + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => clock.now(), + tickMs: 250, + schedule: () => () => undefined, + }); + try { + bridge.handle({ type: "run", state: "busy" }); + await h.renderOnce(); + const first = shell.lockupNowMs; + clock.advance(RAMP_CYCLE_MS / 4); + await h.renderOnce(); + expect(shell.lockupNowMs - first).toBeGreaterThanOrEqual( + RAMP_CYCLE_MS / 4, + ); + } finally { + bridge.dispose(); + } + }, + { ...WIDE, clock }, + ); + }); }); describe("transcript append", () => { diff --git a/src/tui/row-update-perf.test.ts b/src/tui/row-update-perf.test.ts index f3200b96d..51180b903 100644 --- a/src/tui/row-update-perf.test.ts +++ b/src/tui/row-update-perf.test.ts @@ -171,23 +171,20 @@ describe("row update perf gates (J3)", () => { await h.renderOnce(); const baseline = work.replaces; const destroys = work.destroys; + // The frame timeline paints the opening clock; the stall tick is + // not required for the first "0:00". + expect(streamRowAt(shell, 0)?.stat).toBe("0:00"); - tick?.(); - await h.renderOnce(); - // The first tick changes the row (no stat yet -> "0:00"). - expect(work.replaces).toBe(baseline + 1); - expect(work.destroys).toBe(destroys); - - // Ticks within the same clock second leave the stat unchanged: - // zero updates across any number of them. + // Ticks within the same clock second leave the stat unchanged. for (let i = 0; i < 4; i++) tick?.(); await h.renderOnce(); - expect(work.replaces).toBe(baseline + 1); + expect(work.replaces).toBe(baseline); + expect(work.destroys).toBe(destroys); nowMs += 5_000; for (let i = 0; i < 3; i++) tick?.(); await h.renderOnce(); - expect(work.replaces).toBe(baseline + 2); + expect(work.replaces).toBe(baseline + 1); expect(work.destroys).toBe(destroys); expect(streamRowAt(shell, 0)?.stat).toBe("0:05"); } finally { diff --git a/src/tui/runner/host.ts b/src/tui/runner/host.ts index d11a129d4..a65571300 100644 --- a/src/tui/runner/host.ts +++ b/src/tui/runner/host.ts @@ -156,6 +156,8 @@ export interface RunnerHostDeps { readonly subAgentSessions: () => readonly SubAgentSession[]; /** Per-call bounded live shell-output feeds for the transcript tail. */ readonly shellOutputFeed?: (callId: string) => ShellOutputFeed | undefined; + /** Append listener for those feeds. The host coalesces repaints to a frame. */ + readonly subscribeShellOutput?: (listener: () => void) => () => void; /** * Live data behind the command surfaces (settings, permissions, plugins). * `notify` is supplied by the host itself. @@ -375,6 +377,9 @@ export async function mountRunnerHost( ...(deps.shellOutputFeed !== undefined ? { shellOutputFeed: deps.shellOutputFeed } : {}), + ...(deps.subscribeShellOutput !== undefined + ? { subscribeShellOutput: deps.subscribeShellOutput } + : {}), ...(deps.createRenderer !== undefined ? { createRenderer: deps.createRenderer } : {}), diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index b6a40ea2d..0025f52b9 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -232,6 +232,8 @@ export async function runTUI(initialConfig: Config): Promise { }, subAgentSessions: () => services.subAgentSessions.list(), shellOutputFeed: (callId) => services.toolset.shellOutputFeed.get(callId), + subscribeShellOutput: (listener) => + services.toolset.shellOutputFeed.subscribe(listener), surfaces: { permissions: settings.surfaces.permissions, plugins: settings.surfaces.plugins, diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 8aae8a066..7763ec328 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -39,6 +39,8 @@ import { import { applyShellInterrupt, surfaceSystemNotice } from "./shell/prompt.js"; import { streamRowAt, streamRowCount } from "./shell/transcript.js"; import { rampAnimating } from "./ramp-glyphs.js"; +import { startFrameTimeline } from "./frame-timeline.js"; +import type { CliRenderer } from "@opentui/core"; import { onTurnBoundary } from "../agent/reactor-events.js"; import { resolveRampPhase, @@ -183,9 +185,6 @@ export type SuspendedApprovalResume = ( const DEFAULT_TICK_MS = 250; -/** Poll period while animating; the idle cadence skips pulse steps. */ -const ANIMATION_TICK_MS = 80; - function defaultSchedule(tick: () => void, intervalMs: number): () => void { const handle = setInterval(tick, intervalMs); // The monitor must never keep the process alive. @@ -1504,13 +1503,31 @@ export function attachSessionBridge( bridges.set(shell, bag); const frozenTickMs = monitor?.tickMs ?? DEFAULT_TICK_MS; - const animationTickMs = Math.min(frozenTickMs, ANIMATION_TICK_MS); - /** Current cadence, or null while stopped. Every paint resolves it — one - * timer, never two. */ + /** Current cadence, or null while stopped. Stall and quota stay on this + * clock. Ramp glyphs and the reveal ride the renderer's Timeline instead + * of a faster interval. */ let cadenceMs: number | null = null; let stopTick: (() => void) | undefined; const schedule = monitor?.schedule ?? defaultSchedule; + let stopMotion: (() => void) | null = null; + const stopMotionTimeline = (): void => { + stopMotion?.(); + stopMotion = null; + }; + /** Frame-driven paint while the ramp or reveal still has frames. The + * stall watchdog keeps its own slower schedule. */ + const syncMotion = (animating: boolean): void => { + if (bag.disposed) return; + if (!animating) { + stopMotionTimeline(); + return; + } + if (stopMotion !== null) return; + stopMotion = startFrameTimeline(shell.renderer as CliRenderer, () => { + if (!bag.disposed) paintPhase(); + }); + }; const applyCadence = (next: number | null): void => { if (monitor === undefined || cadenceMs === next) return; @@ -1588,6 +1605,7 @@ export function attachSessionBridge( rampPhase: null, stalledForMs: null, }); + syncMotion(false); // Nothing animates or waits: stop the loop; the next event re-arms // it. applyCadence(bag.turn.quota !== null ? frozenTickMs : null); @@ -1602,11 +1620,10 @@ export function attachSessionBridge( rampPhase, stalledForMs: stalledFor, }); - // A frozen ramp (gate-blocked or past its blink burst) still needs the - // stall and quota clocks, not animation. - applyCadence( - rampAnimating(rampPhase, stalledFor) ? animationTickMs : frozenTickMs, - ); + // Glyphs and the reveal advance on Timeline frames while the ramp is + // moving. A frozen ramp keeps the stall schedule only. + syncMotion(rampAnimating(rampPhase, stalledFor)); + applyCadence(frozenTickMs); }; /** Read the clock once per frame so the pulse and cadence cannot straddle @@ -2347,6 +2364,7 @@ export function attachSessionBridge( bag.flushMailboxMail = null; bag.flushOccupancyThenWake = null; applyCadence(null); + stopMotionTimeline(); clearShellBridgeHooks(shell); bridges.delete(shell); }, diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index 7625f81bc..b8adc9c35 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -1457,14 +1457,11 @@ function clearLandingMark(shell: AppShell): void { } } -/** Idle repaint cadence (mount-scoped timer in `createAppShell`): snow - * needs about half a row per second, so 8fps reads as motion. */ -export const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; - /** Repaint the landing mark for `nowMs`: `animating` runs the draw/fill/fade * timeline, else it holds the filled frame. No-op once the landing is gone, - * so callers can always drive it. Idle still repaints (snow drifts); - * reduced motion freezes the mark and drops snow even under `animating`. */ + * so callers can always drive it. Idle snow is the post-process attached at + * mount; reduced motion freezes the mark and drops snow even under + * `animating`. */ export function paintLanding( shell: AppShell, nowMs: number, @@ -1476,7 +1473,16 @@ export function paintLanding( const motion = bag.reducedMotion ? false : animating; bag.landingAnimating = motion; bag.landingNowMs = nowMs; - paintLandingMark(landing.above, nowMs, !motion, bag.reducedMotion); + // Idle snow is a post-process so the text nodes stay on the filled frame. + // A live turn paints flakes into the glyphs and the effect stands down. + const postSnow = !motion && !bag.reducedMotion && bag.markSnow !== null; + bag.markSnow?.setActive(postSnow); + paintLandingMark( + landing.above, + nowMs, + !motion, + bag.reducedMotion || postSnow, + ); } /** Fill the prompt from a landing starter; false when the key selects diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index fce101da4..3d97cf5d1 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -22,6 +22,8 @@ import { splitLandingRows, versionBadgeVisible, } from "../landing.js"; +import { startFrameTimeline } from "../frame-timeline.js"; +import { attachMarkSnow } from "../mark-snow-effect.js"; import { destroySubtree } from "../teardown.js"; import { createRecordingClipboard } from "../copy-path.js"; import { copyFinishedSelection } from "../selection-copy.js"; @@ -60,7 +62,6 @@ import { import { applyFocus, applyLayout, - LANDING_IDLE_REPAINT_INTERVAL_MS, paintLanding, relayout, setStatusFlash, @@ -573,6 +574,7 @@ export function createAppShell( landingNowMs: 0, reducedMotion, landingIdleTimerCancel: null, + markSnow: null, chrome: { task: [], tasksRaw: [], agents: [] }, // The manage_tasks panel starts hidden — it owns too much of the screen // for a fresh shell; Alt+T (toggleTasksPanel) opts in for the shell's @@ -581,31 +583,44 @@ export function createAppShell( tasksPanelHidden: true, pendingSelId: null, }); - // The landing's snow needs a clock that runs while the turn monitor is - // deliberately quiet; a timer armed at mount provides it (FRAME follows - // dirty rows, not time). It repaints only while idle — a live turn drives - // the mountain off the turn monitor's clock, which this timer must not - // stomp — and is cleared on the first teardown (landing gone, shell - // disposed, or the renderer destroyed by a headless harness). Reduced - // motion never starts it: no snow to advance, the mountain stays filled. + // Idle snow is a post-process. A Timeline keeps the renderer live so + // frames exist to draw into; it does not rebuild the mark. A live turn + // paints the mountain itself and this effect stands down. Reduced motion + // never starts it. if (!reducedMotion) { - const landingIdleHandle = setInterval(() => { - if (renderer.isDestroyed) { - clearInterval(landingIdleHandle); - return; - } - const bag = shellInternals(shell); - if (bag?.landing == null || bag.landingAnimating) return; - paintLanding(shell, Date.now(), false); - }, LANDING_IDLE_REPAINT_INTERVAL_MS); - landingIdleHandle.unref?.(); - { + const liveRenderer = renderer as unknown as CliRenderer; + const snow = attachMarkSnow(liveRenderer, () => { const bag = shellInternals(shell); - if (bag !== undefined) { - bag.landingIdleTimerCancel = () => clearInterval(landingIdleHandle); - } else { - clearInterval(landingIdleHandle); + // The effect runs after the scene, so an open overlay must win. + if ( + bag === undefined || + shell.overlayList !== null || + bag.landingAnimating + ) { + return null; + } + return bag.landing?.above ?? null; + }); + const stopTimeline = startFrameTimeline(liveRenderer, () => { + // requestLive is the point; flakes advance in the post-process. + }); + const bag = shellInternals(shell); + const cancel = (): void => { + stopTimeline(); + snow.stop(); + const current = shellInternals(shell); + if (current === undefined) return; + current.markSnow = null; + if (current.landingIdleTimerCancel === cancel) { + current.landingIdleTimerCancel = null; } + }; + if (bag !== undefined) { + bag.markSnow = snow; + bag.landingIdleTimerCancel = cancel; + paintLanding(shell, Date.now(), false); + } else { + cancel(); } } setTranscriptSpacer(shell, transcriptSpacer); diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 53f1c644c..ae8c5a100 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -731,10 +731,16 @@ interface ShellInternals { landingAnimating: boolean; /** Clock of the last painted mark frame, so a resize can redraw in place. */ landingNowMs: number; - /** Mount-time reduced-motion flag: no idle snow timer; still mountain. + /** Mount-time reduced-motion flag: no idle snow timeline; still mountain. * Set once at `createAppShell`. */ reducedMotion: boolean; - /** Cancels the idle repaint timer armed in `createAppShell`; null while none + /** Post-process flakes for the idle landing. Null when reduced motion is + * on or the landing timeline has been stopped. */ + markSnow: { + setActive(active: boolean): void; + stop(): void; + } | null; + /** Cancels the idle Timeline armed in `createAppShell`; null while none * is armed. */ landingIdleTimerCancel: (() => void) | null; /** Chrome content (empty array = zone off). */ diff --git a/src/tui/thinking-reveal.test.ts b/src/tui/thinking-reveal.test.ts index 811487e2d..15313e8d6 100644 --- a/src/tui/thinking-reveal.test.ts +++ b/src/tui/thinking-reveal.test.ts @@ -4,6 +4,7 @@ */ import { describe, expect, test } from "bun:test"; +import { ManualClock } from "@opentui/core/testing"; import { advanceRevealChars, @@ -213,4 +214,40 @@ describe("the reveal position through the bridge", () => { { width: 80, height: 24 }, ); }); + + test("a timeline frame reveals at the bounded rate without the stall tick", async () => { + const clock = new ManualClock(); + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => clock.now(), + tickMs: 250, + schedule: () => () => undefined, + }); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.thinking.delta", + data: { token: "x".repeat(500) }, + }); + await h.renderOnce(); + clock.advance(1_000); + await h.renderOnce(); + const revealed = shell.streamLog.at(-1)?.revealChars ?? 0; + // 28 chars/sec. A second of frame time must move, and must not dump + // the burst. + expect(revealed).toBeGreaterThanOrEqual(28); + expect(revealed).toBeLessThan(40); + } finally { + bridge.dispose(); + } + }, + { width: 80, height: 24, clock }, + ); + }); }); diff --git a/src/tui/tool-rows.test.ts b/src/tui/tool-rows.test.ts index cab8a5f25..3e86f8cff 100644 --- a/src/tui/tool-rows.test.ts +++ b/src/tui/tool-rows.test.ts @@ -26,6 +26,7 @@ function liveFeed(snapshot: () => string): ShellOutputFeed { append: () => undefined, clear: () => undefined, snapshot, + subscribe: () => () => undefined, }; } diff --git a/src/tui/welcome.ts b/src/tui/welcome.ts index d28746d75..ecf7b8086 100644 --- a/src/tui/welcome.ts +++ b/src/tui/welcome.ts @@ -22,6 +22,7 @@ import { } from "@opentui/core"; import { PRODUCT_NAME } from "../branding.js"; +import { startFrameTimeline } from "./frame-timeline.js"; import { MARK_PERIOD_SECONDS, renderMark } from "./mark-raster.js"; import { MARK_LARGE, @@ -66,9 +67,6 @@ export function resolveWelcomeLine(columns: number): string { return stringWidth(WELCOME_LINE) > columns ? "" : WELCOME_LINE; } -/** Paint cadence while the mountain draws. */ -const PAINT_TICK_MS = 80; - export interface WelcomeConfig { /** Renderer factory override for headless mounting in tests. */ readonly createRenderer?: () => Promise; @@ -248,14 +246,12 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise { resolveDone = resolve; }); - let paintTimer: ReturnType | null = null; + let stopPaint: (() => void) | null = null; let advanceTimer: ReturnType | null = null; const teardown = (): void => { - if (paintTimer !== null) { - clearInterval(paintTimer); - paintTimer = null; - } + stopPaint?.(); + stopPaint = null; if (advanceTimer !== null) { clearTimeout(advanceTimer); advanceTimer = null; @@ -296,7 +292,9 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise { } renderer.keyInput.on("keypress", onKey); - paintTimer = setInterval(paint, PAINT_TICK_MS); + stopPaint = startFrameTimeline(renderer, () => { + paint(); + }); advanceTimer = setTimeout(() => settle(true), autoAdvanceMs); return done;