Skip to content

Commit 722dee5

Browse files
Merge pull request #410 from corbitsdev/cl-5695-live-working-indicator
Animate the working indicator and narrow the side gutter
2 parents c986948 + 6c7cd70 commit 722dee5

20 files changed

Lines changed: 911 additions & 196 deletions

docs/TUI.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,58 @@ goal/task/agents strips, then progress, then the prompt itself shrinks one
3636
row at a time down to its 3-row base — never the transcript
3737
(`COLLAPSE_ORDER` in `zones.ts`).
3838

39+
Horizontally, every surface sits inside one shared gutter
40+
(`resolveSideMargin`, `src/tui-opentui/geometry/margins.ts`) so the shell reads
41+
as a single column of content rather than stacked panes. The gutter is one
42+
column per side at every width that can afford it, and zero below
43+
`MARGIN_MIN_COLUMNS` (40), where every column belongs to content. There is no
44+
middle tier: one column is already enough to keep content off the frame edge,
45+
which is the gutter's entire job, and anything wider only read as excess air on
46+
a wide pane. The gutter costs no rows.
47+
3948
The prompt box's border carries the metadata that would otherwise cost a
4049
titlebar row: the model label sits right-aligned in the top rule; the brand
4150
lockup sits at the left of the bottom rule with the working directory and git
4251
branch at its right (`AppShell.promptTopRule` / `promptBottomRule`,
4352
`src/tui-opentui/shell.ts`). Both rules cost zero transcript rows because they
4453
ride the prompt box's own border.
4554

55+
While a turn is live the lockup slot swaps the wordmark for the phase word —
56+
`thinking`, `streaming 12 tok`, the running tool's name — led by a single
57+
density cell (`rampPulse`, `src/tui-opentui/ramp.ts`). The cell, not the word,
58+
is what says whether the session is healthy, and it carries four states:
59+
60+
| State | Cell | Reads as |
61+
|---|---|---|
62+
| `working` | cycles `░ ▒ ▓ █` on `RAMP_CYCLE_MS` | moving |
63+
| `done` | static `` | finished |
64+
| `blocked` | static `` | waiting on the operator |
65+
| `stalled` | `!` blinking against ``, then a static `!` | a problem |
66+
67+
Every state is separated by glyph and motion before colour, so all four survive
68+
a monochrome terminal and are readable without stopping to read the word. A
69+
static `working` word was the original failure: a live run and a hung one
70+
printed identically, so the only way to tell them apart was to wait.
71+
72+
`blocked` and `stalled` share the orange deliberately — both name a turn
73+
waiting on something outside itself — and are told apart by motion: `blocked`
74+
holds perfectly still, which is the signal that the session is waiting on *you*.
75+
76+
The stall phase is driven by the watchdog's own silence clock
77+
(`stallLevel`, `src/tui-opentui/stall-watchdog.ts`), so the indicator and the
78+
abort can never disagree about which runs are stuck. It arms at
79+
`STALL_NOTICE_MS` and keeps reading as stalled straight through the abort
80+
threshold. Its blink is a bounded burst (`STALL_BLINK_BURST_MS`) that settles
81+
to a static `!`: an alarm that strobes for the whole stall window becomes
82+
wallpaper, and settling also lets the render loop drop back to the slow
83+
cadence. The burst is measured from the moment silence crossed the notice
84+
threshold, so a resumed session with already-stale activity shows the settled
85+
glyph immediately rather than alarming about silence the operator missed, and
86+
a stall that breaks and re-arms bursts again.
87+
88+
An idle session animates nothing at all: the monitor tick stops entirely
89+
rather than repainting an unchanging frame.
90+
4691
Color is a small, deliberate palette, not decoration
4792
(`src/tui-opentui/theme.ts`). Dimmed text is a dimmed cream, never a neutral
4893
gray, so every emphasis level keeps the same warm hue. Orange

