diff --git a/docs/TUI.md b/docs/TUI.md index a42ce17c1..bf88d14e8 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -36,6 +36,15 @@ goal/task/agents strips, then progress, then the prompt itself shrinks one row at a time down to its 3-row base — never the transcript (`COLLAPSE_ORDER` in `zones.ts`). +Horizontally, every surface sits inside one shared gutter +(`resolveSideMargin`, `src/tui-opentui/geometry/margins.ts`) so the shell reads +as a single column of content rather than stacked panes. The gutter is one +column per side at every width that can afford it, and zero below +`MARGIN_MIN_COLUMNS` (40), where every column belongs to content. There is no +middle tier: one column is already enough to keep content off the frame edge, +which is the gutter's entire job, and anything wider only read as excess air on +a wide pane. The gutter costs no rows. + The prompt box's border carries the metadata that would otherwise cost a titlebar row: the model label sits right-aligned in the top rule; the brand lockup sits at the left of the bottom rule with the working directory and git @@ -43,6 +52,42 @@ branch at its right (`AppShell.promptTopRule` / `promptBottomRule`, `src/tui-opentui/shell.ts`). Both rules cost zero transcript rows because they ride the prompt box's own border. +While a turn is live the lockup slot swaps the wordmark for the phase word — +`thinking`, `streaming 12 tok`, the running tool's name — led by a single +density cell (`rampPulse`, `src/tui-opentui/ramp.ts`). The cell, not the word, +is what says whether the session is healthy, and it carries four states: + +| State | Cell | Reads as | +|---|---|---| +| `working` | cycles `░ ▒ ▓ █` on `RAMP_CYCLE_MS` | moving | +| `done` | static `█` | finished | +| `blocked` | static `▌` | waiting on the operator | +| `stalled` | `!` blinking against `█`, then a static `!` | a problem | + +Every state is separated by glyph and motion before colour, so all four survive +a monochrome terminal and are readable without stopping to read the word. A +static `working` word was the original failure: a live run and a hung one +printed identically, so the only way to tell them apart was to wait. + +`blocked` and `stalled` share the orange deliberately — both name a turn +waiting on something outside itself — and are told apart by motion: `blocked` +holds perfectly still, which is the signal that the session is waiting on *you*. + +The stall phase is driven by the watchdog's own silence clock +(`stallLevel`, `src/tui-opentui/stall-watchdog.ts`), so the indicator and the +abort can never disagree about which runs are stuck. It arms at +`STALL_NOTICE_MS` and keeps reading as stalled straight through the abort +threshold. Its blink is a bounded burst (`STALL_BLINK_BURST_MS`) that settles +to a static `!`: an alarm that strobes for the whole stall window becomes +wallpaper, and settling also lets the render loop drop back to the slow +cadence. The burst is measured from the moment silence crossed the notice +threshold, so a resumed session with already-stale activity shows the settled +glyph immediately rather than alarming about silence the operator missed, and +a stall that breaks and re-arms bursts again. + +An idle session animates nothing at all: the monitor tick stops entirely +rather than repainting an unchanging frame. + Color is a small, deliberate palette, not decoration (`src/tui-opentui/theme.ts`). Dimmed text is a dimmed cream, never a neutral gray, so every emphasis level keeps the same warm hue. Orange diff --git a/src/tui-opentui/geometry/index.ts b/src/tui-opentui/geometry/index.ts index 4e3c7eb25..c0d13b592 100644 --- a/src/tui-opentui/geometry/index.ts +++ b/src/tui-opentui/geometry/index.ts @@ -21,9 +21,7 @@ export { export { BOTTOM_MARGIN_MIN_ROWS, BOTTOM_MARGIN_ROWS, - MARGIN_FULL_MIN_COLUMNS, MARGIN_MIN_COLUMNS, - NARROW_SIDE_MARGIN, SIDE_MARGIN, TOP_PAD_MIN_TRANSCRIPT_ROWS, TOP_PAD_ROWS, diff --git a/src/tui-opentui/geometry/margins.ts b/src/tui-opentui/geometry/margins.ts index 7acc0afd1..0090a571d 100644 --- a/src/tui-opentui/geometry/margins.ts +++ b/src/tui-opentui/geometry/margins.ts @@ -9,14 +9,16 @@ * here can take a row away from it. */ -/** Gutter columns on each side once the terminal can afford them. */ -export const SIDE_MARGIN = 2 - -/** Half gutter for terminals too narrow to spend four columns on air. */ -export const NARROW_SIDE_MARGIN = 1 - -/** At or above this width the full gutter is affordable. */ -export const MARGIN_FULL_MIN_COLUMNS = 60 +/** + * Gutter columns on each side once the terminal can afford them. + * + * One column at every width the gutter exists at all. A single column is + * already enough to keep content off the frame edge, which is the whole job, + * and a wider gutter only read as excess air on a wide pane. There is no + * middle tier: a width that can spare a column gets one, and a width that + * cannot gets none. + */ +export const SIDE_MARGIN = 1 /** Below this width every column belongs to content: the gutter goes to zero. */ export const MARGIN_MIN_COLUMNS = 40 @@ -24,9 +26,7 @@ export const MARGIN_MIN_COLUMNS = 40 /** Gutter width for a terminal of `columns` columns. */ export function resolveSideMargin(columns: number): number { const cols = Math.max(0, Math.floor(columns)) - if (cols >= MARGIN_FULL_MIN_COLUMNS) return SIDE_MARGIN - if (cols >= MARGIN_MIN_COLUMNS) return NARROW_SIDE_MARGIN - return 0 + return cols >= MARGIN_MIN_COLUMNS ? SIDE_MARGIN : 0 } /** Columns left for content after both gutters. */ diff --git a/src/tui-opentui/landing.test.ts b/src/tui-opentui/landing.test.ts index f714b3899..6f99cf33a 100644 --- a/src/tui-opentui/landing.test.ts +++ b/src/tui-opentui/landing.test.ts @@ -305,7 +305,7 @@ describe("landing screen", () => { expect(ruleRow).toBe(SIZE.height - 1) const row = painted[ruleRow]! // Left end of the rule, inside the shell gutter, costing no row. - expect(row.startsWith(" ╰─ ")).toBe(true) + expect(row.startsWith(" ╰─ ")).toBe(true) expect(row.trimEnd().endsWith("╯")).toBe(true) } finally { shell.dispose() diff --git a/src/tui-opentui/lockup.test.ts b/src/tui-opentui/lockup.test.ts index 594da3009..8a4881d3a 100644 --- a/src/tui-opentui/lockup.test.ts +++ b/src/tui-opentui/lockup.test.ts @@ -6,15 +6,40 @@ import { lockupCells, lockupText, lockupWidth, + type LockupInput, } from "./lockup" +import { STALL_BLINK_BURST_MS, STALL_BLINK_CYCLE_MS, type RampPhase } from "./ramp" import { UI } from "./theme" -const still = (nowMs = 0) => lockupCells({ nowMs, still: true }) +const idle = (nowMs: number): LockupInput => ({ + nowMs, + still: true, + phase: null, + changedMs: 0, + rampPhase: null, + stalledForMs: null, +}) + +const live = ( + nowMs: number, + phase: string, + rampPhase: RampPhase, + stalledForMs: number | null, +): LockupInput => ({ + nowMs, + still: false, + phase, + changedMs: 0, + rampPhase, + stalledForMs, +}) + +const still = (nowMs = 0) => lockupCells(idle(nowMs)) describe("brand lockup", () => { test("idle is the wordmark alone", () => { const cells = still() - expect(cells).toHaveLength(lockupWidth(null)) + expect(cells).toHaveLength(lockupWidth(idle(0))) expect(lockupText(cells)).toBe(LOCKUP_WORDMARK) // The mountain lives on the landing; one row cannot hold a silhouette. expect(lockupText(cells)).not.toMatch(/[▁▂▃▄▅▆▇█]/) @@ -28,9 +53,16 @@ describe("brand lockup", () => { }) test("a live turn swaps the wordmark for the phase", () => { - const cells = lockupCells({ nowMs: 0, still: false, phase: "thinking" }) - expect(lockupText(cells)).toBe("thinking") - expect(lockupWidth("thinking")).toBe(cells.length) + const input: LockupInput = { + nowMs: 0, + still: false, + phase: "thinking", + changedMs: 0, + rampPhase: null, + stalledForMs: null, + } + expect(lockupText(lockupCells(input))).toBe("thinking") + expect(lockupWidth(input)).toBe(lockupCells(input).length) }) test("the wordmark stays chrome-dim", () => { @@ -46,6 +78,8 @@ describe("brand lockup", () => { still: false, phase: "bash", changedMs: 0, + rampPhase: null, + stalledForMs: null, }) const tone = (elapsed: number) => at(elapsed)[0]?.fg expect(tone(0)).toBe(UI.textFaint) @@ -59,3 +93,108 @@ describe("brand lockup", () => { expect(lockupText(at(0))).toBe(lockupText(at(LOCKUP_FADE_MS))) }) }) + +describe("the live phase slot's pulse cell", () => { + test("working keeps the word and leads it with a density cell", () => { + const cells = lockupCells(live(0, "streaming 3 tok", "working", null)) + expect(lockupText(cells)).toMatch(/^[░▒▓█] streaming 3 tok$/) + for (const cell of cells) expect(cell.fg).toBe(UI.inFlight) + }) + + test("working's cell moves — the slot's glyphs change over a cycle", () => { + const seen = new Set( + [0, 300, 600, 900].map((nowMs) => + lockupText(lockupCells(live(nowMs, "working", "working", null))), + ), + ) + expect(seen.size).toBeGreaterThan(1) + }) + + test("blocked holds one static cell — stillness is the signal", () => { + const at = (nowMs: number) => + lockupText(lockupCells(live(nowMs, "blocked", "blocked", null))) + expect(at(0)).toBe("▌ blocked") + expect(at(STALL_BLINK_CYCLE_MS)).toBe(at(0)) + expect(at(60_000)).toBe(at(0)) + }) + + test("working and blocked differ in glyph, not only in colour", () => { + // Same word, same instant, colour stripped: the cell is the only thing + // that can tell them apart, and it must. + const distinct = new Set( + [0, 300, 600, 900].map( + (nowMs) => + `${lockupText(lockupCells(live(nowMs, "working", "working", null)))}|${lockupText( + lockupCells(live(nowMs, "working", "blocked", null)), + )}`, + ), + ) + for (const pair of distinct) { + const [moving, waiting] = pair.split("|") + expect(moving).not.toBe(waiting) + } + }) + + test("stalled blinks a bang against a block while the burst runs", () => { + const on = lockupText(lockupCells(live(0, "working", "stalled", 0))) + const off = lockupText( + lockupCells(live(STALL_BLINK_CYCLE_MS / 2, "working", "stalled", 0)), + ) + expect(on).toBe("█ working") + expect(off).toBe("! working") + }) + + test("stalled settles to a static bang once the burst has spent itself", () => { + const past = STALL_BLINK_BURST_MS + const at = (nowMs: number) => + lockupText(lockupCells(live(nowMs, "working", "stalled", past + nowMs))) + expect(at(0)).toBe("! working") + expect(at(STALL_BLINK_CYCLE_MS / 2)).toBe("! working") + expect(at(120_000)).toBe("! working") + }) + + test("a stall already older than the burst never blinks at all", () => { + // A resumed session inherits stale activity; bursting at it would alarm + // the operator about silence they were not present for. + const resumed = STALL_BLINK_BURST_MS * 4 + for (const nowMs of [0, 225, 450, 675]) { + expect( + lockupText(lockupCells(live(nowMs, "working", "stalled", resumed))), + ).toBe("! working") + } + }) + + test("the stalled word stays legible — only the cell blinks", () => { + for (const nowMs of [0, STALL_BLINK_CYCLE_MS / 2]) { + expect( + lockupText(lockupCells(live(nowMs, "bash", "stalled", 0))), + ).toContain("bash") + } + }) + + test("working, blocked and stalled all read apart with no colour at all", () => { + const glyph = (rampPhase: RampPhase, stalledForMs: number | null) => + lockupText(lockupCells(live(0, "working", rampPhase, stalledForMs)))[0] + expect(new Set([glyph("blocked", null), glyph("stalled", 0)]).size).toBe(2) + // Working sweeps the density glyphs; neither of the other two is one. + const workingGlyphs = new Set( + [0, 300, 600, 900].map( + (nowMs) => + lockupText(lockupCells(live(nowMs, "working", "working", null)))[0], + ), + ) + expect(workingGlyphs.has(glyph("blocked", null))).toBe(false) + }) + + test("the slot's width never changes across a blink", () => { + // A wide (CJK) and an astral label: the reservation is measured in columns + // and the blink must not move it, whatever the label is made of. + for (const label of ["読み込み中", "a😀b", "working"]) { + const on = lockupWidth(live(0, label, "stalled", 0)) + const off = lockupWidth( + live(STALL_BLINK_CYCLE_MS / 2, label, "stalled", 0), + ) + expect(off).toBe(on) + } + }) +}) diff --git a/src/tui-opentui/lockup.ts b/src/tui-opentui/lockup.ts index a8499336b..80d836976 100644 --- a/src/tui-opentui/lockup.ts +++ b/src/tui-opentui/lockup.ts @@ -10,19 +10,32 @@ * information, the mark is not. * * Idle it reads `corbits code`; while a turn runs it reads the live phase — - * `thinking`, `responding`, the running tool's name. The motion is the slot - * changing what it *says*, crossfading through the warm dim tones. + * `thinking`, `responding`, the running tool's name — led by a single density + * cell (`rampPulse` in `ramp.ts`) that carries the state the word cannot. * - * There is no glyph. Earlier versions carried the mountain here, first as a - * wide ridgeline and then reduced to three cells; one row has too little - * vertical range for a silhouette, so the wide form read as a lump and the - * short form as an anonymous tall-between-two-short. The mark gets its full - * expression on the landing, where it has the rows to earn it. + * The word alone was the original failure: a live run and a hung one printed + * the same static `working`, so the only way to tell them apart was to wait and + * see whether anything ever changed. The cell fixes that in one column, which + * is all the border row can spare. It cycles through the density glyphs while + * the turn moves, holds one static half block while the turn is blocked on an + * operator gate, and blinks a bang while the run has gone stalled-silent. Every + * distinction is a glyph or a motion before it is a color, so the three states + * separate on a monochrome terminal and at a glance, without reading the word. + * + * The cell and the word share `rampFor`'s phase and color rather than + * re-deriving them, so this slot can never disagree with the phase itself. + * + * There is no glyph beyond that cell. Earlier versions carried the mountain + * here, first as a wide ridgeline and then reduced to three cells; one row has + * too little vertical range for a silhouette, so the wide form read as a lump + * and the short form as an anonymous tall-between-two-short. The mark gets its + * full expression on the landing, where it has the rows to earn it. * * Pure and clock-injected: `nowMs` in, cells out, no timer. */ import { type MarkCell } from "./mark-anim.js" +import { rampFg, rampPulse, type RampPhase, type StallAge } from "./ramp.js" import { UI } from "./theme.js" import { stringWidth } from "../tui/view/height.js" @@ -43,32 +56,57 @@ export type LockupInput = { /** Hold the settled frame: idle session, or reduced motion. */ readonly still: boolean /** Live phase word, or null when the session is idle. */ - readonly phase?: string | null + readonly phase: string | null /** Clock reading when the slot's text last changed. */ - readonly changedMs?: number + readonly changedMs: number + /** The turn's ramp phase, or null when the session is idle. */ + readonly rampPhase: RampPhase | null + /** How long the turn has been stalled, or null when it is not stalled. */ + readonly stalledForMs: StallAge } /** What the slot says: the phase while a turn runs, the wordmark otherwise. */ -export function lockupLabel(phase: string | null | undefined): string { +export function lockupLabel(phase: string | null): string { const live = phase?.trim() ?? "" return live.length > 0 ? live : LOCKUP_WORDMARK } -/** Columns the slot paints for a given state. */ -export function lockupWidth(phase: string | null | undefined): number { - return stringWidth(lockupLabel(phase)) +/** + * Columns the slot paints. Measured off the cells it will actually draw rather + * than off the label, so the reservation cannot drift from the paint when the + * pulse is present or the label is wide (CJK) or astral. + */ +export function lockupWidth(input: LockupInput): number { + return stringWidth(lockupText(lockupCells(input))) } /** * The slot as coloured cells, left to right. `still` is the settled state: the * idle wordmark at its resting tones, with nothing left to animate. + * + * A live turn is led by the phase's single density cell and tinted by the + * phase's colour; the cell is what makes the state readable without colour. + * The idle wordmark keeps the neutral crossfade — nothing is running, so there + * is no phase to draw from. */ export function lockupCells(input: LockupInput): readonly MarkCell[] { const live = (input.phase?.trim().length ?? 0) > 0 + const label = lockupLabel(input.phase) + + if (live && input.rampPhase !== null) { + const fg = rampFg(input.rampPhase) + const pulse = rampPulse({ + phase: input.rampPhase, + nowMs: input.nowMs, + stalledForMs: input.stalledForMs, + }) + return [...`${pulse} ${label}`].map((char) => ({ char, fg })) + } + const progress = fadeProgress(input) const cells: MarkCell[] = [] const textTone = toneAt(live ? PHASE_FADE : WORDMARK_FADE, progress) - for (const char of lockupLabel(input.phase)) { + for (const char of label) { cells.push({ char, fg: textTone }) } return cells @@ -80,7 +118,7 @@ export function lockupCells(input: LockupInput): readonly MarkCell[] { * the remaining frames has already stopped by then. */ function fadeProgress(input: LockupInput): number { - if (input.still || input.changedMs === undefined) return 1 + if (input.still) return 1 const elapsed = input.nowMs - input.changedMs if (!Number.isFinite(elapsed) || elapsed >= LOCKUP_FADE_MS) return 1 return elapsed <= 0 ? 0 : elapsed / LOCKUP_FADE_MS diff --git a/src/tui-opentui/margins.test.ts b/src/tui-opentui/margins.test.ts index 0e3aae9b2..9b909c9d7 100644 --- a/src/tui-opentui/margins.test.ts +++ b/src/tui-opentui/margins.test.ts @@ -6,9 +6,7 @@ import { describe, expect, test } from "bun:test" import { BOTTOM_MARGIN_MIN_ROWS, - MARGIN_FULL_MIN_COLUMNS, MARGIN_MIN_COLUMNS, - NARROW_SIDE_MARGIN, SIDE_MARGIN, resolveBottomMarginRows, resolveContentWidth, @@ -29,15 +27,22 @@ function frameRows(h: Harness): readonly string[] { } describe("side margin resolution", () => { - test("steps down with width and floors at zero", () => { - expect(resolveSideMargin(120)).toBe(SIDE_MARGIN) - expect(resolveSideMargin(MARGIN_FULL_MIN_COLUMNS)).toBe(SIDE_MARGIN) - expect(resolveSideMargin(MARGIN_FULL_MIN_COLUMNS - 1)).toBe( - NARROW_SIDE_MARGIN, - ) - expect(resolveSideMargin(MARGIN_MIN_COLUMNS)).toBe(NARROW_SIDE_MARGIN) + test("one column at every affordable width, and zero below the floor", () => { + expect(SIDE_MARGIN).toBe(1) + for (const columns of [MARGIN_MIN_COLUMNS, 60, 80, 120, 200]) { + expect(resolveSideMargin(columns)).toBe(1) + } expect(resolveSideMargin(MARGIN_MIN_COLUMNS - 1)).toBe(0) expect(resolveSideMargin(10)).toBe(0) + expect(resolveSideMargin(0)).toBe(0) + }) + + test("content never touches the first or last column of the frame", () => { + // The gutter's whole job. A one-column gutter is the narrowest thing that + // can do it, so this is what would break if it were ever spent. + for (const columns of [80, 120, 200]) { + expect(resolveContentWidth(columns)).toBe(columns - 2) + } }) test("content width never collapses below one column", () => { diff --git a/src/tui-opentui/ramp-paint.test.ts b/src/tui-opentui/ramp-paint.test.ts index 4bfbc3e24..d29ab7240 100644 --- a/src/tui-opentui/ramp-paint.test.ts +++ b/src/tui-opentui/ramp-paint.test.ts @@ -1,12 +1,19 @@ /** - * The density ramp is the activity primitive: a running turn must paint block - * glyphs, and no braille spinner may survive anywhere in the frame. + * The density ramp is the activity primitive. Every assertion here reads the + * rendered frame and is pinned to the prompt box's bottom border — the one row + * the status slot rides. Shell fields are not evidence: the bug this indicator + * exists to fix was a slot whose internal state was perfectly correct and whose + * painted row never changed. */ import { describe, expect, test } from "bun:test" import { withTestRenderer } from "./harness" -import { RAMP_CYCLE_MS } from "./ramp" +import { + RAMP_CYCLE_MS, + STALL_BLINK_BURST_MS, + STALL_BLINK_CYCLE_MS, +} from "./ramp" import { attachSessionBridge, createRecordingPort } from "./runtime-bridge" import { createAppShell } from "./shell" import { UI } from "./theme" @@ -15,11 +22,16 @@ const BRAILLE = /[⠀-⣿]/ const DENSITY = /[░▒▓█]/ /** Drives the bridge monitor tick by hand so the ramp animates deterministically. */ -function fakeMonitor(): { +function fakeMonitor(stall?: { + readonly noticeMs: number + readonly timeoutMs: number +}): { readonly monitor: { now: () => number tickMs: number schedule: (tick: () => void, intervalMs: number) => () => void + stallNoticeMs?: number + stallTimeoutMs?: number } advance: (ms: number) => void } { @@ -35,6 +47,9 @@ function fakeMonitor(): { ticker = null } }, + ...(stall === undefined + ? {} + : { stallNoticeMs: stall.noticeMs, stallTimeoutMs: stall.timeoutMs }), }, advance: (ms) => { clock += ms @@ -43,8 +58,26 @@ function fakeMonitor(): { } } +/** The prompt box's bottom border — the row the status slot rides. */ +function statusRow(frame: string): string { + const row = frame.split("\n").find((line) => line.includes("╰")) + if (row === undefined) throw new Error("no prompt-box bottom border in frame") + return row +} + +/** + * The status slot's single state cell: the first glyph after the border's + * opening corner and rule. Pinning to it is what keeps these assertions honest + * — a bang or a block elsewhere in the frame must not satisfy them. + */ +function slotGlyph(frame: string): string { + const match = /╰─ (\S)/.exec(statusRow(frame)) + if (match?.[1] === undefined) throw new Error("no status slot in border row") + return match[1] +} + describe("turn ramp paint", () => { - test("a running turn names its phase, and never a braille spinner", async () => { + test("a running turn names its phase in the border, never a braille spinner", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -58,14 +91,9 @@ describe("turn ramp paint", () => { bridge.handle({ type: "run", state: "busy" }) await h.renderOnce() - // The border's bottom-left slot carries the running state as a - // word. The ramp that used to sit beside it was a second animation - // saying the same thing, and the context reading is a plain percent. - expect(shell.turnPhase).not.toBeNull() - expect(shell.turnPhase).toContain("working") - const frame = h.captureCharFrame() - expect(frame).toContain("working") + expect(statusRow(frame)).toContain("working") + expect(slotGlyph(frame)).toMatch(DENSITY) expect(frame).not.toMatch(BRAILLE) } finally { bridge.dispose() @@ -75,7 +103,7 @@ describe("turn ramp paint", () => { ) }) - test("the ramp animates off the monitor tick, with no timer of its own", async () => { + test("the working slot moves off the monitor tick, with no timer of its own", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -87,10 +115,14 @@ describe("turn ramp paint", () => { const bridge = attachSessionBridge(shell, createRecordingPort(), monitor) try { bridge.handle({ type: "run", state: "busy" }) - const first = shell.turnPhase - advance(RAMP_CYCLE_MS / 2) - expect(shell.turnPhase).not.toBe(first) - expect(shell.turnPhase).toMatch(DENSITY) + await h.renderOnce() + const first = statusRow(h.captureCharFrame()) + advance(RAMP_CYCLE_MS / 4) + await h.renderOnce() + const second = statusRow(h.captureCharFrame()) + // The whole point of the indicator: a live run does not look hung. + expect(second).not.toBe(first) + expect(slotGlyph(second)).toMatch(DENSITY) } finally { bridge.dispose() } @@ -99,7 +131,7 @@ describe("turn ramp paint", () => { ) }) - test("an idle turn clears the ramp entirely", async () => { + test("an idle turn clears the slot back to the wordmark", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -112,7 +144,131 @@ describe("turn ramp paint", () => { try { bridge.handle({ type: "run", state: "busy" }) bridge.handle({ type: "run", state: "idle" }) - expect(shell.turnPhase).toBeNull() + await h.renderOnce() + const row = statusRow(h.captureCharFrame()) + expect(row).not.toContain("working") + expect(row).not.toMatch(DENSITY) + } finally { + bridge.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("idle costs zero animation frames — the tick does not re-arm", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }) + let scheduleCalls = 0 + const { monitor, advance } = fakeMonitor() + const countingMonitor = { + ...monitor, + schedule: (tick: () => void, ms: number) => { + scheduleCalls++ + return monitor.schedule(tick, ms) + }, + } + const bridge = attachSessionBridge( + shell, + createRecordingPort(), + countingMonitor, + ) + try { + bridge.handle({ type: "run", state: "busy" }) + bridge.handle({ type: "run", state: "idle" }) + const callsAtIdle = scheduleCalls + advance(10_000) + expect(scheduleCalls).toBe(callsAtIdle) + } finally { + bridge.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("working and blocked read apart in the border with no colour at all", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }) + const { monitor, advance } = fakeMonitor() + const bridge = attachSessionBridge(shell, createRecordingPort(), monitor) + try { + bridge.handle({ type: "run", state: "busy" }) + await h.renderOnce() + const workingGlyphs = new Set() + for (let i = 0; i < 4; i++) { + workingGlyphs.add(slotGlyph(h.captureCharFrame())) + advance(RAMP_CYCLE_MS / 4) + await h.renderOnce() + } + // Moving: the cell is not the same glyph frame to frame. + expect(workingGlyphs.size).toBeGreaterThan(1) + + bridge.gateOpened() + await h.renderOnce() + const blockedGlyph = slotGlyph(h.captureCharFrame()) + // Waiting: a glyph the moving state never paints, held still. + expect(workingGlyphs.has(blockedGlyph)).toBe(false) + const blockedRow = statusRow(h.captureCharFrame()) + advance(4_000) + await h.renderOnce() + expect(statusRow(h.captureCharFrame())).toBe(blockedRow) + } finally { + bridge.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("a stalled turn blinks a bang into the border, then settles to a static one", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }) + const { monitor, advance } = fakeMonitor({ + noticeMs: 1_000, + timeoutMs: 600_000, + }) + const bridge = attachSessionBridge(shell, createRecordingPort(), monitor) + try { + bridge.handle({ type: "run", state: "busy" }) + await h.renderOnce() + expect(slotGlyph(h.captureCharFrame())).toMatch(DENSITY) + + advance(1_500) + await h.renderOnce() + // Scoped to the slot: a bang anywhere else in the frame is not this. + const blinking = new Set() + for (let i = 0; i < 4; i++) { + blinking.add(slotGlyph(h.captureCharFrame())) + advance(STALL_BLINK_CYCLE_MS / 2) + await h.renderOnce() + } + expect(blinking.has("!")).toBe(true) + expect(blinking.size).toBeGreaterThan(1) + + // Past the burst the alarm stops strobing but still reads as one. + advance(STALL_BLINK_BURST_MS * 2) + await h.renderOnce() + const settled = statusRow(h.captureCharFrame()) + expect(slotGlyph(settled)).toBe("!") + advance(STALL_BLINK_CYCLE_MS / 2) + await h.renderOnce() + expect(statusRow(h.captureCharFrame())).toBe(settled) } finally { bridge.dispose() } diff --git a/src/tui-opentui/ramp.test.ts b/src/tui-opentui/ramp.test.ts index f3d057dc5..6a0862a6f 100644 --- a/src/tui-opentui/ramp.test.ts +++ b/src/tui-opentui/ramp.test.ts @@ -5,8 +5,12 @@ import { RAMP_WIDTH, rampFor, rampLine, + rampAnimating, + rampPulse, renderIndeterminateRamp, renderRamp, + STALL_BLINK_BURST_MS, + STALL_BLINK_CYCLE_MS, } from "./ramp" import { UI } from "./theme" @@ -122,6 +126,69 @@ describe("rampFor", () => { }) }) +describe("rampPulse", () => { + test("working cycles the density glyphs, so the cell visibly moves", () => { + const seen = new Set( + [0, 300, 600, 900].map((nowMs) => + rampPulse({ phase: "working", nowMs, stalledForMs: null }), + ), + ) + expect(seen.size).toBeGreaterThan(1) + for (const glyph of seen) expect("░▒▓█").toContain(glyph) + }) + + test("blocked is one static glyph that working never paints", () => { + const blocked = rampPulse({ phase: "blocked", nowMs: 0, stalledForMs: null }) + expect(rampPulse({ phase: "blocked", nowMs: 77_000, stalledForMs: null })).toBe( + blocked, + ) + expect("░▒▓█").not.toContain(blocked) + }) + + test("stalled blinks a bang against a block while the burst runs", () => { + expect(rampPulse({ phase: "stalled", nowMs: 0, stalledForMs: 0 })).toBe("█") + expect( + rampPulse({ + phase: "stalled", + nowMs: STALL_BLINK_CYCLE_MS / 2, + stalledForMs: 0, + }), + ).toBe("!") + }) + + test("stalled settles to a static bang once the burst is spent", () => { + for (const nowMs of [0, STALL_BLINK_CYCLE_MS / 2, 9_999]) { + expect( + rampPulse({ phase: "stalled", nowMs, stalledForMs: STALL_BLINK_BURST_MS }), + ).toBe("!") + } + }) + + test("every glyph is a spinner-free single cell", () => { + for (const phase of ["working", "done", "blocked", "stalled"] as const) { + const glyph = rampPulse({ phase, nowMs: 400, stalledForMs: 0 }) + expect(glyph).not.toMatch(BRAILLE) + expect(glyph).toHaveLength(1) + } + }) +}) + +describe("rampAnimating", () => { + test("terminal and waiting states cost no frames", () => { + expect(rampAnimating("done", null)).toBe(false) + expect(rampAnimating("blocked", null)).toBe(false) + expect(rampAnimating("working", null)).toBe(true) + }) + + test("a stall stops asking for frames once its burst has spent itself", () => { + expect(rampAnimating("stalled", 0)).toBe(true) + expect(rampAnimating("stalled", STALL_BLINK_BURST_MS - 1)).toBe(true) + expect(rampAnimating("stalled", STALL_BLINK_BURST_MS)).toBe(false) + // A resumed session inherits an already-old stall and never bursts. + expect(rampAnimating("stalled", STALL_BLINK_BURST_MS * 100)).toBe(false) + }) +}) + describe("rampLine", () => { test("composes ramp, lowercase label and elapsed seconds", () => { const ramp = rampFor({ phase: "working", nowMs: 0, progress: 0.7 }) diff --git a/src/tui-opentui/ramp.ts b/src/tui-opentui/ramp.ts index ea68edfea..93f305733 100644 --- a/src/tui-opentui/ramp.ts +++ b/src/tui-opentui/ramp.ts @@ -3,13 +3,27 @@ * * corbits.dev renders an ordered dither at a 4-pixel cell; a terminal has that * texture natively as block-density characters, so the house motif ports rather - * than being approximated. The ramp fills left to right and its *color and - * motion* carry the state, not a text label: + * than being approximated. Two surfaces draw from it, at two widths. * - * working ███████▓▒░ blue, animating + * The wide fill (`rampFor`) is the provider-setup status line: + * + * working ███████▓▒░ bronze, comet crawling left to right * done ██████████ green, still * blocked █████▓▒░ orange, frozen mid-fill * + * The single cell (`rampPulse`) is the session shell's bottom-left status slot, + * where one column is all the border row can spare: + * + * working █ ▓ ▒ ░ … bronze, cycling density — it visibly moves + * done █ green, still + * blocked ▌ orange, one static half block — stillness is the signal + * stalled ! / █ orange, bangs alternating with a block, then static ! + * + * `blocked` and `stalled` share a color deliberately — both name a turn waiting + * on outside action — but must never be confused for each other, and neither + * may be confused with a live one. Every distinction above is carried by glyph + * and motion before color, so all four survive a monochrome terminal. + * * Pure and clock-injected: `nowMs` is the only time source, so the caller's * existing tick drives the animation and tests drive it deterministically. */ @@ -39,6 +53,49 @@ export const RAMP_CYCLE_MS = 1200 /** Where a blocked ramp freezes when the caller has no real progress. */ const BLOCKED_DEFAULT_PROGRESS = 0.5 +/** Glyph shown in place of a block during the off phase of the stall blink. */ +export const STALL_GLYPH = "!" + +/** Static single cell for a turn frozen on an operator gate. */ +const BLOCKED_GLYPH = "▌" + +/** One full on/off cycle of the stall blink. */ +export const STALL_BLINK_CYCLE_MS = 900 + +/** + * How long the stall blink runs before settling to a static bang. + * + * A stall notice arms at 90s of silence and the abort does not land until 900s, + * so an unbounded blink would strobe for a quarter of an hour. An alarm that is + * identical at second one and minute thirteen stops being an alarm — the + * operator learns to filter it, which is the exact failure this indicator + * exists to fix. So the blink is a burst: it spends its attention up front, + * where the state is news, then holds a bang that still reads as a problem to + * anyone arriving late and still differs from working (which moves) and blocked + * (which is a block glyph) with no color and no motion at all. Settling also + * lets the tick fall back to the slow cadence instead of holding an animation + * frame budget open for the rest of the stall, and gives a motion-sensitive + * operator a bounded rather than indefinite strobe. + */ +export const STALL_BLINK_BURST_MS = STALL_BLINK_CYCLE_MS * 9 + +/** + * Whether `nowMs` falls in the "on" (solid) half of the stall blink. Exported + * so any surface painting a stalled phase blinks on the same clock rather than + * each inventing its own. + */ +export function stallBlinkOn(nowMs: number): boolean { + const phase = + ((nowMs % STALL_BLINK_CYCLE_MS) + STALL_BLINK_CYCLE_MS) % + STALL_BLINK_CYCLE_MS + return phase < STALL_BLINK_CYCLE_MS / 2 +} + +/** Whether the burst is still running for a stall that began `stalledForMs` ago. */ +export function stallBlinkActive(stalledForMs: number): boolean { + return stalledForMs >= 0 && stalledForMs < STALL_BLINK_BURST_MS +} + function clamp01(value: number): number { if (!Number.isFinite(value)) return 0 if (value < 0) return 0 @@ -83,11 +140,65 @@ export function renderIndeterminateRamp( return out } -export type RampPhase = "working" | "done" | "blocked" +export type RampPhase = "working" | "done" | "blocked" | "stalled" -export type RampInput = { +/** + * The phases the wide fill draws. A stall is only ever reported by a live + * session, and the only surface a live session paints is the single cell, so + * widening a stall would produce glyphs nothing renders. + */ +export type RampFillPhase = Exclude + +/** + * How long the turn has been stalled, or null when it is not stalled. Required + * rather than defaulted: it is what decides whether the blink is still running, + * and a caller that forgets it would silently paint a permanent strobe. + */ +export type StallAge = number | null + +export type PulseInput = { readonly phase: RampPhase readonly nowMs: number + readonly stalledForMs: StallAge +} + +/** The one glyph the session shell's status slot can afford. */ +export function rampPulse(input: PulseInput): string { + if (input.phase === "done") return SOLID + if (input.phase === "blocked") return BLOCKED_GLYPH + if (input.phase === "stalled") { + const blinking = + input.stalledForMs !== null && stallBlinkActive(input.stalledForMs) + return blinking && stallBlinkOn(input.nowMs) ? SOLID : STALL_GLYPH + } + const phase = ((input.nowMs % RAMP_CYCLE_MS) + RAMP_CYCLE_MS) % RAMP_CYCLE_MS + const step = Math.floor((phase / RAMP_CYCLE_MS) * FEATHER.length) + return FEATHER[Math.min(FEATHER.length - 1, step)] ?? SOLID +} + +/** The turn phase's color, shared by every surface that paints the phase. */ +export function rampFg(phase: RampPhase): string { + if (phase === "done") return UI.done + if (phase === "blocked" || phase === "stalled") return UI.action + return UI.inFlight +} + +/** + * Whether the phase still has frames left to draw. False for the terminal and + * waiting states, and false for a stall once its blink burst has settled, so + * the caller's tick can fall back to its slow cadence. + */ +export function rampAnimating(phase: RampPhase, stalledForMs: StallAge): boolean { + if (phase === "done" || phase === "blocked") return false + if (phase === "stalled") { + return stalledForMs !== null && stallBlinkActive(stalledForMs) + } + return true +} + +export type RampInput = { + readonly phase: RampFillPhase + readonly nowMs: number /** Omit when the work has no denominator — the ramp animates instead. */ readonly progress?: number readonly width?: number @@ -100,33 +211,31 @@ export type Ramp = { readonly animating: boolean } -/** Resolve the ramp's glyphs, color and motion from the turn phase. */ +/** Resolve the wide fill's glyphs, color and motion from the turn phase. */ export function rampFor(input: RampInput): Ramp { const width = input.width ?? RAMP_WIDTH + const fg = rampFg(input.phase) if (input.phase === "done") { - return { cells: SOLID.repeat(width), fg: UI.done, animating: false } + return { cells: SOLID.repeat(width), fg, animating: false } } if (input.phase === "blocked") { return { cells: renderRamp(input.progress ?? BLOCKED_DEFAULT_PROGRESS, width), - fg: UI.action, + fg, animating: false, } } - return input.progress === undefined - ? { - cells: renderIndeterminateRamp(input.nowMs, width), - fg: UI.inFlight, - animating: true, - } - : { - cells: renderRamp(input.progress, width), - fg: UI.inFlight, - animating: true, - } + return { + cells: + input.progress === undefined + ? renderIndeterminateRamp(input.nowMs, width) + : renderRamp(input.progress, width), + fg, + animating: true, + } } /** `███████▓▒░ working · 14s` — ramp, lowercase label, optional elapsed. */ diff --git a/src/tui-opentui/render-loop.test.ts b/src/tui-opentui/render-loop.test.ts index 211208a5c..035d18d92 100644 --- a/src/tui-opentui/render-loop.test.ts +++ b/src/tui-opentui/render-loop.test.ts @@ -9,7 +9,7 @@ import { describe, expect, test } from "bun:test" import { withTestRenderer } from "./harness" -import { RAMP_CYCLE_MS } from "./ramp" +import { RAMP_CYCLE_MS, rampPulse } from "./ramp" import { attachSessionBridge, createRecordingPort } from "./runtime-bridge" import { appendStreamRow, createAppShell } from "./shell" @@ -64,7 +64,7 @@ describe("monitor cadence", () => { expect(m.running()).toBe(true) bridge.handle({ type: "reactor.done", data: {} }) - expect(shell.turnPhase).toBeNull() + expect(shell.lockupPhase).toBeNull() expect(m.running()).toBe(false) } finally { bridge.dispose() @@ -86,12 +86,20 @@ describe("monitor cadence", () => { // samples fewer times than that jumps cells instead of travelling. expect(RAMP_CYCLE_MS / (tickMs ?? 1)).toBeGreaterThanOrEqual(14) - const frames = new Set() + // 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) - if (shell.turnPhase !== null) frames.add(shell.turnPhase) + glyphs.add( + rampPulse({ + phase: "working", + nowMs: shell.lockupNowMs, + stalledForMs: null, + }), + ) } - expect(frames.size).toBeGreaterThanOrEqual(10) + expect(glyphs.size).toBeGreaterThanOrEqual(4) } finally { bridge.dispose() } diff --git a/src/tui-opentui/runtime-bridge.test.ts b/src/tui-opentui/runtime-bridge.test.ts index 9e2354d31..d4f12b42e 100644 --- a/src/tui-opentui/runtime-bridge.test.ts +++ b/src/tui-opentui/runtime-bridge.test.ts @@ -282,7 +282,7 @@ describe("attachSessionBridge", () => { }) bridge.handle({ type: "inference.done" }) expect(shell.session.run).toBe("idle") - expect(shell.turnPhase).toBeNull() + expect(shell.lockupPhase).toBeNull() port.clear() bridge.submit("are you still there", "queue") diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index 9d86b682a..a461ba7a4 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -25,14 +25,13 @@ import { setLockupFrame, setShellBridgeHooks, setStatusFlash, - setTurnPhase, streamRowAt, streamRowCount, truncateStreamRows, userRowText, type AppShell, } from "./shell.js" -import { rampFor, rampLine } from "./ramp.js" +import { rampAnimating } from "./ramp.js" import { onTurnBoundary } from "../agent/reactor-events.js" import { resolveRampPhase, @@ -43,8 +42,9 @@ import { quotaWaitSeconds, shouldAutoRetryQuota } from "./quota-retry.js" import { applyStallRecovery, repetitionRecoveryMessage, + isStalledForDisplay, shouldAbortForStall, - shouldNoticeStall, + stallLevel, STALL_NOTICE_MESSAGE, STALL_NOTICE_MS, STALL_RECOVERY_MESSAGE, @@ -133,11 +133,11 @@ const DEFAULT_TICK_MS = 250 /** * Poll period while something on this clock is animating. * - * The ramp traverses in `RAMP_CYCLE_MS` (1200 ms) and the landing mark runs a - * 4.6 s timeline; at 250 ms that is 5 and 19 samples respectively, so the ramp - * head jumps three cells a frame and the mark strobes. ~12 fps is the coarsest - * cadence at which both read as motion, and it costs nothing when idle because - * the monitor stops entirely then. + * The status slot's pulse steps through its glyphs in `RAMP_CYCLE_MS` (1200 ms) + * and the landing mark runs a 4.6 s timeline; at 250 ms that is 5 and 19 + * samples respectively, so the pulse skips steps and the mark strobes. ~12 fps + * is the coarsest cadence at which both read as motion, and it costs nothing + * when idle because the monitor stops entirely then. */ const ANIMATION_TICK_MS = 80 @@ -756,16 +756,48 @@ export function attachSessionBridge( } } - const paintPhase = (): void => { + /** + * The watchdog's inputs for the current turn. Built here rather than at each + * call site so the indicator and the abort can never be judging different + * facts about the same turn. + */ + const stallArgsFor = (nowMs: number) => ({ + status: bag.turn.status, + awaitingResponse: bag.turn.awaitingResponse, + lastActivityAt: bag.turn.lastActivityAt, + nowMs, + stallTimeoutMs, + isProcessing: bag.turn.isProcessing, + streamingType: bag.turn.streamingType, + activeToolCalls: bag.turn.activeToolCalls, + stallNoticeMs, + repeating: bag.turn.repeating, + }) + + /** + * How long the turn has been stalled, measured from the moment silence + * crossed the notice threshold, or null when it is not stalled. + * + * Derived from `lastActivityAt` rather than stamped when the stall is first + * seen, which gets two cases right for free: a session resumed with already + * stale activity reports a stall older than the blink burst and so paints the + * settled glyph immediately instead of alarming about an event the operator + * was not present for, and a stall that breaks and re-arms is measured from + * the new silence, so a second stall in a long session bursts again. + */ + const stalledForMs = (nowMs: number, isStalled: boolean): number | null => + isStalled ? nowMs - bag.turn.lastActivityAt - stallNoticeMs : null + + const paintPhaseAt = (nowMs: number, isStalled: boolean): void => { const turn = bag.turn // The landing mark rides this same re-entry: it animates through the // draw/fill loop while a turn is live and holds its filled frame otherwise. - paintLanding(shell, now(), turn.isProcessing) + paintLanding(shell, nowMs, turn.isProcessing) // The reveal position rides the same re-entry as the ramp and landing // mark: it needs to keep crawling through already-arrived text even when // no new delta has landed this tick. if (bag.openRow !== null && bag.openRow.kind === "thinking") { - advanceOpenReveal(shell, bag.openRow, now()) + advanceOpenReveal(shell, bag.openRow, nowMs) } const input = { isProcessing: turn.isProcessing, @@ -776,24 +808,46 @@ export function attachSessionBridge( streamTokenCount: turn.streamTokenCount, } const label = resolveTurnLabel(input) - // The bottom-left status slot rides the same re-entry as the landing mark, - // so it crossfades between phases without a timer of its own. - setLockupFrame(shell, now(), turn.isProcessing, label ?? null) if (label === undefined) { - setTurnPhase(shell, null) + // The bottom-left status slot rides the same re-entry as the landing + // mark, so it crossfades between phases without a timer of its own. + setLockupFrame(shell, { + nowMs, + animating: turn.isProcessing, + phase: null, + rampPhase: null, + stalledForMs: null, + }) // Nothing animates and nothing is being waited on, so the loop stops // rather than repainting an unchanging frame forever. The next event // re-enters here and re-arms it. applyCadence(bag.turn.quota !== null ? frozenTickMs : null) return } - // The monitor tick re-enters here, so reading the clock is all the - // animation the ramp needs — no second timer. - const ramp = rampFor({ phase: resolveRampPhase(input), nowMs: now() }) - setTurnPhase(shell, rampLine(ramp, label)) - // A frozen ramp (blocked on a gate) still needs the stall and quota clocks, - // just not animation frames. - applyCadence(ramp.animating ? animationTickMs : frozenTickMs) + const rampPhase = resolveRampPhase(input, isStalled) + const stalledFor = stalledForMs(nowMs, rampPhase === "stalled") + setLockupFrame(shell, { + nowMs, + animating: turn.isProcessing, + phase: label, + rampPhase, + stalledForMs: stalledFor, + }) + // A frozen ramp (blocked on a gate, or a stall past its blink burst) still + // needs the stall and quota clocks, just not animation frames. + applyCadence( + rampAnimating(rampPhase, stalledFor) ? animationTickMs : frozenTickMs, + ) + } + + /** + * The clock is read once here and threaded through everything the frame + * draws. Reading it per-consumer let the pulse and the cadence land on + * opposite sides of a blink boundary and disagree about the same frame. + */ + const paintPhase = (): void => { + const nowMs = now() + paintPhaseAt(nowMs, isStalledForDisplay(stallArgsFor(nowMs))) } /** True when this event is what ended the turn. */ @@ -966,16 +1020,7 @@ export function attachSessionBridge( return } - const stallArgs = { - status: bag.turn.status, - awaitingResponse: bag.turn.awaitingResponse, - lastActivityAt: bag.turn.lastActivityAt, - nowMs, - stallTimeoutMs, - isProcessing: bag.turn.isProcessing, - streamingType: bag.turn.streamingType, - activeToolCalls: bag.turn.activeToolCalls, - } + const stallArgs = stallArgsFor(nowMs) if (shouldAbortForStall(stallArgs)) { applyStallRecovery( @@ -987,17 +1032,12 @@ export function attachSessionBridge( // Notice only — the phase still paints below, because a ramp that stops // moving is the very thing that reads as a hang. - if ( - shouldNoticeStall({ - ...stallArgs, - stallNoticeMs, - repeating: bag.turn.repeating, - }) - ) { + const level = stallLevel(stallArgs) + if (level === "notice") { setStatusFlash(shell, STALL_NOTICE_MESSAGE) } - paintPhase() + paintPhaseAt(nowMs, level !== "quiet") } setShellBridgeHooks(shell, { @@ -1031,7 +1071,6 @@ export function attachSessionBridge( bag.disposed = true applyCadence(null) clearShellBridgeHooks(shell) - setTurnPhase(shell, null) bridges.delete(shell) }, } diff --git a/src/tui-opentui/session-chrome.test.ts b/src/tui-opentui/session-chrome.test.ts index c6f49bd71..12f67eed9 100644 --- a/src/tui-opentui/session-chrome.test.ts +++ b/src/tui-opentui/session-chrome.test.ts @@ -102,16 +102,28 @@ describe("resolveRampPhase", () => { } test("blocked gate freezes the ramp", () => { - expect(resolveRampPhase({ ...base, status: "blocked" })).toBe("blocked") + expect(resolveRampPhase({ ...base, status: "blocked" }, false)).toBe("blocked") }) test("done fills the ramp", () => { - expect(resolveRampPhase({ ...base, status: "done" })).toBe("done") + expect(resolveRampPhase({ ...base, status: "done" }, false)).toBe("done") }) test("everything else is working", () => { - expect(resolveRampPhase({ ...base, status: "running" })).toBe("working") - expect(resolveRampPhase({ ...base, status: "stopping" })).toBe("working") + expect(resolveRampPhase({ ...base, status: "running" }, false)).toBe("working") + expect(resolveRampPhase({ ...base, status: "stopping" }, false)).toBe("working") + }) + + test("a stalled running turn paints stalled, not working", () => { + expect( + resolveRampPhase({ ...base, status: "running" }, true), + ).toBe("stalled") + }) + + test("a blocked gate beats stalled — waiting on you outranks silence", () => { + expect( + resolveRampPhase({ ...base, status: "blocked" }, true), + ).toBe("blocked") }) }) diff --git a/src/tui-opentui/session-chrome.ts b/src/tui-opentui/session-chrome.ts index 126d6fc7f..943dd506e 100644 --- a/src/tui-opentui/session-chrome.ts +++ b/src/tui-opentui/session-chrome.ts @@ -51,10 +51,19 @@ export function resolveTurnLabel(input: TurnLabelInput): string | undefined { return "working" } -/** Which ramp the turn paints: frozen-orange, solid-green, or animating blue. */ -export function resolveRampPhase(input: TurnLabelInput): RampPhase { +/** + * Which ramp the turn paints: frozen-orange (blocked), solid-green (done), + * blinking-orange (stalled), or animating bronze (working). `isStalled` is + * the caller's own `shouldNoticeStall` result — this function does not + * re-derive staleness, it only orders it against the other phases. + */ +export function resolveRampPhase( + input: TurnLabelInput, + isStalled: boolean, +): RampPhase { if (input.status === "blocked") return "blocked" if (input.status === "done") return "done" + if (isStalled) return "stalled" return "working" } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 29d4ea710..d133b9905 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -62,7 +62,13 @@ import { } from "./prompt-input.js" import { promptBoxRows } from "./prompt-rows.js" import { composeNoticeLine } from "./notice-line.js" -import { lockupCells, lockupText, lockupWidth } from "./lockup.js" +import { + lockupCells, + lockupText, + lockupWidth, + type LockupInput, +} from "./lockup.js" +import type { RampPhase, StallAge } from "./ramp.js" import { BORDER, composeCostContextMeter, @@ -629,13 +635,6 @@ export type AppShell = { statusFlash: string | null /** MCP servers awaiting authorization; the notice row names them. */ mcpNeedsAuth: readonly string[] - /** - * Live turn phase ("Thinking…", "Running tool…", …) or null when idle. - * Lives on the transient notice row rather than a chrome zone because the product host - * owns the goal/task/agents zones and overwrites them wholesale on every - * snapshot push, which would clobber a per-token progress line. - */ - turnPhase: string | null /** * Clock, motion and content state for the bottom-left status slot. The bridge * pushes all of it off its existing monitor tick (`setLockupFrame`); the @@ -648,6 +647,10 @@ export type AppShell = { lockupPhase: string | null /** Clock reading when `lockupPhase` last changed — the fade's origin. */ lockupChangedMs: number + /** Density ramp phase for the same turn — drives the slot's pulse cell and tint. */ + lockupRampPhase: RampPhase | null + /** How long the turn has been stalled, or null when it is not — bounds the blink. */ + lockupStalledForMs: StallAge /** * Cost/context meter carried by the bottom border, or null when the active * session has nothing to report (context window unknown). Pushed by the @@ -872,21 +875,33 @@ function syncLandingSuggestions(shell: AppShell): void { * frame the turn ends, and a transition with no frames left to draw is worse * than none. */ -export function setLockupFrame( - shell: AppShell, - nowMs: number, - animating: boolean, - phase: string | null = null, -): void { - const settled = !animating && !shell.lockupAnimating - shell.lockupNowMs = nowMs - const changed = phase !== shell.lockupPhase - if (changed) { - shell.lockupPhase = phase - shell.lockupChangedMs = nowMs - } - if (settled && !changed && shell.lockupAnimating === animating) return - shell.lockupAnimating = animating +export type LockupFrame = { + readonly nowMs: number + readonly animating: boolean + /** Live phase word, or null for the idle wordmark. */ + readonly phase: string | null + /** The turn's ramp phase, or null when idle. */ + readonly rampPhase: RampPhase | null + /** How long the turn has been stalled, or null when it is not stalled. */ + readonly stalledForMs: StallAge +} + +export function setLockupFrame(shell: AppShell, frame: LockupFrame): void { + const settled = !frame.animating && !shell.lockupAnimating + shell.lockupNowMs = frame.nowMs + const phaseChanged = frame.phase !== shell.lockupPhase + if (phaseChanged) { + shell.lockupPhase = frame.phase + shell.lockupChangedMs = frame.nowMs + } + const changed = + phaseChanged || + frame.rampPhase !== shell.lockupRampPhase || + frame.stalledForMs !== shell.lockupStalledForMs + shell.lockupRampPhase = frame.rampPhase + shell.lockupStalledForMs = frame.stalledForMs + if (settled && !changed && shell.lockupAnimating === frame.animating) return + shell.lockupAnimating = frame.animating paintChrome(shell) } @@ -993,13 +1008,6 @@ export function setStatusFlash( ) } -/** Set the live turn phase label (null hides it). Repaints only on change. */ -export function setTurnPhase(shell: AppShell, phase: string | null): void { - if (shell.turnPhase === phase) return - shell.turnPhase = phase - paintChrome(shell) -} - /** Apply focus state to OpenTUI focusables. */ export function applyFocus(shell: AppShell): void { const owner = focusOwner(shell.focus) @@ -1466,12 +1474,14 @@ function meterChunks(shell: AppShell, cell: string): TextChunk[] { } /** The status slot's state, as the lockup renderer wants it. */ -function lockupFrameInput(shell: AppShell) { +function lockupFrameInput(shell: AppShell): LockupInput { return { nowMs: shell.lockupNowMs, still: !shell.lockupAnimating, phase: shell.lockupPhase, changedMs: shell.lockupChangedMs, + rampPhase: shell.lockupRampPhase, + stalledForMs: shell.lockupStalledForMs, } } @@ -1493,7 +1503,7 @@ export function paintPromptBorder(shell: AppShell): void { // what the workspace has to fit inside — with the lockup if the rule can // seat both, without it if it cannot. Where the row can only afford one, the // information wins and the mark goes. - const withBrand = Math.max(0, width - 9 - lockupWidth(shell.lockupPhase)) + const withBrand = Math.max(0, width - 9 - lockupWidth(lockupFrameInput(shell))) const alone = Math.max(0, width - 6) const workspaceInput = { cwd: shell.workspace.cwd, @@ -5645,11 +5655,12 @@ export function createAppShell( copyTargets: null, statusFlash: null, mcpNeedsAuth: [], - turnPhase: null, lockupNowMs: 0, lockupAnimating: false, lockupPhase: null, lockupChangedMs: 0, + lockupRampPhase: null, + lockupStalledForMs: null, costContext: null, observe: null, parentStreamLog: null, diff --git a/src/tui-opentui/stall-watchdog.test.ts b/src/tui-opentui/stall-watchdog.test.ts index 91fe39e94..7b0f007de 100644 --- a/src/tui-opentui/stall-watchdog.test.ts +++ b/src/tui-opentui/stall-watchdog.test.ts @@ -3,9 +3,11 @@ import { describe, expect, test } from "bun:test" import { applyStallRecovery, detectRepetition, + isStalledForDisplay, repetitionRecoveryMessage, shouldAbortForStall, shouldNoticeStall, + stallLevel, STALL_NOTICE_MS, STALL_RECOVERY_MESSAGE, STALL_TIMEOUT_MS, @@ -244,3 +246,41 @@ describe("shouldNoticeStall", () => { ).toBe(false) }) }) + +describe("the stall level the indicator reads", () => { + const base = { + status: "running" as const, + awaitingResponse: true, + lastActivityAt: 0, + nowMs: STALL_NOTICE_MS, + stallTimeoutMs: STALL_TIMEOUT_MS, + stallNoticeMs: STALL_NOTICE_MS, + isProcessing: true, + streamingType: null, + activeToolCalls: [], + repeating: false, + } + + test("quiet, notice and abort partition the same silence clock", () => { + expect(stallLevel({ ...base, nowMs: STALL_NOTICE_MS - 1 })).toBe("quiet") + expect(stallLevel({ ...base, nowMs: STALL_NOTICE_MS })).toBe("notice") + expect(stallLevel({ ...base, nowMs: STALL_TIMEOUT_MS })).toBe("abort") + }) + + test("the indicator keeps reading stalled across the abort threshold", () => { + // The notice hands over to the abort so the two never speak at once, but + // the phase must not flip back to healthy at the exact moment the run is + // most stuck — that was the whole complaint the indicator answers. + expect(shouldNoticeStall({ ...base, nowMs: STALL_TIMEOUT_MS })).toBe(false) + expect(isStalledForDisplay({ ...base, nowMs: STALL_TIMEOUT_MS })).toBe(true) + expect(isStalledForDisplay({ ...base, nowMs: STALL_TIMEOUT_MS * 3 })).toBe( + true, + ) + }) + + test("a repeating run is not a stall on any surface", () => { + const looping = { ...base, nowMs: STALL_TIMEOUT_MS, repeating: true } + expect(stallLevel(looping)).toBe("quiet") + expect(isStalledForDisplay(looping)).toBe(false) + }) +}) diff --git a/src/tui-opentui/stall-watchdog.ts b/src/tui-opentui/stall-watchdog.ts index 0ca82147d..0f408f202 100644 --- a/src/tui-opentui/stall-watchdog.ts +++ b/src/tui-opentui/stall-watchdog.ts @@ -142,16 +142,37 @@ export type ShouldNoticeStallArgs = ShouldAbortForStallArgs & { readonly repeating: boolean } +export type StallLevel = "quiet" | "notice" | "abort" + +/** + * How stuck the run is. Three levels, because the two consumers want different + * cuts of the same clock: the status flash wants "silent, but not yet handled" + * so it does not shout over the abort's own message, while the phase indicator + * wants "silent at all" — it must keep reading as a problem right through the + * abort threshold rather than flipping back to healthy at the worst possible + * instant. Both read this one function so they can never disagree about which + * runs are stalled, only about what to do at each level. + * + * Quiet while repeating: that run is producing output, just not useful output, + * and "no response" would misdescribe it. + */ +export function stallLevel(args: ShouldNoticeStallArgs): StallLevel { + if (args.repeating) return "quiet" + if (shouldAbortForStall(args)) return "abort" + return silentPastThreshold(args, args.stallNoticeMs) ? "notice" : "quiet" +} + /** * Returns true while the run has been silent long enough to say so but not yet - * long enough to abort. False once the abort takes over, so the two never - * paint at the same time, and false while repeating — that run is producing - * output, just not useful output, and "no response" would misdescribe it. + * long enough to abort, so the notice and the abort never speak at once. */ export function shouldNoticeStall(args: ShouldNoticeStallArgs): boolean { - if (args.repeating) return false - if (shouldAbortForStall(args)) return false - return silentPastThreshold(args, args.stallNoticeMs) + return stallLevel(args) === "notice" +} + +/** Whether the phase indicator should paint the run as stalled. */ +export function isStalledForDisplay(args: ShouldNoticeStallArgs): boolean { + return stallLevel(args) !== "quiet" } /** diff --git a/src/tui-opentui/turn-monitor.test.ts b/src/tui-opentui/turn-monitor.test.ts index 517faf03f..0ac5b2cd3 100644 --- a/src/tui-opentui/turn-monitor.test.ts +++ b/src/tui-opentui/turn-monitor.test.ts @@ -51,39 +51,41 @@ const quotaEvent = (retryAfterMs: number) => ({ data: { error: { category: "quota_exhausted", retryAfterMs } }, }) -const RAMP = /[░▒▓█]/ describe("turn progress label", () => { test("tracks the live phase and clears when the run settles", async () => { await withTestRenderer(async (h) => { const t: Harness = await setup(h) try { - expect(t.shell.turnPhase).toBeNull() + expect(t.shell.lockupPhase).toBeNull() t.bridge.handle({ type: "inference.start", data: {} }) - expect(t.shell.turnPhase).toMatch(RAMP) - expect(t.shell.turnPhase).toEndWith("working") + // What the slot paints from this phase is asserted against the + // rendered border row in the ramp paint tests; here it is only that + // the phase itself tracks the run. + expect(t.shell.lockupRampPhase).toBe("working") + expect(t.shell.lockupPhase).toBe("working") t.bridge.handle({ type: "inference.thinking.delta", data: { token: "hm" }, }) - expect(t.shell.turnPhase).toEndWith("thinking") + expect(t.shell.lockupPhase).toBe("thinking") t.bridge.handle({ type: "inference.text.delta", data: { token: "hi" } }) - expect(t.shell.turnPhase).toEndWith("streaming 1 tok") + expect(t.shell.lockupPhase).toBe("streaming 1 tok") t.bridge.handle({ type: "inference.text.delta", data: { token: " there" } }) - expect(t.shell.turnPhase).toEndWith("streaming 2 tok") + expect(t.shell.lockupPhase).toBe("streaming 2 tok") t.bridge.handle({ type: "inference.tool_call.end", data: { name: "bash", callId: "c1" }, }) - expect(t.shell.turnPhase).toEndWith("bash") + expect(t.shell.lockupPhase).toBe("bash") t.bridge.handle({ type: "reactor.done", data: {} }) - expect(t.shell.turnPhase).toBeNull() + expect(t.shell.lockupPhase).toBeNull() } finally { t.bridge.dispose() } @@ -146,7 +148,7 @@ describe("turn progress label", () => { // The cycle's reply lands while bash is still out: the turn continues. t.bridge.handle({ type: "connector.reply", data: { content: "" } }) - expect(t.shell.turnPhase).not.toBeNull() + expect(t.shell.lockupPhase).not.toBeNull() t.bridge.handle({ type: "tool.start", @@ -162,7 +164,7 @@ describe("turn progress label", () => { t.bridge.handle({ type: "inference.done", data: {} }) t.bridge.handle({ type: "connector.reply", data: { content: "done." } }) - expect(t.shell.turnPhase).toBeNull() + expect(t.shell.lockupPhase).toBeNull() expect(t.bridge.turn.isProcessing).toBe(false) expect(noticeText(t.shell)).not.toContain("working") // The session is handed back and the transient row empties with it. @@ -172,7 +174,7 @@ describe("turn progress label", () => { // A later tick must not resurrect it. t.advance(250) t.tick() - expect(t.shell.turnPhase).toBeNull() + expect(t.shell.lockupPhase).toBeNull() } finally { t.bridge.dispose() } @@ -185,13 +187,13 @@ describe("turn progress label", () => { try { t.bridge.handle({ type: "inference.start", data: {} }) t.bridge.handle({ type: "inference.text.delta", data: { token: "hi" } }) - expect(t.shell.turnPhase).not.toBeNull() + expect(t.shell.lockupPhase).not.toBeNull() t.bridge.interrupt() - expect(t.shell.turnPhase).toBeNull() + expect(t.shell.lockupPhase).toBeNull() t.advance(250) t.tick() - expect(t.shell.turnPhase).toBeNull() + expect(t.shell.lockupPhase).toBeNull() } finally { t.bridge.dispose() } @@ -207,13 +209,13 @@ describe("turn progress label", () => { type: "inference.tool_call.end", data: { name: "bash", callId: "c1" }, }) - expect(t.shell.turnPhase).not.toBeNull() + expect(t.shell.lockupPhase).not.toBeNull() t.bridge.handle({ type: "reactor.error", data: { fatal: true, error: "boom" }, }) - expect(t.shell.turnPhase).toBeNull() + expect(t.shell.lockupPhase).toBeNull() expect(t.bridge.turn.isProcessing).toBe(false) } finally { t.bridge.dispose() @@ -229,12 +231,12 @@ describe("turn progress label", () => { t.shell.overlayKind = "permissions" t.bridge.gateOpened() t.tick() - expect(t.shell.turnPhase).toEndWith("blocked") + expect(t.shell.lockupPhase).toBe("blocked") // Frozen is the signal: the ramp must not move while a human is asked. - const frozen = t.shell.turnPhase + const frozen = t.shell.lockupPhase t.advance(1_000) - expect(t.shell.turnPhase).toBe(frozen) + expect(t.shell.lockupPhase).toBe(frozen) // The running state lives in the border, not the transient row: the // row would be a second indicator one line above the first. @@ -315,7 +317,7 @@ describe("stall watchdog", () => { expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE) // A notice, not a timeout: the run is still going. expect(t.port.calls).toEqual([]) - expect(t.shell.turnPhase).not.toBeNull() + expect(t.shell.lockupPhase).not.toBeNull() } finally { t.bridge.dispose() } diff --git a/src/tui-opentui/width-columns.test.ts b/src/tui-opentui/width-columns.test.ts index 5dbdcffd4..e06d4f515 100644 --- a/src/tui-opentui/width-columns.test.ts +++ b/src/tui-opentui/width-columns.test.ts @@ -20,6 +20,7 @@ import { middleEllipsis } from "./command-display.js" import { renderDiff } from "./diff.js" import { wrapLanding } from "./landing.js" import { lockupWidth } from "./lockup.js" +import type { RampPhase } from "./ramp.js" import { formatPaletteRows } from "./palette.js" import { composeDecisionBody, decisionChoiceRows, wrapWords } from "./overlay-body.js" import { thinkingScrollLine, thinkingSettledLine } from "./thinking.js" @@ -191,8 +192,23 @@ describe("palette rows", () => { }) describe("lockup", () => { + const slot = (phase: string | null, rampPhase: RampPhase | null) => ({ + nowMs: 0, + still: phase === null, + phase, + changedMs: 0, + rampPhase, + stalledForMs: null, + }) + test("reports painted columns", () => { - expect(lockupWidth(null)).toBe(stringWidth("corbits code")) - expect(lockupWidth(CJK)).toBe(stringWidth(CJK)) + expect(lockupWidth(slot(null, null))).toBe(stringWidth("corbits code")) + expect(lockupWidth(slot(CJK, null))).toBe(stringWidth(CJK)) + }) + + test("a live slot reserves the pulse cell and its space too", () => { + // Reserved off the cells that get painted, so a wide label cannot make + // the reservation and the paint disagree by a column. + expect(lockupWidth(slot(CJK, "working"))).toBe(stringWidth(CJK) + 2) }) })