# Conflicts: # crates/maestro-daemon/src/session.rs # crates/maestro-protocol/src/lib.rs # crates/maestro-protocol/src/routes.rs
277 lines
14 KiB
Markdown
277 lines
14 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.
|
|
|
|
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.
|
|
|
|
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 5 seconds**: 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 (`Ctrl+Shift+H`, and the
|
|
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
|
`ctrl-w h`.
|