Line 2 of a session card spelled out its blocked work ("1 uncommitted
file") and its command ("claude"). Both now render as marks: the
breakdown as diff notation, and the command's leading program as its
tool's icon in the web client.
The probe carries the line delta of the tracked changes — staged and
unstaged together, against HEAD — so `git add` never makes the counter
drop. Untracked files stay a file count; counting their lines means
reading every one off disk. A tracked change with no line delta falls
back to `~N` rather than claiming `+0 -0`.
The TUI keeps its command as text: a terminal cannot be assumed to have
a font carrying tool glyphs.
9.2 KiB
SIDEBAR.md — the session card contract
One rule for how a session is rendered as a card in the sidebar, so the web sidebar (#107) and the TUI sidebar (#108) describe a session the same way. Read this before changing either. Both clients implement exactly what is written here; if it needs to change, change it here first.
A card is always two lines: line 1 and line 2.
Line 1 — name
- Left: the session name —
metadata.nameif present, else the command line (same fallback both clients already use). - In the web client, spawn similar (
⧉) and kill (✕) remain buttons anchored on the right. The TUI omits those glyphs because its actions are keyboard-driven and the icons do not identify their keys. - The name truncates with
…at the available width. In the web client it must stop before the action buttons; in the TUI it may use the full row.
Line 1 carries no badges. The ⚠ blocked work badge (#83) and the exited
badge move off this row into line 2.
The name is editable in place (#125). In the web client, right-clicking a card
opens a context menu whose rename entry swaps the name for a text field —
Enter commits, Escape cancels. The TUI opens the same edit as a prompt, bound
to r in the sidebar by default. Both send PATCH /api/sessions/:id; both
start from the session's own name, never from the command it falls back to,
and an empty field clears the name so the fallback resumes.
Line 2 — meta line (adaptive)
Line 2 always renders — never an empty placeholder row either, since its unconditional fragments (see below) always have something to show. Its fragments are adaptive: some show only when their trigger holds. The line itself is not.
Line 2 reads left to right:
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
Its minimum content is therefore the dot and the command.
<dot> is a single filled glyph (● on both clients) carrying the activity
state's colour, and <duration> follows in the same colour when shown. There
is no state word and no second badge: the dot exists so the state is still
legible when a row is dimmed or the terminal renders the duration in the
default fg. The dot is an unconditional part of line 2; the bracketed
fragments appear only when their trigger holds.
<duration> renders only once the session has been held in its current
activity state longer than 30 seconds (activity_since from #102) — under
that threshold the duration is absent and the dot renders alone. A payload
that predates #102 (no usable activity_since) likewise contributes no
duration — the dot still renders. This rule now governs only the duration
fragment; it no longer decides whether line 2 exists.
<detail> is an agent-reported string (#105) — e.g. why a session is in
Error. It sits right after the duration, in the muted style, and shows only
while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it. The detail
truncates with … ahead of the trailing fragments — a long detail never
pushes <blocked work>, exited, or <command> off the line, and neither
client ever lets it wrap.
<diff stat> is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out ⚠ 3 uncommitted files, 2 unpushed commits. It renders only
while something would block an unforced kill, and reads:
+<insertions> -<deletions> ?<untracked files> ↑<unpushed commits>
Each fragment is omitted when its count is zero. The line counts cover
tracked changes only — staged and unstaged together, measured against
HEAD — so a git add never makes the counter drop. Untracked files are
counted as files, not lines: counting their lines means reading every one off
disk, and ?3 already says what is there. A tree whose only tracked change
carries no line delta — a mode change, a rename — shows ~<changed files>
instead of the additions and deletions, since +0 -0 would describe it
wrongly.
Only + and - take colour (--live / --danger in the web client, the
surface's active / error roles in the TUI); ~, ? and ↑ stay muted, so
the fragment reads as a diff rather than as an alarm. The spelled-out phrasing
of maestro_protocol::InTheWay's Display does not disappear: it is the web
card's tooltip and the wording of the kill refusal, so no two clients describe
the same tree differently.
<command> is unconditional and muted: the session's command line, shown on
every card in both clients — no omission in the TUI, no name-vs-command
comparison gating it. A session named by its command line then shows the same
string on both lines; that is accepted, the price of a row that never changes
height.
In the web client the command's leading program is replaced by that tool's
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the
command line follow as text. Recognition looks through a path, leading
FOO=bar assignments and wrappers (sudo, env, npx, pnpm dlx, …); an
unrecognised program keeps its full text, so the card never shows a mark
standing for the wrong tool. The full command line stays in the card's tooltip
either way. The TUI keeps the command as text: a terminal cannot be assumed
to have a font carrying tool glyphs.
Colour per activity state
The state word is never spelled out — the colour carries it.
| Activity state | Web token | TUI palette role |
|---|---|---|
Active |
--live |
active |
Idle |
--muted |
idle |
AwaitingInput |
--warn |
awaiting |
Error |
--danger |
error |
Web tokens resolve per theme automatically (styles.css defines dark, light,
and prefers-color-scheme variants for each). The TUI names roles, not
colours: a role's value comes from the active palette
(maestro-tui/src/palette.rs), never from a colour spelled out at the use
site.
A palette holds one role set per surface — the card surface every card sits on, and the selection surface the highlighted card swaps in — so each state role resolves to the value picked for the surface it is painted on. The highlighted card therefore takes its state colours from the selection surface; a colour chosen for the card background is never painted onto the selection background. Contrast is a property of the palette (values are RGB) and is asserted in the palette's tests, not left to the terminal theme.
The duration text inherits its state's colour; the exited and command
fragments use the surface's muted role, as do the diff stat's ~, ? and
↑ fragments. Only + and - step outside the surface's muted role, and they
take the active and error roles of the same surface.
Duration format
Time spent in the current activity state, sourced from activity_since
(#102) — not time since spawn, and not time since last PTY output
(last_activity_at). Both clients compute it identically:
< 60s→ whole seconds, floored:30s< 60m→ whole minutes, floored:4m≥ 60m→ whole hours, floored:2h
No days unit, no seconds inside minutes, no rounding up. Floor everywhere so the counter never shows a duration the state hasn't actually reached yet. Clock granularity: re-render at least once every 30s while visible so the cutover points land within one tick.
Precedence
An agent-reported ActivityState (#105) overrides the daemon's inference for
five minutes (the TTL is fixed by the daemon; re-reporting extends it).
When the TTL expires, inference resumes and the session's reported detail —
if any — clears with the report. Clients never blend the two: whichever source
is in force supplies both the colour and the clock for activity_since.
Two rulings specific to a reported Error (#114):
- The five-minute TTL applies to
Errorlike to any other state. A session that failed is still failed after five minutes, but a card pinned red forever lies once the session moves on; when the report lapses the card returns to the daemon's own inference — usuallyIdlefor a session that has gone quiet. - Only a new report clears an
Errorbefore its TTL. The nextActivityStateof any kind replaces it (with #106's hook that is typicallyPreToolUse→Activewhen the user prompts again). PTY output and process changes do not clear it — only the agent itself may withdraw what the agent itself reported. Re-reportingErrorextends the TTL rather than stacking reports.
If a client receives an ActivityState variant it does not know, it must not
fail rendering: treat it as Idle (neutral/dim, line-2 rules apply as usual)
until the daemon sends a variant it does know. Rust clients deserialize
activity leniently for the same reason.
Leniency runs one way only. It covers a client reading a newer daemon, where
dropping the whole session would be worse than dimming one card. A report
arriving at POST /api/sessions/:id/activity is parsed strictly and an unknown
state is a 4xx: an agent posting a state the daemon does not know is a bug in
the agent, and answering 204 would hide it.