Escape unfocusing the pane made it impossible to send a literal Esc into the attached session. Removes pane.focusList and its Escape binding, and rebinds focus.toggle from ctrl-shift-h (claimed by the browser's History shortcut) to leader-h, consistent with the other leader escape hatches. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
347 lines
18 KiB
Markdown
347 lines
18 KiB
Markdown
# 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:
|
|
|
|
```
|
|
[<duration>] [<detail>] <command> [<diff stat> | 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. `<duration>` 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.
|
|
|
|
`<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 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.
|
|
|
|
`<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 `<command>`, `<blocked work>`, or `exited` 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: 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.
|