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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user