Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,58 @@ 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
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
Expand Down
2 changes: 0 additions & 2 deletions src/tui-opentui/geometry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 11 additions & 11 deletions src/tui-opentui/geometry/margins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,24 @@
* 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

/** 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. */
Expand Down
2 changes: 1 addition & 1 deletion src/tui-opentui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
149 changes: 144 additions & 5 deletions src/tui-opentui/lockup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/[▁▂▃▄▅▆▇█]/)
Expand All @@ -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", () => {
Expand All @@ -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)
Expand All @@ -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)
}
})
})
Loading
Loading