feat(tui): tally the fleet in the session list title - #1066
Conversation
The operator's most frequent question about a fleet is "which of these is waiting on me?" Per-row markers only answer it for rows currently on screen, so a fleet longer than the pane — or a scrolled list — had no answer at all, even though the count is exactly what decides whether the operator needs to look. The session list title now trails a tally of its own rows: how many are working, how many want the operator, how many broke. Glyphs are distinct so the tally survives a terminal that drops color, and the wants-you entry takes the attention marker's hue so the count and the dots it counts read as one signal. Hovering an entry names it in words, which is the only place the vocabulary is spelled out. "Waiting on you" is the sticky attention marker (spec 0054), not the at-a-prompt run state. `AwaitingInput` is where sessions rest — a healthy fleet is mostly at prompts — so treating it as blocked would flag everything and carry no information. It keeps `Running`'s glyph and color and is not tallied; only the attention marker means a session wants the operator. Empty buckets render nothing, buckets are disjoint and ranked, archived and orchestrator rows are excluded so the numbers can never disagree with the list beneath them, and the whole tally yields rather than collide with the mode toggle on a narrow pane. Adds spec 0169.
70f6c94 to
53bafc8
Compare
Before / after — the session list titleSame fleet on both sides: two sessions mid-loop, three that finished while
The three flagged rows each carry a blue Recordings and extracted midpoint frames are local — drag them in here: (full paths under |
The tally's wants-you entry wore a bang while the rows it counts wear a blue dot. That reads as a fourth mark to learn rather than as the total of the dots below it, so make the two one glyph and one hue, shared through a single constant. The cost is that working and wants-you are then told apart only by color. Accepted: the two buckets that collide are both "alive and fine", and failure — the reading that has to survive a terminal with no color at all — keeps its own glyph. Hover text names each bucket in words. That makes hue load-bearing, so dim the unfocused tally by attribute rather than shifting it toward gray: desaturating would collapse the one channel separating two of the three buckets.
The tally names a problem it cannot help you solve. With thirteen sessions in one group and the flagged one below the fold, `1●` in the title sends the operator hunting by hand for a dot the list never scrolls to — selection does not move the viewport, only the wheel and the scrollbar do. Hovering a tally now opens a panel listing exactly that bucket's rows, and clicking one selects it, focuses the list, and scrolls it into view. The panel lingers 500ms after the pointer leaves and holds while the pointer is over it, so the trip from tally to row is actually survivable. Clicking the tally pins it open until Esc or a click away — hover cannot be the only door when terminals that never report mouse motion exist. Also fixes a conformance bug the panel made visible: `fleet_status_counts` skipped archived and orchestrator sessions, but the list additionally filters to `SessionKind::User`, so a running `UsageProbe` inflated the working tally with a row that does not exist. Counting is now derived from the rendered list items, which inherits that filter plus both rollup computations, so a collapsed subtree or group counts its one visible marker once — and every counted entry is a row the panel can take you to. Along the way: the tally hit zones were cleared inside `render_sessions`, which a collapsed sidebar and the zoomed view never reach, leaving last frame's zones live at coordinates now owned by another surface. Harmless for a tooltip, a phantom button once it pins a panel. Clearing moved to the per-frame block that runs on every path. The zones were also one cell wide too far, covering the separator space after each entry.
After — the tally opens into the rows it counted (
|
| t | beat | frame |
|---|---|---|
| ~6s | hover the working tally → panel opens listing all 8, overhanging into the view pane | panel-01-hover-working.png |
| ~10s | pointer walks the rows; each highlights under it | |
| ~14s | click a row → list focuses, that session is selected, row highlights | panel-02-row-clicked.png |
| ~19s | click the needs-you tally → panel pins open, titled 2 sessions need you, both flagged sessions listed |
panel-03-pinned-needs-you.png |
| ~25s | click a listed row → panel closes, that session is selected — and because selecting it clears its attention marker, the tally drops 2● → 1● while the still-flagged row keeps its dot |
panel-04-selected-and-cleared.png |
┌ + sessions 8● 2● ────── compact ⇄ « ● Repeated Edit Message Loop ───
● Repeated Ed┌ 2 sessions need you ─────┐ src/main.rs Running tests
● Loop Printi│Repeated Edit Message Loop│ src/main.rs Running tests
● sbece8e563 │Loop Printing Editing Mess│ src/main.rs Running tests
● sa66dcac25 └──────────────────────────┘ src/main.rs Running tests
✳ Infinite Print Loop Status shell Editing src/main.rs Running tests
The last beat is the one the panel exists for: 2● named two sessions the
operator could not otherwise find, and one click both took them there and
retired the count.
How this was recorded
vhs has no mouse verb — its tape vocabulary is keys only — so a tape cannot
drive hover or click, and a hover-opened panel is unrecordable the normal
way. The TUI therefore runs inside tmux and the tape merely attaches to
it, while a background driver injects real SGR mouse sequences with
tmux send-keys -H. That writes the bytes in one shot, so crossterm's
parser sees a complete ESC [ < Cb ; col ; row M rather than a bare Esc.
The driver locates the tally and the panel border by capturing the pane and
searching for them, rather than hard-coding columns — the tally shifts with
digit count and the panel is sized to its content.
No "before" side: hovering a tally previously produced a one-line tooltip
naming the bucket and nothing else, and there was no panel, no rows, and
nothing clickable to compare against.
Artifacts (local — drag them in here)
scratchpad/fleet-panel.mp4
scratchpad/panel-01-hover-working.png
scratchpad/panel-02-row-clicked.png
scratchpad/panel-03-pinned-needs-you.png
scratchpad/panel-04-selected-and-cleared.png
(full paths under
/private/tmp/claude-501/-Users-moon/d91bd660-bf51-4d71-9d59-a8eebe117cfa/)
The driver script that produced it is scratchpad/record-panel.sh; a
second script, scratchpad/live-panel-check.sh, drives the same mouse
choreography against a 24-row pane and dumps text captures at each step —
that one is what confirmed the 500ms linger expires on its own and that
click-to-pin works where motion is never reported.
The wants-you bucket is a marker, not a state: it collects sessions stopped at a prompt beside sessions that finished beside group rows standing in for flagged members. The panel printed all of them as bare names, so the one bucket whose members differ was the one bucket the operator could not read. Each row now carries the mark its list row carries — static, never the working spinner, since a column of animating dots inside a floating box competes with the list it points at. Group rows wear their disclosure triangle instead of a state glyph: that row is a way in, not a destination.
Follow-up — every panel row now wears its own mark (
|
| t | beat | frame |
|---|---|---|
| ~12s | working panel — 8 rows, all ●; the hovered row highlights as one band, mark included |
glyph-01-working-panel.png |
| ~20s | needs-you panel pinned — ✓ above ● in the same bucket |
glyph-02-needs-you-mixed.png |
| ~22s | clicking the ✓ row selects it and retires the count, 2● → 1● |
glyph-03-selected-and-cleared.png |
Two deliberate choices:
- Static marks, never the working spinner. A column of animating dots
inside a floating box competes with the list it is pointing at. - Group rows wear their disclosure triangle, not a state glyph. A group
has no run state, and the triangle is the warning that the row is a way in
rather than a destination. It's borrowed from the list rather than minted
for the panel — the panel draws no distinction the list doesn't draw,
which is also why a rolled-up marker and a direct one still look alike.
Spec 0169 gains the row-mark contract, a consequence separating "the wording
tells buckets apart / the marks tell a bucket's members apart", and the
rule that panel marks are the list's marks.
Tests: one asserting a needs-you panel holding an AwaitingInput and a
Done session paints ●/success and ✓/info respectively (reading the
cells out of the rendered buffer, not assuming row order); one asserting a
collapsed group row wears ▶ and that it collides with no session state's
glyph. Full suite: 1275 passed.
Artifacts (local — drag them in here)
scratchpad/fleet-panel-glyphs.mp4
scratchpad/glyph-01-working-panel.png
scratchpad/glyph-02-needs-you-mixed.png
scratchpad/glyph-03-selected-and-cleared.png
(full paths under
/private/tmp/claude-501/-Users-moon/d91bd660-bf51-4d71-9d59-a8eebe117cfa/)
The mark sat flush against the left border and read as part of the frame rather than as the first column of content, and long labels ran into the right border the same way. The padding insets the text, not the highlight band: the band still spans the full inner width, so a hovered row stays one unbroken bar instead of losing a cell at each end.
Padding pass (
|
…eet-counter # Conflicts: # crates/cli/src/app.rs
What
The session list title now trails a tally of its own rows — how many
sessions are working, how many want the operator, how many broke.
Each entry opens into the rows it counted — a panel listing that bucket's
sessions, where clicking one selects it and scrolls the list to it.
Why
The operator's most frequent question about a fleet is which of these is
waiting on me? Per-row markers only answer it for rows currently on
screen, so a fleet longer than the pane — or a scrolled list — had no
answer at all, even though the count is exactly what decides whether the
operator needs to look.
"Waiting on you" is the attention marker, not
AwaitingInputAn earlier revision of this PR gave
AwaitingInputits own?glyph andamber color, on the theory that it was the operator-blocked state. It is
not — it is where sessions rest. A healthy fleet is mostly sitting at
prompts, so that rendering flagged nearly everything, and a signal that
fires on the resting state carries no information.
That revision is fully reverted here:
AwaitingInputkeepsRunning'sglyph and color in the TUI, the web client, and the protocol glyph table,
and is not tallied. The sticky attention marker (spec 0054) already
encodes the real predicate — stopped, after activity the operator hasn't
seen — and clears when the operator actually looks. That is what the
wants-you bucket counts.
The wants-you tally wears the marker itself
Not a glyph of its own — the same blue dot the flagged rows carry, shared
through one constant so the two cannot drift apart. A summary that counts
those dots while wearing a different mark reads as a fourth thing to
learn rather than as their total.
That leaves working and wants-you separated only by hue, which is a real
cost and a deliberate one: the collision falls between two buckets that
are both "alive and fine", while failure — the reading that has to
survive a terminal with no color at all — keeps its own glyph. The panel's
title carries the distinction when color cannot, which makes it
load-bearing rather than a convenience.
It also makes hue load-bearing, so the unfocused tally dims by
attribute rather than by shifting toward gray. Desaturating would
collapse the one channel separating two of the three buckets — the tally
fades out of the way of the focused pane but stays answerable from the
corner of the eye.
The panel
A count that names a problem the operator then has to hunt for is half a
signal. This was the concrete failure: with thirteen sessions in one group
and the flagged one below the fold,
1●sent the operator looking by handfor a dot the list never scrolls to — selection doesn't move the viewport,
only the wheel and the scrollbar do.
pointer leaves and holds open while the pointer is over it, so a listed
row is actually reachable.
the only door — terminals that never report mouse motion exist, and there
a hover-only panel is permanently invisible.
row into view. The panel closes and hands off; it isn't a second place to
work. Long buckets cap with a dim
+N morerather than scrolling, sincethe wheel over a floating index would fight the list underneath it.
Fixed: the tally counted rows the list never renders
fleet_status_countsskipped archived and orchestrator sessions, butlist_itemsadditionally filters toSessionKind::User— so a runningUsageProbe, daemon-internal and hidden from every list by design,inflated the working tally with a row that does not exist. That directly
contradicted the spec's own first rule.
Counting now derives from the rendered list items, which inherits that
filter, the orchestrator drop, and both rollup computations for free. A
collapsed subtree or group counts its one visible marker once, and lists
the visible ancestor — clicking it lands where the operator can expand and
drill in, instead of offering rows scroll-into-view can't reach.
Two smaller things found on the way:
render_sessions, which acollapsed sidebar and the zoomed view never reach — leaving last frame's
zones live at coordinates now owned by another surface. Harmless for a
tooltip; a phantom button once it pins a panel. Clearing moved to the
per-frame block that runs on every path.
after each entry. Caught by the render test, fixed in the geometry.
Rules the tally obeys
subtree's or group's rolled-up marker counts once, as itself, so the
numbers can never disagree with the marks beneath them. Archived rows are
the one deliberate exclusion — counted out even while the drawer is open,
so the tally doesn't flicker on a disclosure toggle.
working. A crash the operator hasn't seen is counted once, as a crash.
mode toggle on a narrow pane.
Spec
Adds
specs/0169-session-list-fleet-tally.md, amended in the second commitrather than forked: the membership rule now states the unit is the rendered
row, "hovering names it in words" becomes the panel contract, and the
Consequences record that a session both crashed and unseen paints two marks
while counting once — so visible dots can outnumber the wants-you tally.
(Numbered 0169, not 0168 —
0168-homebrew-distribution.mdlanded on mainwhile this was open.)
Tests
fleet_counts_skip_archived_and_the_orchestratorfleet_counts_read_attention_not_awaiting_inputfleet_counts_bucket_each_session_oncewants_you_tally_wears_the_attention_marker— pins the shared glyph andhue, so a change to one side of the equivalence fails the build.
errored_tally_stays_distinct_from_the_dotsfleet_tally_tooltips_read_naturallyfleet_tally_hover_names_the_bucket_it_sits_on— renders for real,reads the registered hit zone back, then asserts every cell it covers is
painted the bucket's color. Symbols alone prove nothing now that
working and wants-you both spell
1●, so a zone that drifted one tallyto the left would read identically; color is what catches it. Then
hovers and asserts the tooltip paints.
unfocused_tally_dims_but_keeps_its_bucket_hue— assertsDIMappearswhen focus leaves the list and that
fgis unchanged.running_and_awaiting_share_a_dot+distinct_readings_have_distinct_glyphsin the protocol glyph table, and the web-client smoke test asserts
awaiting_inputrenders●.Panel and interaction, added in the second commit:
fleet_tally_panel_lingers_half_a_second_after_the_pointer_leaves— pureupdate_fleet_tally_panel(now)with an injected clock, so expiry istested without sleeping.
fleet_tally_panel_is_held_open_while_the_pointer_is_over_itclicking_a_fleet_tally_pins_its_panel_and_clicking_again_closes_it—the toggle-off ordering hazard: dismissing on the anchor and falling
through lets the list's own click handler re-pin it immediately.
escape_unpins_the_fleet_tally_panel— gated on pinned, so a panelthat merely followed the pointer can't steal Esc from a focused PTY.
clicking_a_fleet_panel_row_selects_it_and_focuses_the_lista_fleet_panel_row_click_does_not_leak_to_the_list_row_underneath— thepanel is content-sized and overhangs into the view pane, so it has to
claim its cells before any pane branch sees them.
clicking_a_fleet_panel_row_scrolls_it_into_viewfleet_tally_excludes_sessions_the_list_never_renders— theUsageProberegression above.
fleet_counts_a_collapsed_subtree_rollup_once,fleet_counts_a_collapsed_group_header_rollupfleet_tally_hits_are_cleared_when_the_list_is_not_renderedfleet_panel_closes_when_its_bucket_emptiesfleet_panel_caps_a_long_bucket_with_a_more_row,fleet_panel_fits_a_ten_row_terminallist_scroll_offset_for_visible_{puts_a_full_mode_card_fully_on_screen, never_exceeds_list_max_scroll, leaves_an_already_visible_row_alone}—the middle one asserts the clamp-survival invariant as a property rather
than trusting the argument for it.
cargo test -p construct-cli: 1273 passed, 0 failed, including the sixpty_renderperf-budget tests CI skips.Binaries
Only
crates/cli,crates/protocol, and the web client'sindex.htmlare touched → the relevant binary is
construct:/Users/moon/agentd/.claude/worktrees/feat-status-glyphs-fleet-counter/target/debug/construct