Skip to content

feat(tui): tally the fleet in the session list title - #1066

Merged
edwin-zvs merged 6 commits into
mainfrom
feat-status-glyphs-fleet-counter
Aug 1, 2026
Merged

feat(tui): tally the fleet in the session list title#1066
edwin-zvs merged 6 commits into
mainfrom
feat-status-glyphs-fleet-counter

Conversation

@edwin-zvs

@edwin-zvs edwin-zvs commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

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.

 + sessions 2● 3● 1✗ ──────────── compact ⇄  «

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.

┌ + sessions 7● 1● ────── compact ⇄  « ┐┌ no session ────────────
   ● busy 4  ┌ 7 sessions working ──┐ll
   ● busy 5  │busy 4                │ll
   ● busy 3  │busy 5                │ll
   ● busy 1  │busy 3                │ll
   ● busy 2  │busy 1                │ll
   ● busy 0  │busy 2                │ll
   ● ask ●   │busy 0                │ll
   ● run     │run                   │ll     Welcome to construct
             └──────────────────────┘

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 AwaitingInput

An earlier revision of this PR gave AwaitingInput its own ? glyph and
amber 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: AwaitingInput keeps Running's
glyph 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 hand
for a dot the list never scrolls to — selection doesn't move the viewport,
only the wheel and the scrollbar do.

  • Hover opens it, and it survives the trip: it lingers 500ms after the
    pointer leaves and holds open while the pointer is over it, so a listed
    row is actually reachable.
  • Clicking the tally pins it until Esc or a click away. Hover can't be
    the only door — terminals that never report mouse motion exist, and there
    a hover-only panel is permanently invisible.
  • Clicking a row selects that session, focuses the list, and scrolls the
    row into view. The panel closes and hands off; it isn't a second place to
    work. Long buckets cap with a dim +N more rather than scrolling, since
    the wheel over a floating index would fight the list underneath it.

Fixed: the tally counted rows the list never renders

fleet_status_counts skipped archived and orchestrator sessions, but
list_items additionally filters to SessionKind::User — so a running
UsageProbe, 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:

  • The tally's 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.
  • Those zones were also one cell too wide, covering the separator space
    after each entry. Caught by the render test, fixed in the geometry.

Rules the tally obeys

  • Counts what the list shows. The unit is the rendered row: a collapsed
    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.
  • Buckets are disjoint and ranked — errored, then wants-you, then
    working. A crash the operator hasn't seen is counted once, as a crash.
  • Zero is silent, and the tally yields rather than collide with the
    mode toggle on a narrow pane.
  • A scan list, not a census. It will not sum to the fleet size.

Spec

Adds specs/0169-session-list-fleet-tally.md, amended in the second commit
rather 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.md landed on main
while this was open.)

Tests

  • fleet_counts_skip_archived_and_the_orchestrator
  • fleet_counts_read_attention_not_awaiting_input
  • fleet_counts_bucket_each_session_once
  • wants_you_tally_wears_the_attention_marker — pins the shared glyph and
    hue, so a change to one side of the equivalence fails the build.
  • errored_tally_stays_distinct_from_the_dots
  • fleet_tally_tooltips_read_naturally
  • fleet_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 tally
    to 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 — asserts DIM appears
    when focus leaves the list and that fg is unchanged.
  • running_and_awaiting_share_a_dot + distinct_readings_have_distinct_glyphs
    in the protocol glyph table, and the web-client smoke test asserts
    awaiting_input renders .

Panel and interaction, added in the second commit:

  • fleet_tally_panel_lingers_half_a_second_after_the_pointer_leaves — pure
    update_fleet_tally_panel(now) with an injected clock, so expiry is
    tested without sleeping.
  • fleet_tally_panel_is_held_open_while_the_pointer_is_over_it
  • clicking_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 panel
    that merely followed the pointer can't steal Esc from a focused PTY.
  • clicking_a_fleet_panel_row_selects_it_and_focuses_the_list
  • a_fleet_panel_row_click_does_not_leak_to_the_list_row_underneath — the
    panel 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_view
  • fleet_tally_excludes_sessions_the_list_never_renders — the UsageProbe
    regression above.
  • fleet_counts_a_collapsed_subtree_rollup_once,
    fleet_counts_a_collapsed_group_header_rollup
  • fleet_tally_hits_are_cleared_when_the_list_is_not_rendered
  • fleet_panel_closes_when_its_bucket_empties
  • fleet_panel_caps_a_long_bucket_with_a_more_row,
    fleet_panel_fits_a_ten_row_terminal
  • list_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 six
pty_render perf-budget tests CI skips.