src/tui-opentui/geometry/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ export {
2121
export {
2222
BOTTOM_MARGIN_MIN_ROWS,
2323
BOTTOM_MARGIN_ROWS,
24-
MARGIN_FULL_MIN_COLUMNS,
2524
MARGIN_MIN_COLUMNS,
26-
NARROW_SIDE_MARGIN,
2725
SIDE_MARGIN,
2826
TOP_PAD_MIN_TRANSCRIPT_ROWS,
2927
TOP_PAD_ROWS,

src/tui-opentui/geometry/margins.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,24 @@
99
* here can take a row away from it.
1010
*/
1111

12-
/** Gutter columns on each side once the terminal can afford them. */
13-
export const SIDE_MARGIN = 2
14-
15-
/** Half gutter for terminals too narrow to spend four columns on air. */
16-
export const NARROW_SIDE_MARGIN = 1
17-
18-
/** At or above this width the full gutter is affordable. */
19-
export const MARGIN_FULL_MIN_COLUMNS = 60
12+
/**
13+
* Gutter columns on each side once the terminal can afford them.
14+
*
15+
* One column at every width the gutter exists at all. A single column is
16+
* already enough to keep content off the frame edge, which is the whole job,
17+
* and a wider gutter only read as excess air on a wide pane. There is no
18+
* middle tier: a width that can spare a column gets one, and a width that
19+
* cannot gets none.
20+
*/
21+
export const SIDE_MARGIN = 1
2022

2123
/** Below this width every column belongs to content: the gutter goes to zero. */
2224
export const MARGIN_MIN_COLUMNS = 40
2325

2426
/** Gutter width for a terminal of `columns` columns. */
2527
export function resolveSideMargin(columns: number): number {
2628
const cols = Math.max(0, Math.floor(columns))
27-
if (cols >= MARGIN_FULL_MIN_COLUMNS) return SIDE_MARGIN
28-
if (cols >= MARGIN_MIN_COLUMNS) return NARROW_SIDE_MARGIN
29-
return 0
29+
return cols >= MARGIN_MIN_COLUMNS ? SIDE_MARGIN : 0
3030
}
3131

3232
/** Columns left for content after both gutters. */

src/tui-opentui/landing.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ describe("landing screen", () => {
305305
expect(ruleRow).toBe(SIZE.height - 1)
306306
const row = painted[ruleRow]!
307307
// Left end of the rule, inside the shell gutter, costing no row.
308-
expect(row.startsWith(" ╰─ ")).toBe(true)
308+
expect(row.startsWith(" ╰─ ")).toBe(true)
309309
expect(row.trimEnd().endsWith("╯")).toBe(true)
310310
} finally {
311311
shell.dispose()

src/tui-opentui/lockup.test.ts

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,40 @@ import {
66
lockupCells,
77
lockupText,
88
lockupWidth,
9+
type LockupInput,
910
} from "./lockup"
11+
import { STALL_BLINK_BURST_MS, STALL_BLINK_CYCLE_MS, type RampPhase } from "./ramp"
1012
import { UI } from "./theme"
1113

12-
const still = (nowMs = 0) => lockupCells({ nowMs, still: true })
14+
const idle = (nowMs: number): LockupInput => ({
15+
nowMs,
16+
still: true,
17+
phase: null,
18+
changedMs: 0,
19+
rampPhase: null,
20+
stalledForMs: null,
21+
})
22+
23+
const live = (
24+
nowMs: number,
25+
phase: string,
26+
rampPhase: RampPhase,
27+
stalledForMs: number | null,
28+
): LockupInput => ({
29+
nowMs,
30+
still: false,
31+
phase,
32+
changedMs: 0,
33+
rampPhase,
34+
stalledForMs,
35+
})
36+
37+
const still = (nowMs = 0) => lockupCells(idle(nowMs))
1338

1439
describe("brand lockup", () => {
1540
test("idle is the wordmark alone", () => {
1641
const cells = still()
17-
expect(cells).toHaveLength(lockupWidth(null))
42+
expect(cells).toHaveLength(lockupWidth(idle(0)))
1843
expect(lockupText(cells)).toBe(LOCKUP_WORDMARK)
1944
// The mountain lives on the landing; one row cannot hold a silhouette.
2045
expect(lockupText(cells)).not.toMatch(/[]/)
@@ -28,9 +53,16 @@ describe("brand lockup", () => {
2853
})
2954

3055
test("a live turn swaps the wordmark for the phase", () => {
31-
const cells = lockupCells({ nowMs: 0, still: false, phase: "thinking" })
32-
expect(lockupText(cells)).toBe("thinking")
33-
expect(lockupWidth("thinking")).toBe(cells.length)
56+
const input: LockupInput = {
57+
nowMs: 0,
58+
still: false,
59+
phase: "thinking",
60+
changedMs: 0,
61+
rampPhase: null,
62+
stalledForMs: null,
63+
}
64+
expect(lockupText(lockupCells(input))).toBe("thinking")
65+
expect(lockupWidth(input)).toBe(lockupCells(input).length)
3466
})
3567

3668
test("the wordmark stays chrome-dim", () => {
@@ -46,6 +78,8 @@ describe("brand lockup", () => {
4678
still: false,
4779
phase: "bash",
4880
changedMs: 0,
81+
rampPhase: null,
82+
stalledForMs: null,
4983
})
5084
const tone = (elapsed: number) => at(elapsed)[0]?.fg
5185
expect(tone(0)).toBe(UI.textFaint)
@@ -59,3 +93,108 @@ describe("brand lockup", () => {
5993
expect(lockupText(at(0))).toBe(lockupText(at(LOCKUP_FADE_MS)))
6094
})
6195
})
96+
97+
describe("the live phase slot's pulse cell", () => {
98+
test("working keeps the word and leads it with a density cell", () => {
99+
const cells = lockupCells(live(0, "streaming 3 tok", "working", null))
100+
expect(lockupText(cells)).toMatch(/^[] streaming 3 tok$/)
101+
for (const cell of cells) expect(cell.fg).toBe(UI.inFlight)
102+
})
103+
104+
test("working's cell moves — the slot's glyphs change over a cycle", () => {
105+
const seen = new Set(
106+
[0, 300, 600, 900].map((nowMs) =>
107+
lockupText(lockupCells(live(nowMs, "working", "working", null))),
108+
),
109+
)
110+
expect(seen.size).toBeGreaterThan(1)
111+
})
112+
113+
test("blocked holds one static cell — stillness is the signal", () => {
114+
const at = (nowMs: number) =>
115+
lockupText(lockupCells(live(nowMs, "blocked", "blocked", null)))
116+
expect(at(0)).toBe("▌ blocked")
117+
expect(at(STALL_BLINK_CYCLE_MS)).toBe(at(0))
118+
expect(at(60_000)).toBe(at(0))
119+
})
120+
121+
test("working and blocked differ in glyph, not only in colour", () => {
122+
// Same word, same instant, colour stripped: the cell is the only thing
123+
// that can tell them apart, and it must.
124+
const distinct = new Set(
125+
[0, 300, 600, 900].map(
126+
(nowMs) =>
127+
`${lockupText(lockupCells(live(nowMs, "working", "working", null)))}|${lockupText(
128+
lockupCells(live(nowMs, "working", "blocked", null)),
129+
)}`,
130+
),
131+
)
132+
for (const pair of distinct) {
133+
const [moving, waiting] = pair.split("|")
134+
expect(moving).not.toBe(waiting)
135+
}
136+
})
137+
138+
test("stalled blinks a bang against a block while the burst runs", () => {
139+
const on = lockupText(lockupCells(live(0, "working", "stalled", 0)))
140+
const off = lockupText(
141+
lockupCells(live(STALL_BLINK_CYCLE_MS / 2, "working", "stalled", 0)),
142+
)
143+
expect(on).toBe("█ working")
144+
expect(off).toBe("! working")
145+
})
146+
147+
test("stalled settles to a static bang once the burst has spent itself", () => {
148+
const past = STALL_BLINK_BURST_MS
149+
const at = (nowMs: number) =>
150+
lockupText(lockupCells(live(nowMs, "working", "stalled", past + nowMs)))
151+
expect(at(0)).toBe("! working")
152+
expect(at(STALL_BLINK_CYCLE_MS / 2)).toBe("! working")
153+
expect(at(120_000)).toBe("! working")
154+
})
155+
156+
test("a stall already older than the burst never blinks at all", () => {
157+
// A resumed session inherits stale activity; bursting at it would alarm
158+
// the operator about silence they were not present for.
159+
const resumed = STALL_BLINK_BURST_MS * 4
160+
for (const nowMs of [0, 225, 450, 675]) {
161+
expect(
162+
lockupText(lockupCells(live(nowMs, "working", "stalled", resumed))),
163+
).toBe("! working")
164+
}
165+
})
166+
167+
test("the stalled word stays legible — only the cell blinks", () => {
168+
for (const nowMs of [0, STALL_BLINK_CYCLE_MS / 2]) {
169+
expect(
170+
lockupText(lockupCells(live(nowMs, "bash", "stalled", 0))),
171+
).toContain("bash")
172+
}
173+
})
174+
175+
test("working, blocked and stalled all read apart with no colour at all", () => {
176+
const glyph = (rampPhase: RampPhase, stalledForMs: number | null) =>
177+
lockupText(lockupCells(live(0, "working", rampPhase, stalledForMs)))[0]
178+
expect(new Set([glyph("blocked", null), glyph("stalled", 0)]).size).toBe(2)
179+
// Working sweeps the density glyphs; neither of the other two is one.
180+
const workingGlyphs = new Set(
181+
[0, 300, 600, 900].map(
182+
(nowMs) =>
183+
lockupText(lockupCells(live(nowMs, "working", "working", null)))[0],
184+
),
185+
)
186+
expect(workingGlyphs.has(glyph("blocked", null))).toBe(false)
187+
})
188+
189+
test("the slot's width never changes across a blink", () => {
190+
// A wide (CJK) and an astral label: the reservation is measured in columns
191+
// and the blink must not move it, whatever the label is made of.
192+
for (const label of ["読み込み中", "a😀b", "working"]) {
193+
const on = lockupWidth(live(0, label, "stalled", 0))
194+
const off = lockupWidth(
195+
live(STALL_BLINK_CYCLE_MS / 2, label, "stalled", 0),
196+
)
197+
expect(off).toBe(on)
198+
}
199+
})
200+
})

0 commit comments

Comments
 (0)