# 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.name` if 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. ### Where a name comes from (#131) `metadata.name_source` says whether the name is settled or provisional: | Source | Means | Rendered | |---|---|---| | `User` | Typed in the spawn form, or set by a rename. | Normal | | `Generated` | The daemon's own handle, assigned because the spawn carried no name. | Muted | | `Haiku` | Derived from the session's first prompt. | Normal | A missing or unrecognised source reads as `User`. That way a session named before the field existed is never dimmed, and never autonamed over. Only `Generated` renders muted, and it is the only source the daemon will replace: the first prompt an agent reports to `POST /api/sessions/:id/prompt` settles the session at `Haiku`. A session that never reports a prompt — a shell, or an agent with no hook installed — keeps its muted handle for as long as it lives. That is accurate, not a missing state: nothing has named it yet. 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: ``` [] [] [ | exited] ``` Its minimum content is therefore just the command. The activity state does not render as an inline glyph on line 2 — a **rail** carries it instead: a coloured bar in the gutter to the left of the card, the space nesting already reserves for indentation. The rail paints for every card, `Idle` included, and is independent of whatever line 2 has to say; a card with nothing else to report still shows its rail next to the bare command. `` follows in the rail's colour when shown. There is no state word and no second badge: the rail exists so the state is still legible when a row is dimmed or the terminal renders the duration in the default fg. In the **web client** the rail is a 5px bar pinned to the row's own left edge, square corners, spanning the full card height edge to edge — it shifts outward with nesting depth along with the row's own padding, since it is positioned relative to the row. It carries motion only for states that need attention (DESIGN.md's "living signals" rule): a hard blink while `AwaitingInput`, a fast flash while `Error`. `Active` and `Idle` are both solid and still — colour alone (green vs. the idle grey) is the signal for `Active`, so it does not also pulse. `prefers-reduced-motion` leaves every state as a solid bar. In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on both the name row and the meta row, so the two lines read as one continuous bar. Folder headers gain a matching blank leading column so labels still align under session names. The TUI has no render cadence to animate against, so its rail is always static — colour is the whole signal there, as it always was. Either client's rail carries the accessibility duty the old inline dot did: it is the element whose `title`/`aria-label` names the state, so status still never relies on colour alone. `` 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 rail carries the state alone. A payload that predates #102 (no usable `activity_since`) likewise contributes no duration — the rail still renders. This rule now governs only the duration fragment; it no longer decides whether line 2 exists. `` 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 ``, ``, or `exited` off the line, and neither client ever lets it wrap. `` 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: ``` + - ? ↑ ``` 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 `~` 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. `` is unconditional and muted: it names the **program** the session runs — never its arguments. `claude --dangerously-skip-permissions` renders as `claude`. Flags say how a session was invoked, which is not what a card is for, and a long one crowds out every other fragment on the line. It shows on every card in both clients — no omission in the TUI, no name-vs-command comparison gating it. A session named by its program then shows the same string on both lines; that is accepted, the price of a row that never changes height. The program is found by looking through a path, leading `FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename: `/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers is shown as written rather than as an empty fragment. In the **web client** the 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 nothing follows it as text. An unrecognised program keeps its name as text, so the card never shows a mark standing for the wrong tool. The full command line, arguments and all, stays in the card's tooltip either way. The **TUI** keeps the program 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. ## Which row is filled Both clients say every one of these with a **background fill**, never an edge. A row is usually several of them at once, so they form a ladder: the tier furthest down wins the paint. | Tier | Web | TUI | |---|---|---| | Alive | 6% accent wash | — (the rail carries it) | | Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard | | Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) | | Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface | | Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) | The web ladder is written as one ordered run of equal-specificity rules in `styles.css`, every qualifier inside `:where()`, so the order they appear in *is* the precedence. Add a tier where it belongs in that run, never by piling on another class to out-weigh the rule above it. The TUI's two filled tiers both carry the **selection** surface's foregrounds, since those are the colours picked for a filled row. `pane_row_bg()` is blended from the two surfaces rather than shipped as a third one — a third surface would mean eight more colours hand-picked seventeen times — and the palette tests assert it stays a visible step from both. The cursor's fill wins on its own row, so a highlight parked on the session already in the pane is still visible. The one edge left in either list is the TUI's `▏` marker, which stands in for the cursor's fill while the pane holds the keyboard, and the web's ring on a highlighted **folder** row — a folder previews nothing, so it has no fill to step up to. 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 `Error` like 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 — usually `Idle` for a session that has gone quiet. - **Only a new report clears an `Error` before its TTL.** The next `ActivityState` of any kind replaces it (with #106's hook that is typically `PreToolUse` → `Active` when 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-reporting `Error` extends the TTL rather than stacking reports. - **Only Claude Code reports `Error`.** Codex has no counterpart to `StopFailure` in its hook event set, so a codex turn that ends on an API error reports `AwaitingInput` and its card carries no error mark. A card without the mark means "nothing reported a failure", not "nothing failed". 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. ## Focus clears `AwaitingInput` A session's pane — the pane a client's own attach view is showing, never a sidebar highlight — clears its `AwaitingInput` glow once it has been **continuously focused for 1 second**: the operator has looked, so the rail stops asking for attention and the card reads as `Idle` instead. This is a real state change, not a client-side paint-over — the daemon computes it, so every client watching the session (web, TUI, a second pane on the same session) sees the same colour. The acknowledgement is sticky: it survives losing focus, and holds until the session is genuinely `Active` again — new work, not merely a refocus. Only after that fresh `Active` cycle does the *next* `AwaitingInput` glow again. Unfocusing and refocusing alone never re-arms it. This applies uniformly to `AwaitingInput` from either source — the daemon's own inference or an agent's report (#105) — since the rail draws them identically. It does not touch `Error`; that state's own TTL (below) is unrelated and unaffected by focus. Each client reports its own pane's focus with `POST /api/sessions/:id/focus` (`{"focused": bool}`) whenever the pane it is showing changes — on attach, on switching which session a pane shows, and on leaving the pane. A client sends `false` for the session it just left before `true` for the session it lands on, so a click that swaps panes never briefly reports both focused. ## 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 (leader-`h` — `Ctrl+A h` by default — and the same chord back): an attached terminal owns every plain keystroke, and Escape is left to reach the pane's shell/app instead. The TUI uses `ctrl-w h`. Everything else is the same key on both clients: | key | action | |---------|---------------------------------------------| | `j`/`k` | move the highlight (arrows too) | | `Enter` | open the session, or expand the folder | | `Tab` | hand the keyboard to the pane | | `c` | collapse or expand the highlighted folder | | `r` | rename the highlighted session | | `x` | kill the highlighted session (confirms) | | `n` | open the spawn form | | `N` | spawn with every default resolved | | `?` | show the shortcut overlay | The TUI binds these through its keymap, so a user can rebind any of them; the web client's are fixed. A binding added to one client belongs in this table and in both help overlays. ## Killing a session **Every kill confirms first, in both clients** (#160). A kill is destructive and unattended agents live in these sessions, so neither client sends `DELETE /api/sessions/:id` off a single press. The TUI raises `OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its `✕` button and the command bar's action page both use. Cancel holds the focus in the web's dialog, so nothing kills on a stray Enter. The confirmation names what the card last saw in the way, when anything was — the daemon will refuse an unforced kill over such a worktree, and saying so before the attempt is cheaper than a refusal. What comes back from that refusal is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns what a forced kill leaves behind.