From e0b3724eb9060ae59fd64c8d6db242b6d2275045 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 12:59:17 -0700 Subject: [PATCH 1/2] Make the live phase slot show whether the session is actually moving The bottom-left slot printed the phase as a flat word, so a live run and a hung one rendered identically and the only way to tell them apart was to wait and see whether anything ever changed. Lead the word with a single density cell that cycles while the turn moves, holds one static glyph while the turn is blocked on an operator gate, and blinks a bang once the run has gone silent. Every distinction is a glyph or a motion before it is a color, so the states separate on a monochrome terminal and without reading the word. The stall blink is a bounded burst that settles to a static bang: an alarm that strobes for the whole stall window becomes wallpaper, and settling lets the render loop drop back to its slow cadence. Measuring the burst from the notice threshold rather than stamping it means a resumed session with stale activity shows the settled glyph immediately instead of alarming about silence its operator was not present for. The watchdog now reports the stall as three levels rather than a boolean, so the indicator can keep reading as stalled through the abort threshold while the status flash still steps aside for the abort's own message. --- docs/TUI.md | 36 +++++ src/tui-opentui/lockup.test.ts | 149 ++++++++++++++++++- src/tui-opentui/lockup.ts | 68 +++++++-- src/tui-opentui/ramp-paint.test.ts | 194 ++++++++++++++++++++++--- src/tui-opentui/ramp.test.ts | 67 +++++++++ src/tui-opentui/ramp.ts | 147 ++++++++++++++++--- src/tui-opentui/render-loop.test.ts | 18 ++- src/tui-opentui/runtime-bridge.test.ts | 2 +- src/tui-opentui/runtime-bridge.ts | 121 +++++++++------ src/tui-opentui/session-chrome.test.ts | 20 ++- src/tui-opentui/session-chrome.ts | 13 +- src/tui-opentui/shell.ts | 77 +++++----- src/tui-opentui/stall-watchdog.test.ts | 40 +++++ src/tui-opentui/stall-watchdog.ts | 33 ++++- src/tui-opentui/turn-monitor.test.ts | 44 +++--- src/tui-opentui/width-columns.test.ts | 20 ++- 16 files changed, 876 insertions(+), 173 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index a42ce17c1..31138351a 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -43,6 +43,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/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/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) }) }) From 6c7cd70bb90d700c2c956fee15baa6c111184408 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 12:59:24 -0700 Subject: [PATCH 2/2] Narrow the side gutter shared by every shell surface The gutter held at two columns per side from 60 columns wide all the way up, never scaling down or up with the terminal, which read as excess air on a wide pane. Drop it to one column, still enough to keep content off the frame edge at every width the gutter is affordable. --- docs/TUI.md | 9 +++++++++ src/tui-opentui/geometry/index.ts | 2 -- src/tui-opentui/geometry/margins.ts | 22 +++++++++++----------- src/tui-opentui/landing.test.ts | 2 +- src/tui-opentui/margins.test.ts | 23 ++++++++++++++--------- 5 files changed, 35 insertions(+), 23 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 31138351a..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 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/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", () => {