10 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.
Keyboard walk
j/k (and the arrow keys) move a highlight through the sidebar. Both clients
follow one rule for what the walk stops on:
- Session cards, always.
- Folder headers, only while collapsed. An expanded folder's cards are one row away, so its header is nothing to land on; a collapsed folder's header is the only row it has, and skipping it would leave the folder unreachable.
Enter opens the highlighted session, or expands the highlighted folder and lands on the first row it was hiding. A highlight parked on a skipped row — reachable by clicking a folder header — still steps to its neighbour rather than jumping to the end of the list.
The web client needs a chord to reach the list at all (Ctrl+Shift+H, and the
same chord back): an attached terminal owns every plain keystroke. The TUI uses
ctrl-w h.