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
2 changes: 2 additions & 0 deletions src/plugins/shell-guard-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions src/session/shell-output-feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
});
});
30 changes: 29 additions & 1 deletion src/session/shell-output-feed.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -11,13 +11,17 @@ 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. */
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 {
Expand All @@ -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();
Expand All @@ -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<string, ShellOutputFeed>();
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;
Expand All @@ -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);
};
},
};
}
44 changes: 44 additions & 0 deletions src/tui/frame-timeline.ts
Original file line number Diff line number Diff line change
@@ -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.
}
};
}
97 changes: 23 additions & 74 deletions src/tui/landing.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/** 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";
import { makePermissionItems, type Harness } from "./harness";
import {
appendStreamRow,
applyLandingSuggestion,
LANDING_IDLE_REPAINT_INTERVAL_MS,
noticeText,
paintChrome,
setChromeZones,
Expand All @@ -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,
Expand Down Expand Up @@ -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<typeof nativeSetInterval>[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<typeof nativeClearInterval>[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<void> {
await h.renderOnce();
Expand Down Expand Up @@ -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);
Expand All @@ -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: {
Expand All @@ -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: {
Expand All @@ -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: {
Expand Down
17 changes: 16 additions & 1 deletion src/tui/mark-raster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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++) {
Expand Down
Loading
Loading