Binaries

Only crates/cli, crates/protocol, and the web client's index.html
are touched → the relevant binary is construct:

/Users/moon/agentd/.claude/worktrees/feat-status-glyphs-fleet-counter/target/debug/construct

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.
@edwin-zvs
edwin-zvs force-pushed the feat-status-glyphs-fleet-counter branch from 70f6c94 to 53bafc8 Compare August 1, 2026 06:03
@edwin-zvs edwin-zvs changed the title feat(tui): give every run state its own glyph, tally the fleet in the list title feat(tui): tally the fleet in the session list title Aug 1, 2026
@edwin-zvs

edwin-zvs commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Before / after — the session list title

Same fleet on both sides: two sessions mid-loop, three that finished while
unfocused (so they carry the sticky attention marker), one killed, one
parked at a prompt and never given work.

session list title
before (4ba5d45) + sessions ──────────── compact ⇄ «
after (12035de) + sessions 2● 3● 1✗ ──── compact ⇄ «

The three flagged rows each carry a blue after their title; the
title's 3● is the same glyph in the same hue, and is exactly their
count. 2● (working) is green, 1✗ (errored) is red.

Recordings and extracted midpoint frames are local — drag them in here:

scratchpad/status-before.mp4   scratchpad/before-mid.png
scratchpad/status-after.mp4    scratchpad/after-mid.png

(full paths under
/private/tmp/claude-501/-Users-moon/d91bd660-bf51-4d71-9d59-a8eebe117cfa/)

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.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

After — the tally opens into the rows it counted (9d61432)

Twelve shell sessions in one group: eight looping forever, two that ran
briefly and fell back to their prompt while unfocused (so they raise the
sticky attention marker). Title reads + sessions 8● 2●.

The clip walks the four behaviours in order:

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.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Follow-up — every panel row now wears its own mark (e24365f)

Supersedes the clip above. Same choreography, one change: the listed rows
carry the glyph their list row carries.

The gap this closes only shows in one bucket. "Working" and "errored" are
homogeneous — the mark there just restates the title — but "wants you" is
a marker, not a state
. It collects sessions that stopped at a prompt
() alongside ones that ran to completion () alongside group rows
standing in for flagged members (). The panel printed all of them as bare
names, so the one bucket whose members actually differ was the one bucket
you couldn't read.

To make that visible the recording's two flagged sessions now end
differently — one falls back to its prompt (awaiting input), one exits
(done) — where the previous take had two of the same:

┌ + sessions 8● 2● ────── compact ⇄  «
   ✓ Print Editi┌ 2 sessions need you ───────┐
   ● Repeated Ed│✓ Print Editing Messages Loop│
   ● s68250efe3 │● Repeated Editing Message Lo│
   ● seb6fe8e9c └─────────────────────────────┘

One is finished work to read; one is a question to answer. Before this, both
were just a name.

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.
@edwin-zvs

Copy link
Copy Markdown
Contributor Author

Padding pass (1b85488) — supersedes the clip above

The body now holds a blank column against each border. Before, the mark
butted straight against the left border and read as part of the frame:

before                          after
┌ 2 sessions need you ────┐     ┌ 2 sessions need you ──────┐
│✓ Repeat Editing Message │     │ ✓ Repeat Editing Message  │
│● Print Editing Message L│     │ ● Print Editing Message L │
└─────────────────────────┘     └───────────────────────────┘

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 rather
than losing a cell at each end. Visible in
padded-01-working-panel.png, where the hovered row's highlight runs
border to border while its label sits inset.

The width already reserved these two cells; they were just both landing on
the right. Geometry now expresses the reservation in terms of the padding
constant so the two can't drift apart, and the test that reads a mark out of
the buffer derives its column from that same constant instead of hardcoding
it — a padding change moves the assertion with it rather than silently
reading a blank cell.

New test: fleet_panel_keeps_a_blank_margin_inside_its_border, checking the
margin on an unhovered row (where a bare space is the honest signal, since
the band deliberately covers those cells when hovered). Full suite: 1276
passed.

Artifacts (local — drag them in here)

scratchpad/fleet-panel-padded.mp4
scratchpad/padded-01-working-panel.png
scratchpad/padded-02-needs-you-mixed.png

(full paths under
/private/tmp/claude-501/-Users-moon/d91bd660-bf51-4d71-9d59-a8eebe117cfa/)

…eet-counter

# Conflicts:
#	crates/cli/src/app.rs
@edwin-zvs
edwin-zvs merged commit ef4d0c3 into main Aug 1, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the feat-status-glyphs-fleet-counter branch August 1, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant