feat: added live tracking and stale status warnings for agent activity snapshots

- Added live tracking and stale status warnings for agent activity snapshots.
- Fixed text wrapping with ANSI escape sequences to defer style open sequences after whitespace.
- Added VirtualRenderScheduler for deterministic virtual-clock rendering tests.
This commit is contained in:
can1357
2026-08-16 10:18:56 +03:00
parent d4587e318e
commit 02cd22dc9b
29 changed files with 314 additions and 57 deletions
@@ -7,6 +7,7 @@ import {
type NativeScrollbackLiveRegion,
TUI,
} from "@oh-my-pi/pi-tui";
import { VirtualRenderScheduler } from "./virtual-render-scheduler";
import { VirtualTerminal } from "./virtual-terminal";
// Law-encoding suite for native-scrollback commits.
@@ -107,21 +108,18 @@ class CommittedRowsWireProbe extends CommittedRowsProbe {
}
}
/** One scheduler per file; `beforeEach` rewinds it so each test starts at t=0 with an empty queue. */
const scheduler = new VirtualRenderScheduler();
async function settle(term: VirtualTerminal): Promise<void> {
const nextTick = Promise.withResolvers<void>();
process.nextTick(nextTick.resolve);
await nextTick.promise;
await Bun.sleep(40);
await term.flush();
await scheduler.settle(term);
}
// The non-multiplexer resize fast path paints the viewport at once and defers
// the authoritative full replay (the ED3 scrollback rebuild) until the drag has
// been quiet for the resize settle window (120 ms). This is an integration test
// against the real render scheduler, so the window is driven with a real delay.
// been quiet for the resize settle window (120 ms). Open that window explicitly.
async function settleResize(term: VirtualTerminal): Promise<void> {
await Bun.sleep(160);
await settle(term);
await scheduler.advance(term, 160);
}
function capture(term: VirtualTerminal): string[] {
@@ -197,6 +195,7 @@ function restoreTerminalEnv(saved: Record<string, string | undefined>): void {
describe("streaming scrollback — visual record", () => {
let savedTerminalEnv: Record<string, string | undefined> = {};
beforeEach(() => {
scheduler.reset();
savedTerminalEnv = saveTerminalEnv();
});
afterEach(() => {
@@ -208,7 +207,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const sealed = new LineList(rows("prior-", 12));
const live = new SeamLineList([]);
@@ -254,7 +253,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const live = new SeamLineList([]);
try {
@@ -286,7 +285,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(60, 8, 1_000);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const wall = new PinnedSeamLineList([]);
try {
@@ -323,7 +322,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
// An append-only streaming reply declares every rendered row final
// (Infinity clamps to the rendered length): its scrolled-off head enters
// the verified zone and never needs a finalize-time repair.
@@ -357,7 +356,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(24, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const sealed = new LineList(rows("prior-", 12));
const live = new SeamLineList([]);
@@ -407,7 +406,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(24, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const sealed = new LineList(rows("prior-", 12));
const live = new SeamLineList([]);
// Status loader below the transcript: also reports a seam. Exactness is
@@ -458,7 +457,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(40, 10);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const component = new LineList([...rows("init-", 10), "prompt"]);
try {
@@ -501,7 +500,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(40, 10);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const component = new LineList([...rows("init-", 10), "prompt"]);
try {
@@ -536,7 +535,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const sealed = new LineList(rows("prior-", 12));
const live = new SeamLineList([]);
@@ -571,7 +570,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(24, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const sealed = new LineList(rows("base-", 12));
try {
@@ -608,7 +607,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(40, 10);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const component = new LineList([...rows("init-", 5), "prompt"]);
try {
@@ -649,7 +648,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const probe = new CommittedRowsProbe([]);
try {
@@ -677,7 +676,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(40, 8);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const probe = new CommittedRowsWireProbe([]);
try {
@@ -726,7 +725,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(40, 8);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
// Content taller than the viewport before the first paint: the initial
// frame takes the full-paint path, whose replay commits (frame - height)
// rows in one shot on a separate exit from the ordinary update emit.
@@ -753,7 +752,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(40, 8);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
// A short header above a tall overflowing body: the engine's committed
// boundary sails past the header's 2-row extent. Both feeds are in the
// child's own coordinates and must saturate at what the child actually
@@ -801,7 +800,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
// A block that rewrites an interior row every frame (a streaming table
// re-aligning, a collapsing preview). Its scrolled rows are frozen
// snapshots: drift never sprays re-anchors; the single strict scan at
@@ -857,7 +856,7 @@ describe("streaming scrollback — visual record", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
// The block declares its whole body final, commits, then violates the
// contract by rewriting TWO committed rows (alignment breaks, so the
// tail-sample tolerance cannot absorb it). The audit re-anchors, the
@@ -908,7 +907,7 @@ describe("scrollback commit gap — live barriers", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const root = new SeamLineList([]);
try {
@@ -946,7 +945,7 @@ describe("scrollback commit gap — live barriers", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const root = new SeamLineList([]);
try {
@@ -983,7 +982,7 @@ describe("scrollback commit gap — live barriers", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const root = new SeamLineList([]);
try {
@@ -1021,7 +1020,7 @@ describe("scrollback commit gap — live barriers", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 5);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const barrier = new SeamLineList(["[tool pending]"]);
const tail = new LineList(rows("out-", 10));
@@ -1058,7 +1057,7 @@ describe("scrollback commit gap — live barriers", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 5);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const root = new SeamLineList([]);
try {
@@ -1098,7 +1097,7 @@ describe("scrollback commit gap — live barriers", () => {
// — so finalize needs NO repair and the tape never duplicates a byte.
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const root = new SeamLineList([]);
try {
@@ -1137,7 +1136,7 @@ describe("scrollback commit gap — live barriers", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const root = new SeamLineList([]);
try {
@@ -1177,7 +1176,7 @@ describe("scrollback commit gap — live barriers", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const root = new SeamLineList([]);
try {
@@ -1228,7 +1227,7 @@ describe("scrollback divergence — multiplexer fallback", () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const root = new SeamLineList([]);
try {
@@ -0,0 +1,128 @@
import type { RenderScheduler, RenderTimer } from "@oh-my-pi/pi-tui/tui";
import type { VirtualTerminal } from "./virtual-terminal";
/**
* Longest delay a throttled frame can ask for: the 30 Hz cadence. Adaptive
* backpressure adds nothing under a virtual clock (a frame costs 0 virtual ms),
* and every deferred window the engine arms is longer — multiplexer resize
* debounce 50 ms, resize viewport settle 120 ms, ConPTY post-paint settle
* 150 ms — so this horizon separates "frames the pipeline owes us now" from
* "windows a test must open deliberately".
*/
const FRAME_HORIZON_MS = 40;
/** Bound on drain rounds; a render loop that keeps re-arming is a bug, not a wait. */
const MAX_ROUNDS = 500;
interface PendingTimer {
at: number;
run: () => void;
}
/**
* Deterministic {@link RenderScheduler} with a virtual clock.
*
* `TUI` reads every timestamp through `RenderScheduler.now()`, so owning the
* clock removes wall-clock races from render assertions: a loaded machine can
* neither lose a throttled frame (sleep too short) nor open a deferred settle
* window early (sleep too long).
*
* ```ts
* const scheduler = new VirtualRenderScheduler();
* const tui = new TUI(term, undefined, { renderScheduler: scheduler });
* tui.start();
* await scheduler.settle(term); // every frame the engine owes, nothing more
* await scheduler.advance(term, 160); // open the resize settle window
* ```
*/
export class VirtualRenderScheduler implements RenderScheduler {
#now = 0;
#nextTimerId = 0;
#immediates: (() => void)[] = [];
#timers = new Map<number, PendingTimer>();
now(): number {
return this.#now;
}
scheduleImmediate(callback: () => void): void {
this.#immediates.push(callback);
}
scheduleRender(callback: () => void, delayMs: number): RenderTimer {
const id = this.#nextTimerId;
this.#nextTimerId += 1;
this.#timers.set(id, { at: this.#now + Math.max(0, delayMs), run: callback });
return {
cancel: () => {
this.#timers.delete(id);
},
};
}
/** Drop every queued frame and rewind the clock, for reuse across tests. */
reset(): void {
this.#now = 0;
this.#immediates = [];
this.#timers.clear();
}
/**
* Run the frames the engine owes — immediates plus timers landing within
* `horizonMs` of the clock, including the ones those frames schedule — until
* the pipeline is quiescent. Deferred windows stay armed.
*/
async settle(term: VirtualTerminal, horizonMs = FRAME_HORIZON_MS): Promise<void> {
await this.#drain(term, () => this.#now + horizonMs);
}
/**
* Open a deferred window: advance the clock by `ms`, running every frame that
* comes due on the way, then settle the frames that follow it.
*/
async advance(term: VirtualTerminal, ms: number): Promise<void> {
const deadline = this.#now + ms;
await this.#drain(term, () => deadline);
this.#now = Math.max(this.#now, deadline);
await this.settle(term);
}
async #drain(term: VirtualTerminal, deadline: () => number): Promise<void> {
for (let round = 0; round < MAX_ROUNDS; round++) {
if (!this.#runImmediates() && !this.#runDue(deadline())) return;
// Terminal writes land synchronously; yielding lets promise
// continuations queue their renders before the next round.
await term.flush();
}
throw new Error(`VirtualRenderScheduler did not settle after ${MAX_ROUNDS} rounds`);
}
#runImmediates(): boolean {
if (this.#immediates.length === 0) return false;
const queued = this.#immediates;
this.#immediates = [];
for (const run of queued) run();
return true;
}
/** Fire the earliest timer batch due at or before `deadline`, advancing the clock to it. */
#runDue(deadline: number): boolean {
let earliest: number | undefined;
for (const timer of this.#timers.values()) {
if (timer.at <= deadline && (earliest === undefined || timer.at < earliest)) earliest = timer.at;
}
if (earliest === undefined) return false;
this.#now = Math.max(this.#now, earliest);
const due: number[] = [];
for (const [id, timer] of this.#timers) {
if (timer.at === earliest) due.push(id);
}
for (const id of due) {
const timer = this.#timers.get(id);
if (!timer) continue;
this.#timers.delete(id);
timer.run();
}
return true;
}
}