Compare commits

...
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 d032036542 feat(maestro): move worktree choice behind advanced options
ci / flake (push) Successful in 5m12s
ci / rust (push) Successful in 2m41s
ci / web (push) Successful in 35s
The worktree step ("+ new" plus the repository's existing worktrees) now
lives inside the collapsed "Advanced options" section in both the web form
and the TUI draft pane, so a plain spawn is command, project, name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 08:42:00 +01:00
Miguel PalhasandClaude Opus 5 43b90ccf0a feat(maestro): rename a session from the web sidebar
ci / web (push) Successful in 31s
ci / rust (push) Successful in 5m26s
ci / flake (push) Successful in 8m13s
Right-clicking a card opens a custom context menu (rename, spawn similar,
kill). Rename swaps the name for an inline field; Enter commits, Escape and
blur cancel. The tree stops rebuilding while the field is open, so a catalog
delta cannot replace what is being typed into.

Refs #125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:32:20 +01:00
Miguel PalhasandClaude Opus 5 bdeec6eff5 feat(maestro): rename the highlighted session from the TUI sidebar
`r` opens a prompt pre-filled with the session's own name — empty when it
has none, so Enter cannot pin the command as the name. Enter saves, Escape
cancels, and an emptied field clears the name.

Refs #125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:29:05 +01:00
Miguel PalhasandClaude Opus 5 345650172b feat(maestro): rename a session over PATCH /api/sessions/:id
The daemon trims and caps the name, persists it to the sidecar under the
sessions lock, and broadcasts SessionUpdated. A blank name clears
metadata.name so the card falls back to the command, matching the rule the
spawn form already uses.

Refs #125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:26:12 +01:00
Miguel Palhas 999dbe3ea3 Merge remote-tracking branch 'origin/main' into themes
ci / web (push) Successful in 1m27s
ci / rust (push) Successful in 5m4s
ci / flake (push) Successful in 6m57s
2026-08-25 18:46:35 +01:00
Miguel Palhas c9d089534b enable session scrolling across clients
ci / web (push) Successful in 59s
ci / rust (push) Successful in 6m8s
ci / flake (push) Successful in 9m52s
2026-08-25 18:46:26 +01:00
Miguel PalhasandClaude Fable 5 573a2b4a07 feat(maestro): add nine editor-theme TUI palettes
Solarized (dark/light), Catppuccin Mocha, GitHub (dark/light),
Tokyo Night, Gruvbox, Nord, Dracula. Colours auto-tuned from the
canonical seeds to clear the palette contrast floors; tests now
assert every RGB preset, not just dark/light.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:44:12 +01:00
Miguel Palhas 9ef3547a00 refine TUI navigation and chrome
ci / web (push) Successful in 30s
ci / rust (push) Successful in 4m41s
ci / flake (push) Successful in 7m13s
2026-08-25 15:10:47 +01:00
Miguel Palhas 2ab07c2343 fix(maestro): harden grouped sidebar interactions
ci / web (push) Successful in 1m29s
ci / rust (push) Successful in 5m25s
ci / flake (push) Successful in 7m56s
2026-08-25 13:59:38 +01:00
Miguel Palhas 342e732ef0 merge: TUI shell/124 — collapsible project groups 2026-08-25 13:54:09 +01:00
Miguel Palhas 3205270b51 feat(maestro): group TUI sidebar by project 2026-08-25 13:53:51 +01:00
Miguel Palhas d8e31f2760 fix(maestro): resize PTY to bordered pane content 2026-08-25 13:37:30 +01:00
Miguel Palhas fdf9ea1b31 merge: TUI shell/123 — unmistakable panel focus 2026-08-25 13:31:24 +01:00
Miguel Palhas 22cbfbc9bc merge: TUI shell/122 — configurable theme presets 2026-08-25 13:31:24 +01:00
Miguel Palhas 8157793282 feat(maestro): add TUI theme presets 2026-08-25 13:30:59 +01:00
Miguel Palhas 839dd27925 feat(maestro): make TUI panel focus unmistakable 2026-08-25 13:26:54 +01:00
Miguel Palhas a1816a0838 fix(maestro): preserve fixed card trailing fragments 2026-08-25 13:18:24 +01:00
Miguel Palhas e01b0616b1 merge: TUI shell/121 — TUI cards always render line two 2026-08-25 13:11:47 +01:00
Miguel Palhas 74d8d3c37a merge: TUI shell/120 — web cards always render line two 2026-08-25 13:11:47 +01:00
Miguel Palhas cc9b027bd1 feat(maestro): make TUI session cards two lines 2026-08-25 13:11:06 +01:00
Miguel Palhas 10a25e5b83 feat(maestro): keep web session cards two lines 2026-08-25 13:06:23 +01:00
Miguel Palhas 4c3f65027d fix(maestro): make TUI chrome contrast palette-controlled 2026-08-25 13:02:48 +01:00
Miguel Palhas 76eb3d1386 merge: TUI shell/119 — bare maestro opens the TUI 2026-08-25 12:55:25 +01:00
Miguel Palhas 877f409194 merge: TUI shell/117 — shared path-abbreviation helpers 2026-08-25 12:55:25 +01:00
Miguel Palhas 39d4014dcb merge: TUI shell/118 — TUI colours behind named palette roles
ci / web (push) Successful in 54s
ci / flake (push) Successful in 8m33s
ci / rust (push) Successful in 8m57s
Closes #118
2026-08-25 12:35:38 +01:00
Miguel Palhas f02510465c docs(maestro): bare maestro opens the TUI 2026-08-25 12:34:36 +01:00
Miguel Palhas 8e251bf8be feat(maestro): default a missing subcommand to the TUI 2026-08-25 12:34:36 +01:00
Miguel Palhas bc95d16a98 docs(maestro): note PTY colours are passthrough, not palette roles 2026-08-25 12:31:46 +01:00
Miguel Palhas 975f3ef0c5 feat(maestro): move path-abbreviation helpers into maestro-protocol
Port abbreviateCwd, abbreviateFolderName, mergeLinear, and
sessionTreePath from web/src/{types,tree}.ts into a maestro-protocol
display module, so the TUI sidebar can group sessions with the same
labels the web UI uses (#117). abbreviate_folder_name returns the
collapsed stem and last segment separately instead of escaped HTML,
so a ratatui caller can style the stem without parsing markup.

maestro-cli's own abbreviate_dir moves into the new module; the CLI
and the worktrees list now call the shared implementation.

Closes #117
2026-08-25 12:31:43 +01:00
Miguel Palhas f0108c5645 Merge branch 'blitz/tui-shell' into issue/118-palette-roles 2026-08-25 12:31:17 +01:00
Miguel Palhas c534cee7cc feat(maestro): palette of named colour roles for the TUI
Every colour the TUI painted on its own chrome was a named ratatui
colour spelled out at the use site, so nothing could be themed and the
selected card had broken contrast: the highlighted card's black-on-cyan
base was patched with an activity colour chosen for the dark card
background, leaving AwaitingInput yellow on light blue.

Roles now live in `palette.rs`, named for meaning. A palette holds one
role set per surface — card and selection — plus the panel border in
its focused and unfocused forms, a focus accent, a group-row style for
the sidebar grouping, the status-bar mode indicators and error text.
Each surface carries its own activity colours, so the selected card's
dot keeps carrying state without wearing a colour picked for another
background.

Values are `Color::Rgb`, so contrast is asserted rather than left to
the terminal theme: every role clears WCAG AA on its own surface, and a
sidebar test fails if a card-surface state colour is painted onto the
selection background.

PTY cell colours stay a passthrough of what the program in the pane
emits — they are not chrome and not a palette role.
2026-08-25 12:31:14 +01:00
Miguel Palhas c2ac3f7eb6 merge: TUI shell/116 — line 2 is unconditional
ci / web (push) Successful in 28s
ci / rust (push) Successful in 6m44s
ci / flake (push) Successful in 8m9s
Closes #116
2026-08-25 12:26:24 +01:00
Miguel Palhas c118020b29 docs(maestro): make sidebar card line 2 unconditional
Line 2 was rendered only past a 30s activity hold, blocked work,
exit, or reported Error, and <command> was gated behind a
metadata.name comparison and TUI-optional. Both worked against
scanability: cards changed height as sessions aged, and fresh
sessions hid their command.

Card is now always two lines. The 30s hold rule survives, demoted
to gating only the <duration> fragment. <command> is unconditional
in both clients.

Closes #116
2026-08-25 12:24:56 +01:00
Miguel Palhas 7cb14c7aae chore: empty commit to test the nix-autodeploy webhook
ci / web (push) Successful in 36s
ci / rust (push) Successful in 4m39s
ci / flake (push) Successful in 7m9s
2026-08-25 10:37:16 +01:00
Miguel Palhas 80a9399905 feat(maestro): session cards in the sidebar
ci / web (push) Successful in 29s
ci / rust (push) Successful in 4m25s
ci / flake (push) Successful in 6m34s
Turns each sidebar row into a card: name plus actions on line 1, an
adaptive meta line below carrying a colour-coded activity dot, the time
held in that state, any agent-reported detail, and the blocked-work or
exited markers that used to crowd the name row.

Renders the activity dimension the daemon already inferred and adds an
Error state fed by a Claude Code hook, which maestro installs globally
and addresses per session through MAESTRO_SESSION_ID / MAESTRO_URL.
Both clients implement one contract, docs/SIDEBAR.md.

Closes #101, #102, #103, #104, #105, #106, #107, #108, #110, #111, #112,
#113, #114, #115.
2026-08-24 23:26:59 +01:00
Miguel Palhas 9d9a2e723d merge: sidebar-cards/115-fourth-flake 2026-08-24 23:13:38 +01:00
Miguel Palhas bbafff5a98 fix(maestro): add settle-finished observable for natural-exit test
The no-retained-store test asserts a negative — the tree survives — so
waiting on that negative proves nothing: settle could still remove the
tree one instant after the assertion passes. mark_session_dead now flips
a per-record settled flag once the exit rule has been applied or refused,
exposed as SessionManager::is_settled; the test waits on it before
asserting.

Refs #115
2026-08-24 23:12:46 +01:00
Miguel Palhas 17e1dc4049 merge: sidebar-cards/114-hook-error 2026-08-24 23:01:14 +01:00
Miguel Palhas 436cb194c3 merge: sidebar-cards/113-more-flakes 2026-08-24 23:00:54 +01:00
Miguel Palhas 6471da2624 feat(maestro): report StopFailure as Error with the error spelling as detail 2026-08-24 22:54:21 +01:00
Miguel Palhas 2a43c76176 feat(maestro): report Active while a long tool call is still running 2026-08-24 22:52:51 +01:00
Miguel Palhas f689c916bf fix(maestro): poll awaiting-input until leaderless state holds
The pipeline leader exits as soon as both stages are forked, which can
beat the surviving stage opening the tty and parking on read — the one-
shot assertion then ran against a group with no parked member yet.
Poll for the answer, accepting it only while the foreground group is
still leaderless so a shell that regained the tty cannot satisfy it.

Fixes #113
2026-08-24 22:50:02 +01:00
Miguel Palhas 88233cb96d fix(maestro): sync watcher re-emit test to real event flow
Two races. First, the watcher updates the snapshot and emits
SessionUpdated as two steps, so the drain could run between them: the
legitimate breakdown emission then arrived inside the quiet window.
Subscribe before the watcher starts and consume through the breakdown
event so the window begins after the last real change.

Second, the assertion treated any SessionUpdated as a failed
suppression, but the activity pass may legitimately announce an
Active→Idle transition in that window (ACTIVE_WINDOW is 3s), carrying
today's blocked_work unchanged. Only a changed indicator counts as a
re-emit now.

Fixes #113
2026-08-24 22:50:02 +01:00
Miguel Palhas 54d84e3eaa docs(maestro): settle Error report TTL and clearing rulings 2026-08-24 22:46:46 +01:00
Miguel Palhas efbccb4e11 fix(maestro): wait for settle in pristine natural-exit test
Dead status is observable a moment before the settle step removes the
worktree, so the assertions raced the removal under parallel load. Wait
for the tree to be gone and the store to stay empty, same as the
retained-store wait cfebfd4 added to the guarded sibling.

Fixes #113
2026-08-24 22:41:54 +01:00
Miguel Palhas 41c498401b merge: sidebar-cards/112-detail-render 2026-08-24 22:30:49 +01:00
Miguel Palhas 6ca347b657 merge: sidebar-cards/106-hooks-install 2026-08-24 22:30:49 +01:00
Miguel Palhas 1b4981f9ce Merge remote-tracking branch 'origin/blitz/sidebar-cards' into sidebar-cards/106-hooks-install 2026-08-24 22:26:48 +01:00
Miguel Palhas 1b289a0450 feat(maestro): daemon auto-installs claude-code hook on first start 2026-08-24 22:25:54 +01:00
Miguel Palhas 3c5bb51e88 feat(maestro): add maestro hooks install subcommand 2026-08-24 22:22:49 +01:00
Miguel Palhas f60587a6d6 merge: sidebar-cards base into 112-detail-render 2026-08-24 22:22:09 +01:00
Miguel Palhas 43946c3c46 feat(maestro): render agent-reported detail on TUI session cards 2026-08-24 22:22:06 +01:00
Miguel Palhas eb605a3d05 feat(maestro): ship claude-code activity hook + idempotent installer 2026-08-24 22:18:23 +01:00
Miguel Palhas 4c9409a785 feat(maestro): add [hooks] auto_install config section 2026-08-24 22:14:15 +01:00
Miguel Palhas c24a2133d4 feat(maestro): render agent-reported detail on web session cards 2026-08-24 22:13:59 +01:00
Miguel Palhas 75ccfe10d2 docs(maestro): pin detail truncation ahead of trailing meta fragments 2026-08-24 22:13:04 +01:00
Miguel Palhas ea3e18d1f2 merge: sidebar-cards/111-flaky-retention 2026-08-24 22:11:30 +01:00
Miguel Palhas 4f0e887a50 fix(maestro): parse reported activity states strictly 2026-08-24 22:05:40 +01:00
Miguel Palhas cfebfd441b fix(maestro): wait for retained-store write in natural-exit test
Dead status can be observed before the settle step's store write
lands, so the test's store read raced the write under parallel load.
Wait for the store to have the entry before asserting on it, same as
the settle-state wait a few lines below.

Fixes #111
2026-08-24 22:03:56 +01:00
Miguel Palhas 81312aa0c5 merge: sidebar-cards/110-lenient-activity 2026-08-24 22:02:04 +01:00
Miguel Palhas 52be48b0a6 merge: sidebar-cards/105-activity-endpoint 2026-08-24 22:01:42 +01:00
Miguel Palhas 202b5008b8 fix(maestro): drop an unused instant from report_activity 2026-08-24 21:40:34 +01:00
Miguel Palhas 015f2be5fb docs(maestro): record the activity-detail meta-line slot and report TTL 2026-08-24 21:39:15 +01:00
Miguel Palhas 5a594b2b7f feat(maestro): serve POST /api/sessions/:id/activity 2026-08-24 21:38:42 +01:00
Miguel Palhas 8370ddf066 feat(maestro): hold agent-reported activity above inference for a TTL 2026-08-24 21:36:56 +01:00
Miguel Palhas 5c4e083c2d feat(maestro): add activity_detail to Session and the activity-report wire types 2026-08-24 21:32:27 +01:00
Miguel Palhas 71add0215a fix(maestro): deserialize unknown ActivityState variants as Idle
A newer daemon's unknown activity variant failed the whole Session
deserialization on older clients. Hand-written Deserialize maps any
unknown string to Idle; non-string values still error. (#110)
2026-08-24 21:28:30 +01:00
Miguel Palhas 766d256970 fix(maestro): suppress epoch durations on TUI cards 2026-08-24 19:57:58 +01:00
Miguel Palhas 78cddec730 merge: sidebar-cards/108-tui-cards 2026-08-24 19:56:51 +01:00
Miguel Palhas 4ff31a4f79 merge: sidebar-cards/107-web-cards 2026-08-24 19:56:51 +01:00
Miguel Palhas abab7dcb63 merge: sidebar-cards/104-activity-clock 2026-08-24 19:56:51 +01:00
Miguel Palhas c7c5b2e0ec feat(maestro): show spawn-similar and kill affordances on the highlighted card
Line-1 actions anchor to the card's top-right corner on the highlighted
row only, reserving their width so the name truncates with an ellipsis
before it can ever reach them.
2026-08-24 19:53:27 +01:00
Miguel Palhas f6a97c4695 docs(maestro): settle line-2 rulings — command fragment, pre-#102 payloads, unconditional dot 2026-08-24 19:51:06 +01:00
Miguel Palhas 323ead5a83 feat(maestro): give TUI cards explicit card and focus backgrounds
Reversed video made a highlighted card unreadable. Cards now sit on a
black card background painted edge to edge; the highlighted card swaps it
for black-on-cyan, matching the status bar's focus treatment.
2026-08-24 19:50:37 +01:00
Miguel Palhas 5be3e33c94 feat(maestro): render sidebar sessions as two-line cards per docs/SIDEBAR.md 2026-08-24 19:50:24 +01:00
Miguel Palhas 002c128ac8 feat(maestro): stamp activity_since only on state transitions (#104)
The activity pass now sets activity_since only when the decided
ActivityState differs from the stored one; foreground/cwd-only diffs and
steady-state ticks leave the clock alone, so the sidebar duration counts
time in the current state instead of reading zero.

Daemon restarts still reset the clock to the restart time (stated in
recover()); the daemon cannot know how long a recovered session had
already been idle.
2026-08-24 19:49:55 +01:00
Miguel Palhas bb28fe1d6e feat(maestro): render an adaptive meta line under TUI session cards
Per docs/SIDEBAR.md: line 2 carries the activity dot + floored duration in
the state's colour, plus the blocked-work and exited fragments; it appears
only when time-in-state exceeds 30s, work is blocked, the session is dead,
or an Error state is in force. Blocked work moves off line 1. Card lines
truncate with an ellipsis instead of wrapping.
2026-08-24 19:48:23 +01:00
Miguel Palhas d7b15a131f feat(maestro): activity duration + colour helpers per the sidebar card contract 2026-08-24 19:42:33 +01:00
Miguel Palhas 581ec29492 docs(maestro): name the activity dot in the card contract 2026-08-24 19:32:08 +01:00
Miguel Palhas 90b7953b17 merge: sidebar-cards/103-session-env 2026-08-24 19:31:35 +01:00
Miguel Palhas 26e7992c8d merge: sidebar-cards/102-activity-wire 2026-08-24 19:31:35 +01:00
Miguel Palhas 60515dd8c6 merge: sidebar-cards/101-sidebar-contract 2026-08-24 19:31:35 +01:00
Miguel Palhas b3c73fd2cb test(maestro): assert the exported session env end to end through real tmux 2026-08-24 19:30:11 +01:00
Miguel Palhas 7d06bec21e feat(maestro): export MAESTRO_SESSION_ID and MAESTRO_URL into spawned sessions
Every tmux-spawned session now gets its UUID under MAESTRO_SESSION_ID and,
when the runner has wired its base URL from [daemon].bind_addr, that URL
under MAESTRO_URL (#103). The daemon-owned values override anything the
spawn request carried. Adopted sessions stay without them by design.
2026-08-24 19:30:11 +01:00
Miguel Palhas 1a0d1284e1 feat(maestro): mirror activity_since and Error state in web types 2026-08-24 19:22:47 +01:00
Miguel Palhas fc6f3469b0 feat(maestro): add Error activity state to the wire 2026-08-24 19:21:44 +01:00
Miguel Palhas b95b5c04c1 feat(maestro): add activity_since to the session wire 2026-08-24 19:19:33 +01:00
Miguel Palhas 6af8abccd3 docs(maestro): add SIDEBAR.md session card contract 2026-08-24 19:17:04 +01:00
Miguel Palhas ad2e700391 fix(maestro): spawn opens a new tab instead of splitting
ci / web (push) Successful in 31s
ci / rust (push) Successful in 2m22s
ci / flake (push) Successful in 6m43s
Pressing Spawn while reading a session split that pane in half. It now
opens the draft in its own tab, leaving the current tab untouched.

Scoped to the explicit Spawn affordances (the header button and the
sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the
fast-forward key deliberately keep splitting: both act on the session in
front of you, so a pane beside it is the point. Reusing an empty pane
(#72) is unchanged — that path never split anything.

Closes #100
2026-08-24 17:17:35 +01:00
Miguel Palhas 2dcef860a6 fix(maestro): group worktree sessions under their repository
The sidebar nested a worktree session under the worktree, so a repo grew
a <repo>/worktrees/<branch> folder holding one session of the same name,
and the repo stopped being where you look for the work.

The daemon recorded whatever base_repo the request named, which is the
worktree itself when spawning from inside one; it now records the
resolved main checkout. The web tree groups by that instead of the cwd.
The TUI sidebar is a flat list with no directory grouping, so it needs
no mirror.

Closes #99
2026-08-24 17:17:35 +01:00
Miguel Palhas 0bb5df7245 fix(maestro): write nix.conf before installing nix in CI
ci / web (push) Successful in 1m26s
ci / rust (push) Successful in 4m20s
ci / flake (push) Successful in 7m24s
The runner container is root with no `nixbld` group. The installer
writes `build-users-group = nixbld` regardless and then fails on it:

  error: the group 'nixbld' specified in 'build-users-group' does not exist

Writing the config first (and again after, in case the installer
replaced it) clears that and enables flakes in the same place.

Refs #98
2026-08-24 16:19:01 +01:00
Miguel Palhas cb070e4f84 fix(maestro): make the flake CI job work on the runner
ci / flake (push) Failing after 16s
ci / web (push) Successful in 37s
ci / rust (push) Successful in 5m9s
Two defects in the job as landed, both invisible locally:

- The multi-user installer needs systemd, which the act_runner container
  does not have. Single-user install instead.
- The installer does not enable flakes, so `nix build .#…` fails on a
  fresh install. A machine with them in nix.conf builds fine, which is
  why this passed locally and failed in CI.

Refs #98
2026-08-24 16:17:00 +01:00
Miguel Palhas 8fde211348 feat(maestro): spawn form v2 refinements and review fixes
ci / web (push) Successful in 26s
ci / flake (push) Failing after 21s
ci / rust (push) Successful in 2m30s
Closes the Spawn v2 milestone: six review findings from the first blitz
plus seven pieces of operator feedback on the draft pane, and a flake
build that had been silently broken.

Form:
- The draft form is capped and centred instead of stretching to the pane.
- The worktree section sits behind a collapsed "Advanced options"
  disclosure whose header says whether a worktree will be created.
- The two controls both labelled "Branch" are now "New branch name" and
  "Pick a branch", matching in both clients.
- Enter submits from any field; inside a picker it chooses first.

Directory picker, rebuilt in both clients from one written set of rules:
- Projects are boxes with full paths; worktrees are a second step that
  appears once a project is picked, with "+ new" as the default.
- Picking an existing worktree runs in it and leaves the create-worktree
  toggle off — the daemon never adopts, so running in a tree and owning
  it stay separate acts (docs/WORKTREES.md).

Fixes: the fast-forward spawn key never matched a shifted 'S'; openDraft
never reused an empty pane and dropped its seed; swapping draft panes
moved the data but not the forms; the gitroot probe fired per keystroke;
the projects rescan ignored its own TTL; a late /api/info reset a picked
preset; and CI now builds the flake packages, which had been failing
since the pnpmDeps hash went stale.
2026-08-24 16:13:17 +01:00
Miguel Palhas 31cdbd4f0e merge: Enter submits the draft form from any field
Closes #97
2026-08-24 15:52:28 +01:00
Miguel Palhas 0d06827b69 fix(maestro): keep Enter toggling the worktree checkbox, not submitting
Matches the TUI's WorktreeToggle, whose Enter also toggles rather than
submitting — otherwise the two clients would disagree on this one
field despite #97 requiring the same rule everywhere else.
2026-08-24 15:51:19 +01:00
Miguel Palhas 8a6cb81d3a feat(maestro): Enter submits the TUI draft pane from any field (#97)
DirPicker mirrors the web filter/picker split: typing narrows and the
next Enter submits with the highlighted project directly (nothing was
chosen yet); an explicit Up/Down move onto a different row is
mid-selection, so Enter there only chooses it and a further Enter (now
the active pick) submits. WorktreeChoice, BranchExisting, Preset and
BranchMode already apply their highlight eagerly on Up/Down/Left/Right,
so Enter always submits there. Advanced and WorktreeToggle keep
activating on Enter, matching the web form's own button controls.
2026-08-24 15:50:28 +01:00
Miguel Palhas 52f9d0679e merge: distinct labels for the worktree branch fields
Closes #94
2026-08-24 15:48:38 +01:00
Miguel Palhas e6ef236f89 fix(maestro): match the web form's distinct branch-field labels in the TUI (#94)
The draft pane's new-branch gutter was labelled "br:" in both branch
modes — since #94's web fix now says "New branch name" / "Pick a
branch", mirror the same distinction here: "new:" for the branch-name
input, "pick:" for the existing-branch list (which previously had no
label at all).
2026-08-24 15:44:06 +01:00
Miguel Palhas 5747c71800 fix(maestro): distinct labels for the worktree branch field (#94)
The new-branch name input and the existing-branch picker both had a
literal <label>Branch</label>, on top of "New branch"/"Existing branch"
mode radios — reading the form gave no clue what the field wanted.
Kept the mode radios (they name the choice, not the field) and gave
each field its own label: "New branch name" for the text input,
"Pick a branch" for the select.
2026-08-24 15:42:10 +01:00
Miguel Palhas 5b0a0c2001 feat(maestro): Enter submits the spawn form from any field (#97)
A project-box or worktree-choice picker button that isn't yet the
active pick takes the Enter to choose it instead; the next Enter, once
it's active, submits. The project filter resolves to the top filtered
project rather than sending whatever cwd was last set. The raw-cwd
autocomplete's own Enter (accept the highlight) keeps working via
e.defaultPrevented.
2026-08-24 15:38:44 +01:00
Miguel Palhas df9013ffdf merge: two-step worktree picker in the TUI draft
Keeps both #93 and #96 in the draft layout: the worktree toggle and body
stay behind the Advanced disclosure, while the worktree choice step
belongs to the directory picker (#95 rule 3) and stays visible once a
project is picked.

Closes #96
2026-08-24 15:30:02 +01:00
Miguel Palhas 39341b278c merge: collapse the worktree section by default in both clients
Closes #93
2026-08-24 15:27:38 +01:00
Miguel Palhas b646cc5adf Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/96-tui-picker 2026-08-24 15:25:29 +01:00
Miguel Palhas cfc125144f feat(maestro): two-step worktree picker in the TUI draft
Mirrors the web project-picker redesign (#95) in the TUI (#96): step one
lists one box per repository (name, full path); picking one reveals step
two — "+ new" then that repository's worktrees newest-first, defaulting
to "+ new" except on a box that is itself a linked worktree. "+ new"
creates (cwd is the repo, create-worktree on); an existing worktree runs
in it (cwd is that worktree, create-worktree off). Neither step probes
/api/gitroot — only the free-text "Other directory…" hatch does.

Replaces the flat indented project+worktree list from #91/#64 with
projects::group_projects-backed DraftGroup boxes; the filter still
matches a repository via its own name/path or any of its worktrees.
2026-08-24 15:25:27 +01:00
Miguel Palhas cd66dbf7d8 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/93-collapse-worktree
# Conflicts:
#	web/src/spawnForm.ts
2026-08-24 15:22:16 +01:00
Miguel Palhas 6bb812f6bd feat(maestro): collapse the TUI draft's worktree section by default (#93)
Mirrors the web spawn form: worktree/branch controls sit behind a new
"Advanced options" disclosure line (bracket [+]/[-], toggled by Enter
or Space, closed by default) instead of always showing the checkbox
and branch rows. The header states whether a worktree will be created
so #91's off-by-default for a worktree row is visible without
expanding. Collapsed fields drop out of Tab order; expanding only adds
rows below the disclosure, never moving the ones above it.
2026-08-24 15:20:35 +01:00
Miguel Palhas d3098a9400 merge: build flake packages in CI so a stale pnpmDeps hash fails loudly
Closes #98
2026-08-24 15:16:30 +01:00
Miguel Palhas 5de04af36a feat(maestro): collapse the spawn form worktree section by default (#93)
Closed by default behind a disclosure reusing the sidebar folder-header
affordance (catalog.ts), since most spawns take the default target and
the section was the largest block in the form. The collapsed header
states whether a worktree will be created, so #91's off-by-default for
a worktree row stays visible without expanding.

Fixes a latent [hidden] + display:flex bug for the new .wt-collapsible
element (author CSS beats the UA [hidden] rule at equal specificity);
.wt-body has the same bug pre-existing and is out of scope here.
2026-08-24 15:15:47 +01:00
Miguel Palhas 36cb03961c ci(maestro): build flake packages so a stale pnpmDeps hash fails loudly
A web/pnpm-lock.yaml change invalidates the pinned pnpmDeps.hash in
flake.nix silently; nix build .#maestro-web then stays broken until
someone notices by hand (#98). Add a flake job that installs nix and
builds the packaged outputs on every CI run.
2026-08-24 15:14:43 +01:00
Miguel Palhas 5e34ce7a61 merge: pick a project, then its worktree
Closes #95
2026-08-24 15:10:27 +01:00
Miguel Palhas 1fa9ce798e Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/95-project-picker
# Conflicts:
#	web/src/spawnFormWorktree.test.ts
2026-08-24 15:09:05 +01:00
Miguel Palhas 2c89902829 feat(maestro): pick a project, then its worktree
The working-directory picker was a flat list mixing repositories with
their worktrees, each row repeating the project name as an abbreviated
path. It is now two steps: a grid of project boxes carrying the full
unabbreviated path, then — only once a project is picked — that
repository's worktrees with "+ new" as the default.

"+ new" creates a worktree; picking an existing one runs in it and
leaves the create-worktree toggle off, since the daemon never adopts
(docs/WORKTREES.md). That is #91's rule, now an explicit choice rather
than an inference from which row was clicked.

Also fixes `.raw-wrap[hidden]`: `display: flex` outranked the UA sheet's
`[hidden]`, so the free-text cwd input showed even when hidden.

The interaction rules are listed in spawnForm.ts for the TUI mirror (#96)
to follow.

Refs #95
2026-08-24 15:08:05 +01:00
Miguel Palhas 8949fdfdf6 merge: debounce the gitroot probe in both clients
Closes #74
2026-08-24 15:06:50 +01:00
Miguel Palhas 596e7e2f49 feat(maestro): debounce the gitroot probe in the TUI draft
Typing in the raw path field fired one /api/gitroot probe per
keystroke (#74). The draft now schedules the probe 250ms out — matching
GITROOT_PROBE_DEBOUNCE_MS in web/src/spawnForm.ts — and every keystroke
resets it; the 30Hz heartbeat polls for due probes so the burst's one
request fires after typing stops. The seq guard in resolve_gitroot is
untouched, so a late answer still cannot overwrite a newer input.
2026-08-24 15:04:47 +01:00
Miguel Palhas 16609eff9e feat(maestro): debounce the gitroot probe in the spawn form
Typing in the raw cwd field fired one /api/gitroot request per
keystroke (#74). Every keystroke now resets a 250ms window shared with
the TUI draft (GITROOT_PROBE_DEBOUNCE_MS there); probeToken still owns
ordering so a late answer can't overwrite a newer input.
2026-08-24 14:59:56 +01:00
Miguel Palhas 9b0e9751ec merge: stop a late /api/info resolution from clobbering a picked preset
Closes #76
2026-08-24 14:58:53 +01:00
Miguel Palhas d1ed4f1fdc Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/76-preset-reset 2026-08-24 14:58:19 +01:00
Miguel Palhas 2c8758ed14 fix(maestro): stop a late /api/info resolution from clobbering a picked preset
A tile clicked, or a custom command typed, during the /api/info round trip
was discarded: applyInitialCommand() ran unconditionally a second time once
presets refined, re-selecting the first non-custom preset with no seed to
protect the operator's pick. Guard it with a commandTouched flag, mirroring
the nameTouched pattern already used for the name-batch race.

Fixes #76.
2026-08-24 14:58:07 +01:00
Miguel Palhas 2a2da7f779 merge: move draft forms along with swapped pane data
Closes #73
2026-08-24 14:55:55 +01:00
Miguel Palhas 5eab434647 merge: default the worktree toggle off in an existing worktree
Closes #91
2026-08-24 14:54:46 +01:00
Miguel Palhas 8e9ed580ea Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/73-draft-pane-swap 2026-08-24 14:54:26 +01:00
Miguel Palhas 2d5e2f1718 fix(maestro): move draft forms along with swapped pane data
Center-zone pane drag swapped dragged.draft/target.draft but left
draftForms cached under the old leaf id, so both panes kept showing
their pre-swap form values while writing into the swapped leaf.

Tear down both leaves' cached forms at swap time (same pattern as
openDraft's stale-form teardown, #72) so render() remounts fresh
forms seeded from the swapped data.

Fixes #73
2026-08-24 14:54:18 +01:00
Miguel Palhas b80d604408 fix(maestro): default the worktree toggle off when the directory is itself a worktree (#91)
Picking a project row that is itself a linked git worktree used to
default 'Create git worktree' on, so spawning asked for a second
worktree of an already-checked-out branch and git refused. Both clients
now default the toggle off there: the session just runs in that existing
worktree with no worktree record (not adoption). Turning it back on by
hand still tries, per the issue's non-goal.
2026-08-24 14:52:17 +01:00
Miguel Palhas 56d1afe9bd merge: cap and centre the spawn form's width
Closes #92
2026-08-24 14:48:45 +01:00
Miguel Palhas 73e9ecfc85 fix(maestro): cap and centre the spawn form's width
The form filled the full pane width, so on a 4K display every input
stretched thousands of pixels wide. Bound it to 60ch (in line with the
48ch/42ch precedents already in the type scale) and centre it with
margin auto; panes narrower than the cap still fill edge to edge.
2026-08-24 14:48:19 +01:00
Miguel Palhas 095f810176 merge: make shift-S resolve to the fast-forward spawn binding
Closes #70
2026-08-24 14:45:53 +01:00
Miguel Palhas 3e809db1bc merge: honour the project cache TTL in the background rescan
Closes #75
2026-08-24 14:42:54 +01:00
Miguel Palhas e8bd8a4952 merge: reuse an empty draft pane and keep its seed
Closes #72
2026-08-24 14:41:42 +01:00
Miguel PalhasandClaude Sonnet 5 a85773cef0 fix(maestro): make shift-S resolve to the fast-forward spawn binding
crossterm attaches KeyModifiers::SHIFT to every uppercase char, so a real
Shift+S delivered Char { c: 'S', shift: true }. canonical() turned that
into "shift-S", which the default "S" binding never matched, so
FastForwardSpawn was unreachable from the keyboard.

canonical() now drops the shift- prefix when the char is already
uppercase, since the case already says "shifted". Regression test drives
an actual crossterm KeyEvent through canonicalise() and the keymap,
rather than asserting on the binding string, per #70.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:39:53 +01:00
Miguel Palhas 9c6e06ec4f fix(maestro): reuse empty draft pane and keep its seed (#72)
openDraft's reuse check was `!target.draft`, dead after first render:
createSpawnForm auto-syncs its picked defaults into the leaf's draft
once its project load settles, so every empty pane has a non-null
draft a second later. `n` and Spawn always split instead of reusing
the pane on screen.

Reuse now checks only `!target.sessionId`, matching the codebase's own
definition of "empty" pane elsewhere. Reusing also drops the leaf's
stale form instance so a new seed is read by the rebuilt form instead
of being ignored by the old one.
2026-08-24 14:39:51 +01:00
Miguel Palhas 3029de667e fix(maestro): honour the project cache TTL in the background rescan (#75) 2026-08-24 14:39:10 +01:00
Miguel Palhas a8d693c0a3 fix(web): bump pnpmDeps hash for lockfile change
ci / web (push) Successful in 26s
ci / rust (push) Successful in 3m8s
2026-08-24 13:57:01 +01:00
Miguel Palhas 6c1bcf042c feat(maestro): spawn v2 and the worktree lifecycle redesign
ci / web (push) Successful in 25s
ci / rust (push) Failing after 1m40s
Merges two milestones. Spawn v2 (#53-#68) replaced the spawn modal with
an inline draft pane, added project scanning, branch pickers and name
generation across the web and TUI clients.

Worktree lifecycle (#77-#89) then redesigned what the daemon does with a
worktree it created, which is what Spawn v2's reviews kept getting wrong:

- The daemon never adopts a directory it finds. A path collision is
  sidestepped with a numeric suffix instead of resolved.
- A kill is refused when the worktree holds unsaved work; ignored files
  never count. --force ends the session and RETAINS the tree.
- A session that exits on its own applies the same predicate, from the
  same function, so the two paths cannot drift.
- Retained trees are persisted in state_dir, listed by GET /api/worktrees,
  and reclaimed explicitly. Reclaim removes the directory, never the branch.
- cleanup_on_kill is gone from the wire; what survives is decided by what
  is in the tree, not by what the spawn asked for.

docs/WORKTREES.md is the written contract, with the reasoning for each
non-obvious rule.
2026-08-24 13:36:53 +01:00
Miguel Palhas fb87a00906 merge: surface blocked work before the kill is attempted
Closes #83
2026-08-24 13:22:50 +01:00
Miguel Palhas d4dcfe7f00 feat(maestro): show blocked work on session rows and warn on kill in the web UI (#83) 2026-08-24 13:19:12 +01:00
Miguel Palhas 6161cabe77 feat(maestro): warn on kill and mark blocked sessions in the TUI (#83) 2026-08-24 13:08:13 +01:00
Miguel Palhas 524a848fc6 feat(maestro): show blocked work in the CLI table and warn on kill (#83) 2026-08-24 13:06:43 +01:00
Miguel Palhas 187bcb01d6 feat(maestro): refresh the blocked-work indicator in the watcher (#83)
run_blocked_work_pass rides the liveness tick but probes on a slower
cadence (DEFAULT_BLOCKED_WORK_INTERVAL, 15s): each probe is three git
subprocesses per session. Sessions without a worktree are filtered out
before any probe, so their indicator costs nothing. A failed probe keeps
the previous value; settled exits clear it.
2026-08-24 13:04:30 +01:00
Miguel Palhas 003e3839bc feat(maestro): carry a blocked-work indicator on Session (#83)
Session.blocked_work is Option<InTheWay>, refreshed by the daemon's
watcher; None for worktree-less sessions and until first probed.
Defaults on the wire so older payloads stay forward-compatible.
2026-08-24 12:58:16 +01:00
Miguel Palhas d2079cac3a merge: e2e coverage for the whole worktree lifecycle
Resolves the e2e conflict in favour of #88's helper, rewritten as a
struct literal now that cleanup_on_kill is gone — #88 had built the
spec through serde_json to dodge the field, which cost compile-time
checking for no reason once the field no longer exists.

Closes #88
2026-08-24 12:37:41 +01:00
Miguel Palhas 2083911baa test(maestro): cover the whole worktree lifecycle end to end (#88)
Every scenario from the contract, against a real daemon, tmux and git:
collision suffixes with both trees working, foreign directories
sidestepped without adoption, the guarded kill and its refusal
breakdown, save-then-kill succeeding, --force retaining the tree,
natural exits settling dirty and pristine worktrees differently, the
retained list with reasons, reclaim keeping the branch, and the store
surviving a daemon restart.

Worktree specs are built through their wire shape so the tests carry no
reference to WorktreeSpec.cleanup_on_kill, which #87 is removing.
2026-08-24 12:33:39 +01:00
Miguel Palhas b5450651d1 test(maestro): expose forced kill and retained-worktree calls in the e2e harness 2026-08-24 12:33:39 +01:00
Miguel Palhas 8aa007c73b fix(maestro): drop the cleanup_on_kill call sites #87 could not reach
Three in session_manager.rs arrived with #82 after #87 branched; four in
maestro-e2e were fenced off because #88 owns that crate. Removing the
field without them left the workspace uncompilable.
2026-08-24 12:12:02 +01:00
Miguel Palhas f4d12fa02d merge: drop cleanup_on_kill from the wire and the CLI
Closes #87
2026-08-24 12:11:31 +01:00
Miguel Palhas 31702e03e3 refactor(maestro): drop cleanup_on_kill from the wire and the CLI
Nothing has honoured WorktreeSpec.cleanup_on_kill since #56, and after
#79 and #81 survival is decided by what is in a worktree, not by what
the spawn asked for. A field that lies is worse than a missing one.

Removes the field from the protocol, the --cleanup-on-kill flag from
the CLI, and the tests asserting its default.
2026-08-24 12:08:41 +01:00
Miguel Palhas dde8dd8cfc merge: apply the worktree rule when a session exits on its own
Closes #82
2026-08-24 11:57:51 +01:00
88 changed files with 10892 additions and 1094 deletions
+31
View File
@@ -97,3 +97,34 @@ jobs:
- name: build
run: pnpm -C web build
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
# change invalidates the pin silently elsewhere, so the packaged build has to
# fail here too.
flake:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The runner container is root with no `nixbld` group, but the installer
# writes `build-users-group = nixbld` anyway and then fails on it, so the
# config goes in first. Flakes are off by default and `nix build .#…`
# needs them. Written again afterwards in case the installer replaced it.
- name: Install nix
run: |
mkdir -p /etc/nix
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
# The installer does not enable flakes, so `nix build .#…` needs the
# features passed explicitly — a local machine that has them in
# nix.conf will build fine while CI fails without this.
- name: build flake packages
run: |
nix --extra-experimental-features "nix-command flakes" build \
.#webFrontend \
.#maestro \
.#maestro-web \
--no-link
+4
View File
@@ -66,6 +66,10 @@ See `docs/INSTALL.md` for end-user install instructions.
collisions, what blocks a kill, what `--force` retains, and what reclaim
touches. Read it before changing anything that creates or removes one.
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
Generated
+11
View File
@@ -1065,6 +1065,7 @@ dependencies = [
"futures-util",
"libc",
"maestro-config",
"maestro-hooks",
"maestro-protocol",
"maestro-testing",
"nix",
@@ -1102,6 +1103,7 @@ dependencies = [
"futures-util",
"libc",
"maestro-config",
"maestro-hooks",
"maestro-names",
"maestro-protocol",
"reqwest",
@@ -1137,6 +1139,15 @@ dependencies = [
"uuid",
]
[[package]]
name = "maestro-hooks"
version = "0.1.0"
dependencies = [
"serde_json",
"tempfile",
"thiserror 1.0.69",
]
[[package]]
name = "maestro-installer"
version = "0.1.0"
+1
View File
@@ -18,6 +18,7 @@ dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2"
maestro-config = { path = "crates/maestro-config" }
maestro-hooks = { path = "crates/maestro-hooks" }
maestro-names = { path = "crates/maestro-names" }
maestro-protocol = { path = "crates/maestro-protocol" }
maestro-testing = { path = "crates/maestro-testing" }
+1
View File
@@ -17,6 +17,7 @@ dirs.workspace = true
futures-util.workspace = true
libc.workspace = true
maestro-config.workspace = true
maestro-hooks.workspace = true
maestro-protocol = { workspace = true, features = ["client"] }
nix.workspace = true
serde_json.workspace = true
+34 -28
View File
@@ -43,6 +43,23 @@ pub enum Command {
Kill(KillArgs),
/// List retained worktrees, or reclaim one by ID (#86).
Worktrees(WorktreesArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
}
/// `maestro hooks <install>`
#[derive(Debug, clap::Args)]
pub struct HooksArgs {
#[command(subcommand)]
pub command: HooksCommand,
}
#[derive(Debug, Subcommand)]
pub enum HooksCommand {
/// Write the Claude Code activity hook into ~/.claude/settings.json.
/// Idempotent: an older maestro hook is updated in place, every other
/// hook is left untouched.
Install,
}
/// `maestro worktrees <list|reclaim>`
@@ -102,11 +119,6 @@ pub struct SpawnArgs {
/// there.
#[arg(long)]
pub existing_branch: bool,
/// Remove the worktree when the session is killed. Kept for wire
/// compatibility (#56): the daemon now always cleans up on kill, guarded
/// against unsaved work.
#[arg(long, default_value_t = true)]
pub cleanup_on_kill: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
pub attach: bool,
@@ -168,7 +180,6 @@ mod tests {
"/repos/foo",
"--branch",
"feat/x",
"--cleanup-on-kill",
"-e",
"FOO=bar",
"-e",
@@ -179,7 +190,6 @@ mod tests {
Command::Spawn(a) => {
assert_eq!(a.command, "bash");
assert_eq!(a.branch.as_deref(), Some("feat/x"));
assert!(a.cleanup_on_kill);
assert_eq!(
a.env,
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),]
@@ -189,27 +199,6 @@ mod tests {
}
}
#[test]
fn cleanup_on_kill_defaults_true_for_the_wire() {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"bash",
"--worktree-from",
"/repos/foo",
"--branch",
"feat/x",
])
.unwrap();
match cli.command {
Command::Spawn(a) => assert!(a.cleanup_on_kill),
_ => panic!("wrong subcommand"),
}
}
/// Review fix: without a flag the CLI always sent `create_branch: true`,
/// so the daemon's existing-branch path was unreachable and spawning onto
/// a branch that already existed just failed.
@@ -303,6 +292,23 @@ mod tests {
assert!(err.to_string().contains("KEY=VALUE"));
}
#[test]
fn parses_hooks_install() {
let cli = Cli::try_parse_from(["maestro", "hooks", "install"]).unwrap();
match cli.command {
Command::Hooks(h) => {
assert!(matches!(h.command, HooksCommand::Install));
}
_ => panic!("wrong subcommand"),
}
// No subcommand → help, like `worktrees`.
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
assert!(matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
));
}
#[test]
fn parses_worktrees_list() {
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
+57
View File
@@ -24,6 +24,10 @@ pub enum CliError {
#[error("{0}")]
Attach(#[from] crate::attach::AttachError),
#[error("{0}")]
Hooks(#[from] maestro_hooks::HooksError),
#[error("could not determine the home directory ($HOME unset?)")]
NoHome,
#[error("{0}")]
Io(#[from] std::io::Error),
}
@@ -40,6 +44,15 @@ pub async fn dispatch(
None => default_config_path()?,
};
// `hooks install` is purely local: it must work with the daemon down, so
// it never touches the client.
if let Command::Hooks(args) = &command {
match args.command {
crate::cli::HooksCommand::Install => run_hooks_install()?,
}
return Ok(());
}
let daemon = client(&config_path, daemon_url_override)?;
match command {
@@ -62,6 +75,11 @@ pub async fn dispatch(
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
}
Command::Kill(args) => {
// #83: warn BEFORE the attempt, so the refusal is not a surprise.
// Best-effort: an unreachable list must not block the kill.
if !args.force {
warn_if_blocked(&daemon, args.session_id).await;
}
let outcome = run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id);
if let Some(w) = outcome.retained {
@@ -72,6 +90,7 @@ pub async fn dispatch(
);
}
}
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
Command::Worktrees(args) => match args.command {
WorktreesCommand::List(list_args) => {
let mut stdout = std::io::stdout().lock();
@@ -96,9 +115,47 @@ pub async fn dispatch(
Ok(())
}
/// `maestro hooks install` (#106): write the shipped hook script under the
/// user's data dir and register it in their Claude Code settings. Idempotent
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
#[allow(clippy::result_large_err)]
fn run_hooks_install() -> Result<(), CliError> {
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
let changed = maestro_hooks::install_for_home(&home)?;
if changed {
println!(
"installed claude-code activity hook: {} → {}",
maestro_hooks::script_path(&home).display(),
maestro_hooks::settings_path(&home).display()
);
} else {
println!("claude-code activity hook already up to date");
}
Ok(())
}
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
const CTRL_Q: u8 = 0x11;
/// #83: say "this will be refused" before the operator commits to it, in the
/// same words [`maestro_protocol::InTheWay`] uses everywhere else.
async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
let Ok(sessions) = daemon.list_sessions().await else {
return;
};
if let Some(b) = sessions
.iter()
.find(|s| s.id == id)
.and_then(|s| s.blocked_work)
{
if !b.is_clear() {
eprintln!(
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
);
}
}
}
/// HTTP client pointed at the daemon's API. That surface needs no auth —
/// reaching `bind_addr` is the authorization.
#[allow(clippy::result_large_err)]
+53 -18
View File
@@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
SessionStatus, WorktreeSpec,
@@ -43,7 +44,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
(Some(repo), Some(branch)) => Some(WorktreeSpec {
base_repo: repo.clone(),
branch: branch.clone(),
cleanup_on_kill: args.cleanup_on_kill,
create_branch: !args.existing_branch,
}),
_ => None,
@@ -94,8 +94,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
}
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
)
.ok();
for s in sorted {
@@ -109,6 +109,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
@@ -116,28 +117,26 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
_ => s.command.clone(),
};
// #83: what an unforced kill would be refused for, in InTheWay's own
// words; blank while nothing blocks (or hasn't been probed yet).
let blocked = blocked_label(s);
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {command}",
s.id, status, activity, dir
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
s.id, status, activity, dir, command
)
.ok();
}
}
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
/// UI's abbreviation so CLI and browser show the same directory shorthand.
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
return "~".into();
}
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
return format!("~/{rest}");
}
}
p.into_owned()
/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`.
/// Empty unless something actually blocks — "no unsaved work" would just be
/// noise on every row.
pub(crate) fn blocked_label(s: &Session) -> String {
s.blocked_work
.filter(|b| !b.is_clear())
.map(|b| b.to_string())
.unwrap_or_default()
}
#[cfg(test)]
@@ -163,8 +162,11 @@ mod tests {
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
@@ -225,4 +227,37 @@ mod tests {
super::print_session_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
}
/// #83: a blocked session shows what an unforced kill would be refused
/// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing.
#[test]
fn shows_the_blocked_work_summary_only_when_something_blocks() {
use maestro_protocol::InTheWay;
let mut blocked = session("/tmp", "claude", 1);
blocked.blocked_work = Some(InTheWay {
uncommitted_files: 3,
unpushed_commits: 2,
..InTheWay::default()
});
let mut clear = session("/tmp", "bash", 2);
clear.blocked_work = Some(InTheWay::default());
let unprobed = session("/tmp", "zsh", 3);
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info(""));
let out = String::from_utf8(buf).unwrap();
assert!(
out.contains("3 uncommitted files, 2 unpushed commits"),
"{out}"
);
// Clear and unprobed rows stay blank in the BLOCKED column.
let lines: Vec<&str> = out.lines().collect();
let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap();
for line in &lines[header + 1..] {
if !line.contains("uncommitted files") {
assert!(!line.contains("unsaved work"), "{line}");
}
}
}
}
+1 -1
View File
@@ -6,10 +6,10 @@
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
use crate::sessions::abbreviate_dir;
/// `maestro worktrees list`
pub async fn run_list(
+138 -4
View File
@@ -22,7 +22,8 @@ use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
pub const DIR_NAME: &str = "maestro";
@@ -64,6 +65,24 @@ pub struct MaestroConfigFile {
pub tui: TuiSection,
#[serde(default)]
pub spawn: SpawnSection,
#[serde(default)]
pub hooks: HooksSection,
}
/// `[hooks]` — agent-hook installation settings (#106).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct HooksSection {
/// Whether the daemon installs the Claude Code activity hook into
/// `~/.claude/settings.json` on first start when no maestro hook is
/// there yet. Default `true`; set `auto_install = false` to opt out.
#[serde(default)]
pub auto_install: Option<bool>,
}
impl HooksSection {
pub fn auto_install(&self) -> bool {
self.auto_install.unwrap_or(true)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
@@ -128,11 +147,14 @@ pub struct WebSection {
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
///
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
/// applied **over** the defaults, so a partial override is enough.
/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI
/// ships with a default keymap; entries in `keymap_normal` / `keymap_insert`
/// are applied **over** the defaults, so a partial override is enough.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct TuiSection {
/// Named palette preset. Defaults to the dark preset.
#[serde(default, deserialize_with = "deserialize_theme")]
pub theme: Option<TuiTheme>,
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
#[serde(default)]
pub leader: Option<String>,
@@ -149,6 +171,75 @@ pub struct TuiSection {
pub keymap_insert: BTreeMap<String, String>,
}
/// Palette presets supported by the TUI's `[tui].theme` setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiTheme {
/// The terminal-native dark palette and the default.
Dark,
/// A high-contrast light palette.
Light,
/// Named ANSI colours, allowing the terminal to provide the colour set.
Ansi,
/// Solarized Dark.
SolarizedDark,
/// Solarized Light.
SolarizedLight,
/// Catppuccin Mocha.
Catppuccin,
/// GitHub Dark.
GithubDark,
/// GitHub Light.
GithubLight,
/// Tokyo Night.
TokyoNight,
/// Gruvbox Dark.
Gruvbox,
/// Nord.
Nord,
/// Dracula.
Dracula,
}
/// Every `[tui].theme` name and its variant, in documentation order.
pub const TUI_THEMES: &[(&str, TuiTheme)] = &[
("dark", TuiTheme::Dark),
("light", TuiTheme::Light),
("ansi", TuiTheme::Ansi),
("solarized-dark", TuiTheme::SolarizedDark),
("solarized-light", TuiTheme::SolarizedLight),
("catppuccin", TuiTheme::Catppuccin),
("github-dark", TuiTheme::GithubDark),
("github-light", TuiTheme::GithubLight),
("tokyo-night", TuiTheme::TokyoNight),
("gruvbox", TuiTheme::Gruvbox),
("nord", TuiTheme::Nord),
("dracula", TuiTheme::Dracula),
];
fn deserialize_theme<'de, D>(deserializer: D) -> Result<Option<TuiTheme>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<String>::deserialize(deserializer)?;
raw.map(|name| {
TUI_THEMES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, theme)| *theme)
.ok_or_else(|| {
let known = TUI_THEMES
.iter()
.map(|(n, _)| format!("{n:?}"))
.collect::<Vec<_>>()
.join(", ");
D::Error::custom(format!(
"invalid [tui].theme {name:?}; expected one of {known}"
))
})
})
.transpose()
}
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
///
/// Defined in shared config so every binary sees the same list: the web reads
@@ -375,6 +466,26 @@ mod tests {
assert_eq!(cfg, MaestroConfigFile::default());
}
#[test]
fn hooks_auto_install_defaults_true_and_is_opt_out() {
// Absent file / absent [hooks] ⇒ auto_install on.
assert!(HooksSection::default().auto_install());
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
assert!(MaestroConfigFile::load(&path).unwrap().hooks.auto_install());
std::fs::write(
&path,
r#"
[hooks]
auto_install = false
"#,
)
.unwrap();
assert!(!MaestroConfigFile::load(&path).unwrap().hooks.auto_install());
}
#[test]
fn spawn_presets_default_to_builtins_plus_custom() {
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
@@ -558,6 +669,29 @@ bind_addr = "127.0.0.1:9090"
);
}
#[test]
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
assert_eq!(TuiSection::default().theme, None);
for (name, theme) in TUI_THEMES {
let cfg = file_with(&format!("[tui]\ntheme = \"{name}\"\n"));
assert_eq!(cfg.tui.theme, Some(*theme));
}
}
#[test]
fn tui_theme_rejects_unknown_names_with_the_field_name() {
let err = toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"glorb\"\n").unwrap_err();
let message = err.to_string();
assert!(
message.contains("[tui].theme"),
"diagnostic should name the field: {message}"
);
assert!(
message.contains("glorb"),
"diagnostic should name the invalid value: {message}"
);
}
fn file_with(body: &str) -> MaestroConfigFile {
toml::from_str(body).expect("parse")
}
+1
View File
@@ -18,6 +18,7 @@ dirs.workspace = true
futures-util.workspace = true
libc.workspace = true
maestro-config.workspace = true
maestro-hooks.workspace = true
maestro-names.workspace = true
maestro-protocol.workspace = true
serde.workspace = true
+59
View File
@@ -15,6 +15,44 @@ use maestro_protocol::ActivityState;
/// Active↔Idle. This window *is* the debounce the issue calls for.
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(3);
/// How long an agent-reported state (#105) stays in force before inference
/// resumes. Long enough for a working agent's heartbeat to hold between
/// reports, short enough that an agent dying mid-run cannot pin its session
/// at `Error` forever.
pub const REPORT_TTL: Duration = Duration::from_secs(300);
/// Server-side cap (#105) on a reported `detail`, in characters. The meta
/// line renders it inline, so an unbounded string would own the whole card.
pub const MAX_REPORT_DETAIL_CHARS: usize = 256;
/// An agent-reported activity state (#105) still within its TTL. While one
/// is [`Self::in_force`], it outranks [`decide`]'s inference; when it
/// lapses, inference resumes and any reported detail is cleared with it.
#[derive(Debug, Clone)]
pub struct ReportedActivity {
pub state: ActivityState,
pub expires_at: std::time::Instant,
}
impl ReportedActivity {
pub fn new(state: ActivityState, ttl: Duration) -> Self {
Self {
state,
expires_at: std::time::Instant::now() + ttl,
}
}
pub fn in_force(&self, now: std::time::Instant) -> bool {
now < self.expires_at
}
}
/// Cap a reported detail to [`MAX_REPORT_DETAIL_CHARS`] characters,
/// char-boundary safe.
pub fn truncate_detail(detail: Option<String>) -> Option<String> {
detail.map(|d| d.chars().take(MAX_REPORT_DETAIL_CHARS).collect())
}
/// Combine the observed signals into an [`ActivityState`]:
/// recent output ⇒ `Active`; else blocked on a terminal read ⇒ `AwaitingInput`;
/// else `Idle`.
@@ -59,4 +97,25 @@ mod tests {
ActivityState::Idle
);
}
#[test]
fn report_is_in_force_until_exactly_its_expiry() {
let ttl = Duration::from_secs(300);
let report = ReportedActivity::new(ActivityState::Error, ttl);
assert!(report.in_force(report.expires_at - Duration::from_millis(1)));
assert!(!report.in_force(report.expires_at));
}
#[test]
fn detail_truncates_to_the_cap_without_panic_on_multibyte_input() {
let long = "é".repeat(MAX_REPORT_DETAIL_CHARS + 10);
let capped = truncate_detail(Some(long)).unwrap();
assert_eq!(capped.chars().count(), MAX_REPORT_DETAIL_CHARS);
assert_eq!(truncate_detail(None), None);
assert_eq!(
truncate_detail(Some("short".into())).as_deref(),
Some("short")
);
}
}
+17
View File
@@ -47,6 +47,20 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let _ = default_state_dir();
std::fs::create_dir_all(config.sidecar_dir())?;
std::fs::create_dir_all(config.fifo_dir())?;
// First-start hook auto-install (#106). Best-effort, before serving: a
// session spawned right away should already carry the hook.
if config.hooks_auto_install {
match crate::hooks::auto_install(&config.home_dir) {
crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!("installed claude-code activity hook (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
}
_ => {}
}
}
// No worktree dir to pre-create: without an override the worktrees live
// under their base repos, created on demand (#56).
@@ -73,6 +87,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
// One store instance shared with the API handlers: forced kills (#81)
// record here, `GET /api/worktrees` (#80) reads back from here.
.with_retained_store((*retained).clone());
// Spawned sessions learn where to report back (#103). Derived from the
// configured bind address; set before any spawn can happen.
manager.set_daemon_url(format!("http://{}", config.bind_addr));
let recovered = manager.recover().await?;
tracing::info!(recovered, "adopted existing tmux sessions");
+26
View File
@@ -88,6 +88,10 @@ pub struct DaemonConfig {
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
pub project_scan_depth: u32,
/// Whether the daemon installs the Claude Code activity hook on first
/// start when none is present (#106): `[hooks].auto_install`, default
/// `true`.
pub hooks_auto_install: bool,
}
impl DaemonConfig {
@@ -197,6 +201,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
spawn_presets,
project_roots,
project_scan_depth,
hooks_auto_install: file.hooks.auto_install(),
})
}
@@ -233,6 +238,27 @@ pub fn system_hostname() -> String {
mod tests {
use super::*;
#[test]
fn hooks_auto_install_defaults_true_and_is_opt_out() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert!(cfg.hooks_auto_install);
std::fs::write(
&path,
r#"
[hooks]
auto_install = false
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert!(!cfg.hooks_auto_install);
}
#[test]
fn empty_file_defaults_everything() {
let dir = tempfile::tempdir().unwrap();
+76
View File
@@ -0,0 +1,76 @@
//! First-start hook auto-install (#106).
//!
//! The daemon runs [`auto_install`] once at startup: when the user's
//! `~/.claude/settings.json` carries no maestro hook yet (and
//! `[hooks].auto_install` hasn't opted out — checked by the caller), the same
//! installer `maestro hooks install` uses writes it. Best-effort: a failure is
//! warned about and never blocks startup. The same global hook serves every
//! Claude Code session on the machine, so nothing here targets repositories
//! or worktrees.
use std::path::Path;
/// Install the Claude Code activity hook for `home_dir` unless one is already
/// present or the home dir is unresolvable (`""`). Returns what happened.
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
if home_dir.is_empty() {
return AutoInstallOutcome::Skipped;
}
let settings = maestro_hooks::settings_path(Path::new(home_dir));
if maestro_hooks::is_installed(&settings) {
return AutoInstallOutcome::AlreadyPresent;
}
match maestro_hooks::install_for_home(Path::new(home_dir)) {
Ok(_) => AutoInstallOutcome::Installed,
Err(e) => {
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
AutoInstallOutcome::Failed
}
}
}
/// What [`auto_install`] did, so callers can log and tests can assert.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoInstallOutcome {
Installed,
AlreadyPresent,
Failed,
Skipped,
}
#[cfg(test)]
mod tests {
use super::*;
/// Everything happens under a temp `$HOME`; the developer's own
/// `~/.claude/settings.json` is never read or written.
#[test]
fn installs_when_settings_carry_no_maestro_hook() {
let dir = tempfile::tempdir().unwrap();
let outcome = auto_install(dir.path().to_str().unwrap());
assert_eq!(outcome, AutoInstallOutcome::Installed);
let settings = dir.path().join(".claude/settings.json");
assert!(maestro_hooks::is_installed(&settings));
}
#[test]
fn second_start_is_a_noop() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
auto_install(home);
// A foreign edit between starts survives; presence alone skips.
let settings = dir.path().join(".claude/settings.json");
let before = std::fs::read_to_string(&settings).unwrap();
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
assert_eq!(
before,
std::fs::read_to_string(&settings).unwrap(),
"already-installed settings must not be rewritten"
);
}
#[test]
fn unresolvable_home_is_skipped() {
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
}
}
+2
View File
@@ -14,6 +14,7 @@ pub mod cli;
pub mod config;
pub mod dirlist;
pub mod gitroot;
pub mod hooks;
pub mod local_api;
pub mod panes;
pub mod proc;
@@ -27,5 +28,6 @@ pub mod tmux;
pub mod worktree;
pub mod worktree_probe;
pub use activity::MAX_REPORT_DETAIL_CHARS;
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
pub use session::{SessionError, SessionManager};
+53 -4
View File
@@ -15,12 +15,12 @@ use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get};
use axum::routing::{any, delete, get, post};
use axum::{Json, Router};
use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
PROTOCOL_VERSION,
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
@@ -46,7 +46,11 @@ pub struct LocalApiState {
pub fn router(state: LocalApiState) -> Router {
Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(
routes::patterns::SESSION,
delete(kill_session).patch(rename_session),
)
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
.route(routes::PROJECTS, get(projects))
@@ -212,6 +216,28 @@ async fn kill_session(
}
}
/// `PATCH /api/sessions/:id` (#125): rename a session. The name is trimmed
/// and capped server-side; a blank one clears `metadata.name` so the session
/// falls back to its command. Answers with the updated session, which also
/// goes out as `SessionUpdated` to every events subscriber.
async fn rename_session(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<RenameSessionRequest>,
) -> Response {
match s.manager.rename(id, &body.name).await {
Ok(session) => (StatusCode::OK, Json(session)).into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
}
}
}
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
/// breakdown recomputed at read time — never stored, so a tree whose work has
/// since been committed or pushed says so. Entries whose directory somebody
@@ -318,6 +344,29 @@ async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
}
}
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
/// The report holds above the daemon's inference for a TTL, then inference
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
/// forever. Unknown session is 404; an unknown state value is rejected by the
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
async fn report_activity(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<ReportActivityRequest>,
) -> Response {
match s.manager.report_activity(id, body.state, body.detail).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
}
}
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response()
}
+35 -13
View File
@@ -261,17 +261,13 @@ impl ProjectScanner {
}
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
/// Also the background timer's entry point: a tick inside the TTL is a
/// no-op (#75).
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
self.repos_inner(false).await
}
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
let mut cache = self.cache.lock().await;
if !force {
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
}
let roots = self.roots.clone();
@@ -287,11 +283,11 @@ impl ProjectScanner {
repos
}
/// Rescan now, ignoring the TTL. Called by the background timer — going
/// through `repos()` would hit the cache and make most ticks no-ops,
/// leaving requests to pay for the rescan instead (#54 review).
/// One background-refresh tick: rescan only if the cached snapshot has
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
/// roots every [`REFRESH_INTERVAL`].
async fn refresh(&self) {
self.repos_inner(true).await;
self.repos().await;
}
/// Spawn the slow background refresh task. Drop the returned handle to
@@ -544,6 +540,32 @@ mod tests {
assert_eq!(fresh[0].name, "late");
}
#[tokio::test]
async fn background_refresh_honours_the_ttl() {
let tmp = tempfile::tempdir().unwrap();
let mut scanner = ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
PathBuf::from("/nonexistent"),
);
let empty = scanner.repos().await;
assert!(empty.is_empty());
make_repo(tmp.path(), "late");
// A background tick inside the TTL must not re-walk the filesystem:
// the new repo stays invisible until the cache expires.
scanner.refresh().await;
assert!(scanner.repos().await.is_empty());
// Once the TTL lapses, the next tick picks it up.
scanner.ttl = Duration::ZERO;
scanner.refresh().await;
let fresh = scanner.repos().await;
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].name, "late");
}
#[test]
fn disabled_scanner_always_serves_empty() {
let scanner = ProjectScanner::disabled();
+269 -4
View File
@@ -33,6 +33,13 @@ pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY;
/// How often the liveness watcher polls tmux for sessions that disappeared.
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
/// How often the liveness watcher re-probes worktrees for the blocked-work
/// indicator (#83). Each probe costs three git subprocesses per session, so
/// the pass rides the same tick as everything else but runs on a slower
/// cadence: an indicator a few seconds stale still warns before the kill
/// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)]
pub struct SessionRecord {
@@ -50,6 +57,16 @@ pub struct SessionRecord {
/// fall back to a probe.
pub worktree_branch: Option<String>,
pub bridge: PtyBridge,
/// Agent-reported activity (#105) still within its TTL. While in force it
/// outranks the activity pass's own inference; when it lapses, inference
/// resumes and [`Session::activity_detail`] is cleared with it. This
/// field is daemon-local bookkeeping only — it never rides the wire.
pub reported_activity: Option<crate::activity::ReportedActivity>,
/// Flipped once the natural-exit settle step (#82) has been applied or
/// refused for this session. `Dead` is observable before settling ends
/// (#115), so this is the observable that says its outcomes are safe to
/// assert on. Daemon-local bookkeeping; never rides the wire.
pub settled: bool,
}
#[derive(Debug, thiserror::Error)]
@@ -124,6 +141,14 @@ struct Inner {
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
/// kill (#81) without one fails rather than destroying unrecorded work.
retained: Option<crate::retained_store::RetainedStore>,
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
/// the variable. A mutex because the runner sets it after construction,
/// possibly while clones are already serving.
daemon_url: std::sync::Mutex<Option<String>>,
/// TTL an agent-reported activity state (#105) stays in force. A const in
/// production; overridable for tests that watch the lapse happen.
report_ttl: Duration,
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>,
}
@@ -163,6 +188,8 @@ impl SessionManager {
replay_bytes,
spawn_history: None,
retained: None,
daemon_url: std::sync::Mutex::new(None),
report_ttl: crate::activity::REPORT_TTL,
sessions: Mutex::new(HashMap::new()),
events,
}),
@@ -195,6 +222,23 @@ impl SessionManager {
self
}
/// Override the agent-report TTL (#105). Tests pass a short one to watch
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_activity_report_ttl must be called before the SessionManager is cloned");
inner.report_ttl = ttl;
self
}
/// Set the daemon's own HTTP base URL (#103), exported to every session
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
/// is a plain setter: the daemon runner learns its bound address after
/// construction, and clones may already exist by then.
pub fn set_daemon_url(&self, url: String) {
*self.inner.daemon_url.lock().expect("daemon_url mutex") = Some(url);
}
/// Recent working dirs for the spawn form's picker (#24), newest-first and
/// deduped by dir. Empty when no history is wired or it's disabled.
pub fn recent_dirs(&self) -> Vec<RecentDir> {
@@ -255,6 +299,17 @@ impl SessionManager {
sessions.get(&id).map(|r| r.session.status)
}
/// Whether the natural-exit settle step (#82) has finished for this
/// session: `Some(true)` once applied or refused, `Some(false)` while
/// still pending, `None` if the session is unknown. `Dead` is observable
/// before settling ends (#115), so anything asserting on a dead
/// session's worktree waits on this rather than inferring settle
/// completion from its side effects.
pub async fn is_settled(&self, id: Uuid) -> Option<bool> {
let sessions = self.inner.sessions.lock().await;
sessions.get(&id).map(|r| r.settled)
}
/// Spawn a new session. On success returns the freshly-created `Session`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
@@ -271,7 +326,11 @@ impl SessionManager {
self.inner.worktree_mgr.add_existing(base_repo, branch)
}
.map_err(SessionError::Worktree)?;
(path.clone(), Some(path), Some(base_repo.clone()))
// Record the repository, not whatever the request named: a
// spawn from inside a linked worktree passes that worktree as
// `base_repo`, and clients group sessions by this path (#99).
let repo = crate::worktree::main_checkout_of(base_repo);
(path.clone(), Some(path), Some(repo))
}
None => (req.cwd.clone(), None, None),
};
@@ -293,7 +352,7 @@ impl SessionManager {
return Err(e.into());
}
};
let session_env = match sanitize_env(&req.env) {
let mut session_env = match sanitize_env(&req.env) {
Ok(env) => env,
Err(e) => {
let _ = std::fs::remove_file(&fifo_path);
@@ -301,6 +360,19 @@ impl SessionManager {
return Err(e);
}
};
// Identity + reporting target for the in-session hook (#106). Ours
// win over whatever the request carried, so a client cannot spoof
// another session's identity (#103).
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
if let Some(url) = self
.inner
.daemon_url
.lock()
.expect("daemon_url mutex")
.clone()
{
session_env.insert("MAESTRO_URL".into(), url);
}
let spawned = {
let tmux = self.inner.tmux.clone();
let cwd = effective_cwd.clone();
@@ -341,9 +413,14 @@ impl SessionManager {
// activity watcher refines `foreground`/`activity` on its next tick.
foreground: None,
activity: ActivityState::Active,
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
// The watcher fills this in on its first blocked-work pass; a
// fresh tree has nothing in the way anyway.
blocked_work: None,
};
if let Err(e) = self.inner.sidecar.write(&session) {
@@ -367,6 +444,8 @@ impl SessionManager {
worktree_base_repo,
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
bridge,
reported_activity: None,
settled: false,
},
);
}
@@ -489,6 +568,17 @@ impl SessionManager {
/// `SessionManager` clone is dropped — call this once from the daemon
/// startup path after [`Self::recover`] has populated the map.
pub fn start_liveness_watcher(&self, interval: Duration) {
self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL);
}
/// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83)
/// made explicit. Tests pass a small `blocked_work_interval` to watch the
/// probe actually happen.
pub fn start_liveness_watcher_with_blocked_work(
&self,
interval: Duration,
blocked_work_interval: Duration,
) {
let weak = Arc::downgrade(&self.inner);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
@@ -496,6 +586,9 @@ impl SessionManager {
// first tick fires immediately — skip it so we don't reap before
// the caller has a chance to create anything.
ticker.tick().await;
// Start "due" so the first live tick probes: a recovered daemon
// should not wait a full interval to show what blocks.
let mut since_probe = blocked_work_interval;
loop {
ticker.tick().await;
let Some(inner) = weak.upgrade() else {
@@ -505,6 +598,11 @@ impl SessionManager {
// Same tick, no new timer (NAP-340): refresh activity for the
// survivors and emit SessionUpdated on any transition.
run_activity_pass(&inner).await;
since_probe += interval;
if since_probe >= blocked_work_interval {
since_probe = Duration::ZERO;
run_blocked_work_pass(&inner).await;
}
}
});
}
@@ -536,11 +634,17 @@ impl SessionManager {
status: SessionStatus::Running,
created_at: Utc::now(),
// Activity is unknown on adoption; the watcher fills it in.
// #104: the restart also resets the activity clock to now —
// accepted, since the daemon cannot know how long a
// recovered session had already been sitting idle.
foreground: None,
activity: ActivityState::Idle,
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
},
};
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -569,6 +673,8 @@ impl SessionManager {
// recovered session probes git for it at retain time.
worktree_branch: None,
bridge,
reported_activity: None,
settled: false,
},
);
let _ = self
@@ -609,6 +715,79 @@ impl SessionManager {
.await
.map_err(SessionError::Tmux)
}
/// Rename a session (#125): set `metadata.name`, or clear it when the
/// requested name is blank so the session falls back to its command.
///
/// The new name is persisted to the sidecar before the event goes out — a
/// rename the operator can see but that a daemon restart would undo is
/// worse than one that failed outright.
pub async fn rename(&self, id: Uuid, name: &str) -> Result<Session, SessionError> {
// The sidecar write happens under the sessions lock so what is on disk
// and what is in memory can never disagree about the newest rename.
let session = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
let mut renamed = rec.session.clone();
match maestro_protocol::normalize_session_name(name) {
Some(name) => renamed
.metadata
.insert("name".into(), serde_json::Value::String(name)),
None => renamed.metadata.remove("name"),
};
self.inner
.sidecar
.write(&renamed)
.map_err(SessionError::Sidecar)?;
rec.session = renamed.clone();
renamed
};
let _ = self.inner.events.send(CatalogEvent::SessionUpdated {
session: session.clone(),
});
Ok(session)
}
/// Accept an agent-reported activity state (#105), the only path that can
/// produce `ActivityState::Error`. The report outranks the activity pass's
/// own inference until its TTL lapses — so an agent that dies mid-run
/// cannot pin its session at `Error` forever. An unknown session is
/// [`SessionError::NotFound`]; the reported `detail` is capped
/// server-side.
///
/// A report that changes the effective state restarts `activity_since`
/// (#104's clock); re-reporting an unchanged state only extends the TTL
/// and stays silent on the event stream at steady state.
pub async fn report_activity(
&self,
id: Uuid,
state: ActivityState,
detail: Option<String>,
) -> Result<(), SessionError> {
let updated = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
let detail = crate::activity::truncate_detail(detail);
let changed = rec.session.activity != state || rec.session.activity_detail != detail;
if rec.session.activity != state {
rec.session.activity_since = Utc::now();
}
rec.session.activity = state;
rec.session.activity_detail = detail;
rec.reported_activity = Some(crate::activity::ReportedActivity::new(
state,
self.inner.report_ttl,
));
changed.then(|| rec.session.clone())
};
if let Some(session) = updated {
let _ = self
.inner
.events
.send(CatalogEvent::SessionUpdated { session });
}
Ok(())
}
}
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
@@ -779,8 +958,22 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
let Some(rec) = sessions.get_mut(&id) else {
continue;
};
// #105: an agent report lapses at its TTL — drop it and its
// detail so this tick's inference takes over again.
if let Some(report) = &rec.reported_activity {
if !report.in_force(std::time::Instant::now()) {
rec.reported_activity = None;
rec.session.activity_detail = None;
}
}
let idle = rec.bridge.idle_for();
let activity = crate::activity::decide(idle, awaiting);
// #105: while a report is in force it supplies the state and
// nothing here may overwrite it; only an unreported session is
// decided by inference.
let activity = match &rec.reported_activity {
Some(report) => report.state,
None => crate::activity::decide(idle, awaiting),
};
let foreground = Some(pane.command.clone());
// The pane's live cwd, drives the dir-based session tree; empty means
// tmux reported none.
@@ -794,6 +987,12 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
{
None
} else {
// #104: stamp only when the state itself changed — never on
// foreground/cwd-only diffs, and never every tick, or the
// sidebar's duration would reset continuously and read zero.
if rec.session.activity != activity {
rec.session.activity_since = Utc::now();
}
rec.session.activity = activity;
rec.session.foreground = foreground;
rec.session.current_cwd = current_cwd;
@@ -809,6 +1008,61 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
}
}
/// Refresh each running session's blocked-work indicator (#83): what its
/// worktree holds right now that would block an unforced kill. Rides the
/// liveness tick on its own (slower) cadence — see
/// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git
/// subprocesses per session.
///
/// Sessions without a worktree are filtered out BEFORE any probe: they can
/// never block a kill, so their indicator must cost nothing at all. A failed
/// probe keeps the previous value rather than clearing it — a transient git
/// error must not read as "nothing blocks". Like the activity pass, this only
/// emits `SessionUpdated` when the value actually changed.
async fn run_blocked_work_pass(inner: &Arc<Inner>) {
let targets: Vec<(Uuid, (PathBuf, PathBuf))> = {
let sessions = inner.sessions.lock().await;
sessions
.iter()
.filter(|(_, r)| r.session.status == SessionStatus::Running)
.filter_map(|(id, r)| {
let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?;
Some((*id, wt))
})
.collect()
};
if targets.is_empty() {
return;
}
for (id, wt) in targets {
// The kill guard's own probe (#79), so the indicator can never
// disagree with what a kill attempt would find.
let breakdown = match probe_worktree(&wt).await {
Ok(breakdown) => breakdown,
Err(e) => {
tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value");
continue;
}
};
let updated = {
let mut sessions = inner.sessions.lock().await;
let Some(rec) = sessions.get_mut(&id) else {
continue;
};
if rec.session.blocked_work == Some(breakdown) {
None
} else {
rec.session.blocked_work = Some(breakdown);
Some(rec.session.clone())
}
};
if let Some(session) = updated {
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
}
}
}
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
/// and settle its worktree per the natural-exit rule (#82).
///
@@ -852,16 +1106,27 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt, branch).await {
session.worktree = None;
// The tree is settled (removed or retained); a stale "in the
// way" would describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
record.session.blocked_work = None;
record.worktree_path = None;
record.worktree_base_repo = None;
record.worktree_branch = None;
}
}
}
// Settle has now been applied or refused either way (#115): from here on
// its outcomes are safe to observe.
{
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.settled = true;
}
}
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
+19
View File
@@ -264,6 +264,25 @@ impl Tmux {
check(out)
}
/// `tmux show-environment -t maestro-<id>`, parsed into KEY=VALUE pairs.
/// Test-facing view of what a spawned session actually inherits.
pub fn show_environment(&self, id: Uuid) -> Result<BTreeMap<String, String>, TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args(["show-environment", "-t", &name])
.output()?;
check(out.clone())?;
let stdout = String::from_utf8_lossy(&out.stdout);
let mut env = BTreeMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
env.insert(k.to_string(), v.to_string());
}
}
Ok(env)
}
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
pub fn kill_server(&self) -> Result<(), TmuxError> {
let out = self.cmd().arg("kill-server").output()?;
+7
View File
@@ -174,6 +174,13 @@ impl WorktreeManager {
/// The repository's main checkout. `--git-common-dir` points at the shared
/// `.git` for every linked worktree, so its parent is the main tree. Falls
/// back to the input when git can't answer.
/// The repository a path belongs to: the main checkout, even when `path` is a
/// linked worktree. Clients group sessions by this (#99), and placement uses it
/// so a worktree is never nested inside another.
pub fn main_checkout_of(path: &Path) -> PathBuf {
main_checkout(path)
}
fn main_checkout(path: &Path) -> PathBuf {
let out = Command::new("git")
.arg("-C")
+19 -4
View File
@@ -189,8 +189,23 @@ fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
"never observed a foreground group whose leader had exited"
);
assert!(
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
"a surviving pipeline member still holds the tty"
);
// The leader can be gone while the surviving stage is still setting up:
// `true` exits as soon as both stages are forked, before the reader has
// opened the tty and parked. Poll for the answer, but only accept it
// while the state this test exists for still holds — otherwise a shell
// that regained the terminal would satisfy it vacuously.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut holds = false;
while std::time::Instant::now() < deadline {
let groups = ProcGroups::snapshot();
let still_leaderless = pgrp_and_tpgid(pane.pane_pid).is_some_and(|(pgrp, tpgid)| {
tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none()
});
if still_leaderless && awaiting_input(pane.pane_pid, &pane.tty, &groups) {
holds = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(holds, "a surviving pipeline member still holds the tty");
}
@@ -0,0 +1,164 @@
//! Agent-reported activity (#105): `POST /api/sessions/:id/activity`
//! semantics at the SessionManager level — precedence over the daemon's own
//! inference, detail handling, and lapse back to inference at the TTL.
mod common;
use std::sync::Arc;
use std::time::Duration;
use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{ActivityState, CreateSessionRequest};
use tempfile::TempDir;
use tokio::time::sleep;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
_tmux: TmuxFixture,
_state_dir: TempDir,
}
async fn fixture(ttl: Duration) -> Fixture {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
SidecarStore::new(state_dir.path().join("sessions")),
WorktreeManager::new(None),
state_dir.path().join("fifos"),
)
.with_activity_report_ttl(ttl);
Fixture {
manager,
_tmux: tmux,
_state_dir: state_dir,
}
}
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
})
.await
.unwrap()
.id
}
async fn observed(manager: &SessionManager, id: Uuid) -> maestro_protocol::Session {
manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == id)
.expect("session still tracked")
}
#[tokio::test]
async fn report_on_unknown_session_is_not_found() {
let fx = fixture(Duration::from_secs(300)).await;
let err = fx
.manager
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
.await
.unwrap_err();
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
}
#[tokio::test]
async fn report_sets_error_state_and_detail() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(
id,
ActivityState::Error,
Some("cargo build failed: link error".into()),
)
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.activity, ActivityState::Error);
assert_eq!(
s.activity_detail.as_deref(),
Some("cargo build failed: link error")
);
}
#[tokio::test]
async fn replacing_a_report_replaces_both_state_and_detail() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Error, Some("first failure".into()))
.await
.unwrap();
fx.manager
.report_activity(
id,
ActivityState::AwaitingInput,
Some("needs approval".into()),
)
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.activity, ActivityState::AwaitingInput);
assert_eq!(s.activity_detail.as_deref(), Some("needs approval"));
}
#[tokio::test]
async fn detail_is_capped_server_side() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
let long = "x".repeat(10_000);
fx.manager
.report_activity(id, ActivityState::Error, Some(long))
.await
.unwrap();
let s = observed(&fx.manager, id).await;
let detail = s.activity_detail.expect("detail present");
assert_eq!(
detail.chars().count(),
maestro_daemon::MAX_REPORT_DETAIL_CHARS
);
}
#[tokio::test]
async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resumes() {
let fx = fixture(Duration::from_millis(150)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager.start_liveness_watcher(Duration::from_millis(50));
fx.manager
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
.await
.unwrap();
// Several watcher ticks inside the TTL: nothing infers `Error`, so the
// report must hold above inference.
sleep(Duration::from_millis(80)).await;
let s = observed(&fx.manager, id).await;
assert_eq!(s.activity, ActivityState::Error);
assert_eq!(s.activity_detail.as_deref(), Some("agent died mid-run"));
// After the TTL lapses, inference resumes and the detail goes with it.
common::wait_for_async(Duration::from_secs(5), || async {
let s = observed(&fx.manager, id).await;
s.activity != ActivityState::Error && s.activity_detail.is_none()
})
.await;
}
@@ -0,0 +1,116 @@
//! The performance contract of #83: the blocked-work pass must cost NO git
//! probe at all for a session without a worktree, while a session with one is
//! still probed.
//!
//! `git` itself is shimmed through PATH with a wrapper that logs every
//! invocation before exec'ing the real binary, so the assertion counts actual
//! probes rather than inferring them from state. This lives in its own test
//! binary because it mutates the process environment.
mod common;
use std::collections::HashMap;
use std::process::Command;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: cwd.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.to_path_buf(),
branch: branch.into(),
create_branch: true,
}),
}
}
#[tokio::test]
async fn a_session_without_a_worktree_costs_no_probe() {
let fx = DaemonFixture::new();
let log = TempDir::new().unwrap();
let log_path = log.path().join("git-calls.log");
// Session A owns a real (pristine, pushed) worktree; session B has none.
let repo_a = TempDir::new().unwrap();
init_repo(repo_a.path());
init_bare_repo(&repo_a.path().join("origin.git"));
push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git"));
fx.manager
.create(request(repo_a.path(), repo_a.path(), "feat/probed"))
.await
.expect("create A");
let cwd_b = TempDir::new().unwrap();
let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused");
req_b.worktree = None;
fx.manager.create(req_b).await.expect("create B");
// Shim `git` AFTER setup so only watcher-driven probes get logged.
let real_git = String::from_utf8(
Command::new("sh")
.arg("-c")
.arg("command -v git")
.output()
.expect("locate git")
.stdout,
)
.unwrap()
.trim()
.to_string();
let shim_dir = TempDir::new().unwrap();
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n",
log_path.display(),
real_git,
);
std::fs::write(shim_dir.path().join("git"), script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
shim_dir.path().join("git"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
}
let old_path = std::env::var("PATH").unwrap_or_default();
std::env::set_var(
"PATH",
format!("{}:{}", shim_dir.path().display(), old_path),
);
fx.manager.start_liveness_watcher_with_blocked_work(
Duration::from_millis(50),
Duration::from_millis(100),
);
// The pass demonstrably runs and probed A's tree...
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.snapshot()
.await
.iter()
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
})
.await;
std::env::set_var("PATH", &old_path);
let calls = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
!calls.is_empty(),
"the pass never probed anything — the shim saw no git call at all"
);
// ...while B — no worktree, so nothing to probe — was never touched.
assert!(
!calls.contains(cwd_b.path().to_str().unwrap()),
"a worktree-less session was probed:\n{calls}"
);
}
+145 -2
View File
@@ -323,6 +323,48 @@ async fn delete_unknown_session_returns_404() {
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
/// #125: the rename endpoint answers with the updated session, and the name
/// it stored is the trimmed one.
#[tokio::test]
async fn patch_session_renames_it() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let resp = reqwest::Client::new()
.patch(url(h.addr, &format!("/api/sessions/{}", session.id)))
.json(&serde_json::json!({ "name": " crimson-otter " }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let updated: Session = resp.json().await.unwrap();
assert_eq!(
updated.metadata.get("name").and_then(|v| v.as_str()),
Some("crimson-otter")
);
}
#[tokio::test]
async fn patch_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.patch(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
.json(&serde_json::json!({ "name": "nope" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_owned_session_kills_it() {
let h = spawn("naps-laptop").await;
@@ -346,6 +388,109 @@ async fn delete_owned_session_kills_it() {
assert!(!h.manager.has(session.id).await);
}
/// Spawn a session through the manager and return its id.
async fn spawn_via_manager(h: &Harness) -> Uuid {
h.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap()
.id
}
/// The agent activity endpoint on the wire (#105): a report lands in the
/// session snapshot with its detail.
#[tokio::test]
async fn post_activity_reports_error_with_detail() {
let h = spawn("naps-laptop").await;
let id = spawn_via_manager(&h).await;
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({
"state": "Error",
"detail": "cargo build failed"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let s = sessions.iter().find(|s| s.id == id).unwrap();
assert_eq!(s.activity, maestro_protocol::ActivityState::Error);
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
}
#[tokio::test]
async fn post_activity_to_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.post(url(
h.addr,
&format!("/api/sessions/{}/activity", Uuid::new_v4()),
))
.json(&serde_json::json!({ "state": "Idle" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
/// Unknown state values are rejected rather than coerced (#105).
#[tokio::test]
async fn post_activity_rejects_an_unknown_state_value() {
let h = spawn("naps-laptop").await;
let id = spawn_via_manager(&h).await;
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({ "state": "Teleported" }))
.send()
.await
.unwrap();
assert!(resp.status().is_client_error(), "got {}", resp.status());
}
#[tokio::test]
async fn post_activity_caps_an_overlong_detail_server_side() {
let h = spawn("naps-laptop").await;
let id = spawn_via_manager(&h).await;
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({
"state": "Error",
"detail": "x".repeat(10_000)
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let s = sessions.iter().find(|s| s.id == id).unwrap();
assert_eq!(
s.activity_detail.as_ref().map(|d| d.chars().count()),
Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS)
);
}
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
/// with the structured breakdown, and the session keeps running.
#[tokio::test]
@@ -363,7 +508,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -417,7 +561,6 @@ async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
+138
View File
@@ -0,0 +1,138 @@
//! Renaming a live session (#125): `SessionManager::rename` semantics —
//! what lands in `metadata.name`, what reaches the sidecar, and what goes out
//! on the event stream.
mod common;
use std::sync::Arc;
use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{CatalogEvent, CreateSessionRequest, MAX_SESSION_NAME_LEN};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
_state_dir: TempDir,
}
fn fixture() -> Fixture {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
sidecar.clone(),
WorktreeManager::new(None),
state_dir.path().join("fifos"),
);
Fixture {
manager,
sidecar,
_tmux: tmux,
_state_dir: state_dir,
}
}
async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
let metadata = match name {
Some(n) => std::collections::HashMap::from([(
"name".to_string(),
serde_json::Value::String(n.to_string()),
)]),
None => Default::default(),
};
manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
metadata,
worktree: None,
})
.await
.unwrap()
.id
}
fn name_of(session: &maestro_protocol::Session) -> Option<&str> {
session.metadata.get("name").and_then(|v| v.as_str())
}
#[tokio::test]
async fn rename_on_unknown_session_is_not_found() {
let fx = fixture();
let err = fx
.manager
.rename(Uuid::new_v4(), "whatever")
.await
.unwrap_err();
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
}
#[tokio::test]
async fn rename_sets_the_name_in_memory_and_on_disk() {
let fx = fixture();
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
let renamed = fx.manager.rename(id, " crimson-otter ").await.unwrap();
assert_eq!(name_of(&renamed), Some("crimson-otter"));
let snapshot = fx.manager.snapshot().await;
let live = snapshot.iter().find(|s| s.id == id).unwrap();
assert_eq!(name_of(live), Some("crimson-otter"));
// A rename a daemon restart would undo is worse than one that failed.
let persisted = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(name_of(&persisted), Some("crimson-otter"));
}
#[tokio::test]
async fn a_blank_rename_clears_the_name() {
let fx = fixture();
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
let renamed = fx.manager.rename(id, " ").await.unwrap();
assert_eq!(name_of(&renamed), None);
// Line 1 falls back to the command, per docs/SIDEBAR.md.
assert_eq!(renamed.command, "sleep 60");
assert_eq!(name_of(&fx.sidecar.read(id).unwrap().unwrap()), None);
}
#[tokio::test]
async fn rename_caps_a_long_name() {
let fx = fixture();
let id = spawn_named(&fx.manager, None).await;
let long = "x".repeat(MAX_SESSION_NAME_LEN + 20);
let renamed = fx.manager.rename(id, &long).await.unwrap();
assert_eq!(
name_of(&renamed).unwrap().chars().count(),
MAX_SESSION_NAME_LEN
);
}
#[tokio::test]
async fn rename_broadcasts_a_session_update() {
let fx = fixture();
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
let mut events = fx.manager.events();
fx.manager.rename(id, "crimson-otter").await.unwrap();
let event = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
.await
.expect("an update within the timeout")
.expect("the event channel stays open");
match event {
CatalogEvent::SessionUpdated { session } => {
assert_eq!(session.id, id);
assert_eq!(name_of(&session), Some("crimson-otter"));
}
other => panic!("expected SessionUpdated, got {other:?}"),
}
}
@@ -30,6 +30,24 @@ impl Fx {
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
}
/// Variant whose manager knows the daemon's own base URL (#103), the way
/// the production runner wires it from `[daemon].bind_addr`.
fn with_daemon_url(url: &str) -> Self {
let fx = Self::new();
fx.manager.set_daemon_url(url.to_string());
fx
}
/// The env map of the (single, expected) spawn call.
fn spawned_env(&self) -> BTreeMap<String, String> {
let calls = self.fake.calls();
let Some(Call::NewSessionPiped { env, .. }) = calls.first() else {
panic!("expected a spawn call, got {calls:?}");
};
assert_eq!(calls.len(), 1, "expected exactly one spawn: {calls:?}");
env.clone()
}
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
let tmp = TempDir::new().expect("tempdir");
let fake = Arc::new(FakePanes::new());
@@ -261,16 +279,23 @@ async fn requested_env_reaches_tmux() {
fx.manager.create(req).await.expect("create");
let spawned = match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => env.clone(),
other => panic!("expected a spawn call, got {other:?}"),
};
let id = spawned
.get("MAESTRO_SESSION_ID")
.expect("spawn must export MAESTRO_SESSION_ID (#103)")
.clone();
// The daemon-owned variable rides alongside whatever was requested.
let want: BTreeMap<String, String> = [
("EMPTY".to_string(), String::new()),
("FOO".to_string(), "bar".to_string()),
("MAESTRO_SESSION_ID".to_string(), id),
]
.into_iter()
.collect();
match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
other => panic!("expected a spawn call, got {other:?}"),
}
assert_eq!(&spawned, &want);
}
#[tokio::test]
@@ -338,6 +363,9 @@ async fn activity_pass_emits_updated_only_when_something_changed() {
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
// change here is `foreground`, not the activity state.
assert_eq!(updated.activity, ActivityState::Active);
// A foreground/cwd-only change is not a state transition (#104): the
// sidebar's duration must keep counting from the original stamp.
assert_eq!(updated.activity_since, session.activity_since);
// Drain anything already queued, then confirm a steady pane stays quiet.
// Stays well inside the 3s window, so no Active -> Idle transition either.
@@ -349,6 +377,70 @@ async fn activity_pass_emits_updated_only_when_something_changed() {
);
}
/// The sidebar's duration (#104) is time in the *current* state, so
/// `activity_since` is stamped exactly when the decided state differs from
/// the stored one — once per transition. Stamping it every tick would make
/// the duration permanently read zero.
#[tokio::test]
async fn activity_transition_stamps_activity_since_once_per_transition() {
let fx = Fx::new();
let session = fx
.manager
.create(fx.request("sleep 300"))
.await
.expect("create");
fx.fake.set_pane_info(
session.id,
PaneInfo {
// Nothing reads from this pid: `awaiting_input` is deterministically
// false, so past `ACTIVE_WINDOW` the session decides as Idle.
pane_pid: i32::MAX,
command: "sleep".into(),
tty: "/dev/null".into(),
current_path: String::new(),
},
);
let mut events = fx.manager.events();
fx.manager.start_liveness_watcher(Duration::from_millis(25));
let idle = wait_for_activity(&fx.manager, session.id, ActivityState::Idle).await;
assert!(
idle.activity_since >= session.activity_since + chrono::Duration::seconds(2),
"the Active -> Idle transition must stamp a fresh activity_since \
(spawn stamp {:?}, transition stamp {:?})",
session.activity_since,
idle.activity_since
);
// Steady Idle across many more ticks must not re-stamp — no further
// SessionUpdated at all, or the sidebar clock would keep resetting.
while events.try_recv().is_ok() {}
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
matches!(events.try_recv(), Err(TryRecvError::Empty)),
"a steady state must not re-emit (and re-stamp) on every tick"
);
}
async fn wait_for_activity(
manager: &SessionManager,
id: uuid::Uuid,
want: ActivityState,
) -> maestro_protocol::Session {
let deadline = tokio::time::Instant::now() + Duration::from_secs(6);
loop {
if tokio::time::Instant::now() >= deadline {
panic!("timed out waiting for {id} to become {want:?}");
}
if let Some(s) = manager.snapshot().await.into_iter().find(|s| s.id == id) {
if s.activity == want {
return s;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
async fn wait_for_updated(
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
) -> maestro_protocol::Session {
@@ -396,3 +488,70 @@ fn attaching_spawn_history_after_cloning_panics() {
7,
));
}
/// Every tmux-spawned session learns who it is (#103): its own UUID under
/// `MAESTRO_SESSION_ID`, so the reporting hook in #106 can name the session
/// it is running inside.
#[tokio::test]
async fn spawn_exports_maestro_session_id() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert_eq!(
env.get("MAESTRO_SESSION_ID").map(String::as_str),
Some(&s.id.to_string()[..]),
"MAESTRO_SESSION_ID must be the session's UUID, got {env:?}"
);
}
/// The hook also needs to know where to report to: `MAESTRO_URL` carries the
/// daemon's base URL derived from `[daemon].bind_addr` (#103).
#[tokio::test]
async fn spawn_exports_maestro_url_when_the_daemon_is_wired() {
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert_eq!(
env.get("MAESTRO_URL").map(String::as_str),
Some("http://127.0.0.1:7070"),
"MAESTRO_URL must be the daemon's base URL, got {env:?}"
);
}
/// Without a wired URL (tests, or a caller that never set one) only the
/// session id is injected — an empty or bogus `MAESTRO_URL` would send the
/// #106 hook's reports nowhere useful.
#[tokio::test]
async fn spawn_omits_maestro_url_when_not_wired() {
let fx = Fx::new();
fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert!(
!env.contains_key("MAESTRO_URL"),
"MAESTRO_URL must be absent when no daemon URL is known, got {env:?}"
);
}
/// The daemon-owned variables win over anything the spawner sent: a client
/// cannot spoof another session's identity.
#[tokio::test]
async fn spawn_overrides_client_supplied_maestro_variables() {
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
let mut req = fx.request("bash");
req.env.insert(
"MAESTRO_SESSION_ID".into(),
"00000000-0000-0000-0000-000000000000".into(),
);
req.env.insert("MAESTRO_URL".into(), "http://evil:1".into());
let s = fx.manager.create(req).await.expect("create");
let env = fx.spawned_env();
assert_eq!(env.get("MAESTRO_SESSION_ID"), Some(&s.id.to_string()));
assert_eq!(
env.get("MAESTRO_URL"),
Some(&"http://127.0.0.1:7070".to_string())
);
}
+218 -10
View File
@@ -367,6 +367,97 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
}
/// #83: the watcher refreshes `Session.blocked_work` on its own cadence —
/// first "nothing blocks" for a pristine tree, then the breakdown once the
/// tree picks up unsaved work, so clients can warn before the kill attempt.
#[tokio::test]
async fn watcher_refreshes_the_blocked_work_indicator() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("indicator");
fx.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/indicator".into(),
create_branch: true,
}),
})
.await
.expect("create");
// Pushed and clean: nothing in the way, and the indicator must say so.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
// Subscribe before the watcher starts so every pass's emission is
// captured — otherwise the breakdown event can land between the
// snapshot observation below and the drain, and the quiet window
// would see it as an illegitimate re-emit.
let mut events = fx.manager.events();
fx.manager.start_liveness_watcher_with_blocked_work(
Duration::from_millis(50),
Duration::from_millis(100),
);
wait_for_async(Duration::from_secs(5), || async {
fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default())
})
.await;
// Unsaved work appears; the next pass must surface it.
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
wait_for_async(Duration::from_secs(5), || async {
matches!(
&fx.manager.snapshot().await[0].blocked_work,
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
)
})
.await;
// Consume through the emission that carried the breakdown (#83
// change-detection): the quiet window below must start after the last
// legitimate change, not race it into the channel.
loop {
match tokio::time::timeout(Duration::from_secs(5), events.recv()).await {
Ok(Ok(CatalogEvent::SessionUpdated { session }))
if matches!(
&session.blocked_work,
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
) =>
{
break;
}
Ok(Ok(_)) => continue,
_ => panic!("never saw the SessionUpdated carrying the breakdown"),
}
}
while events.try_recv().is_ok() {}
tokio::time::sleep(Duration::from_millis(400)).await;
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
// The activity pass may still legitimately announce e.g. Active→Idle
// (its debounce is time-based, not worktree-based), and its payload
// carries today's blocked_work unchanged.
while let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
assert!(
matches!(
&session.blocked_work,
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
),
"an unchanged worktree must not re-emit a changed indicator"
);
}
}
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
/// that says "don't clean up" loses its worktree — the branch was pushed, so
/// nothing in the tree is unique and removal goes ahead.
@@ -387,7 +478,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/keep".into(),
cleanup_on_kill: false,
create_branch: true,
}),
})
@@ -425,7 +515,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -489,7 +578,6 @@ async fn killing_refuses_until_the_branch_is_pushed() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/pushed".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -541,7 +629,6 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/forced".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -607,7 +694,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/clear".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -650,7 +736,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/rusty".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -693,7 +778,6 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/exited".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -715,6 +799,14 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
"a dirty worktree must be retained on natural exit"
);
// ...in the store, under the exit reason.
// (`Dead` is observable a moment before the settle step's store write lands.)
wait_for_async(Duration::from_secs(3), || async {
fx.retained_store()
.load_all()
.map(|all| all.len() == 1)
.unwrap_or(false)
})
.await;
let all = fx.retained_store().load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].path, wt_path);
@@ -728,7 +820,8 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
// settled, so dismissal is neither refused nor retained twice.
// (`Dead` is observable a moment before the settle step finishes.)
wait_for_async(Duration::from_secs(3), || async {
fx.manager.snapshot().await[0].worktree.is_none()
let s = &fx.manager.snapshot().await[0];
s.worktree.is_none() && s.blocked_work.is_none()
})
.await;
let snap = fx.manager.snapshot().await;
@@ -767,7 +860,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/clean".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -786,6 +878,17 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
})
.await;
// (`Dead` is observable a moment before the settle step removes the tree.)
wait_for_async(Duration::from_secs(3), || async {
!wt_path.exists()
&& fx
.retained_store()
.load_all()
.map(|all| all.is_empty())
.unwrap_or(false)
})
.await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
@@ -829,7 +932,6 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/nostore".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -844,6 +946,14 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
})
.await;
// The settle step has been applied or refused either way — here refused,
// since there is no store to record the tree into. Only from this point
// is "the tree survived" a settled fact rather than a race (#115).
wait_for_async(Duration::from_secs(3), || async {
manager.is_settled(s.id).await == Some(true)
})
.await;
assert!(
wt_path.exists(),
"nothing may be destroyed when it cannot be recorded"
@@ -894,3 +1004,101 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
fx.manager.kill(s.id, false).await.unwrap();
}
/// #99: clients group a session under `worktree.base_repo`, so that field has
/// to name the repository even when the spawn request pointed at a linked
/// worktree. Recording the request verbatim put the session in a folder of its
/// own — `<repo>/worktrees/<branch>` — instead of under the repo.
#[tokio::test]
async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
// A linked worktree of that repo, then a spawn whose `base_repo` names it
// rather than the main checkout — what happens when the operator spawns
// from inside a worktree.
let first = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/one".into(),
create_branch: true,
}),
})
.await
.expect("create first");
let inner_worktree = first.worktree.expect("first has a worktree").path;
let second = fx
.manager
.create(CreateSessionRequest {
cwd: inner_worktree.clone(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: inner_worktree.clone(),
branch: "feat/two".into(),
create_branch: true,
}),
})
.await
.expect("create second");
let wt = second.worktree.expect("second has a worktree");
assert_eq!(
wt.base_repo,
repo.path()
.canonicalize()
.unwrap_or_else(|_| repo.path().to_path_buf()),
"base_repo must be the repository, not the worktree the request named",
);
assert_ne!(wt.base_repo, inner_worktree, "the worktree is not the repo");
}
/// #103: every tmux-spawned session carries the daemon's identity variables in
/// its environment — its UUID under `MAESTRO_SESSION_ID` and the daemon's base
/// URL under `MAESTRO_URL` — so the in-session reporting hook (#106) can find
/// both without any out-of-band channel.
#[tokio::test]
async fn spawned_sessions_export_maestro_session_id_and_url() {
let fx = DaemonFixture::new();
fx.manager
.set_daemon_url("http://127.0.0.1:7071".to_string());
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let env = fx
.tmux
.tmux
.show_environment(s.id)
.expect("show-environment");
assert_eq!(
env.get("MAESTRO_SESSION_ID"),
Some(&s.id.to_string()),
"session env: {env:?}"
);
assert_eq!(
env.get("MAESTRO_URL"),
Some(&"http://127.0.0.1:7071".to_string())
);
fx.manager.kill(s.id, false).await.expect("kill");
}
+3
View File
@@ -26,8 +26,11 @@ fn sample(id: Uuid) -> Session {
.with_timezone(&Utc),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
}
}
+16 -1
View File
@@ -12,7 +12,8 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
RetainedWorktree, Session,
};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -64,6 +65,20 @@ impl TestClient {
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
}
/// Kill with `force=true`: overrides a kill refusal and retains the
/// guarded worktree (#81). Answers with what was kept.
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
Ok(self.daemon.kill_session(id, true).await?)
}
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
Ok(self.daemon.list_worktrees().await?)
}
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
Ok(self.daemon.reclaim_worktree(id).await?)
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
Ok(self.daemon.list_projects().await?)
}
+522 -33
View File
@@ -35,6 +35,17 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
}
}
/// A worktree spec built through its wire shape. The daemon and clients ship
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
/// object carries only the fields the lifecycle actually honours.
fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool) -> WorktreeSpec {
WorktreeSpec {
base_repo: base_repo.to_path_buf(),
branch: branch.into(),
create_branch,
}
}
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
@@ -42,12 +53,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.to_path_buf(),
branch: branch.into(),
cleanup_on_kill: true,
create_branch,
}),
worktree: Some(worktree_spec(base, branch, create_branch)),
}
}
@@ -141,12 +147,7 @@ async fn kill_removes_clean_worktree() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/e2e".into(),
cleanup_on_kill: true,
create_branch: true,
}),
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -160,7 +161,7 @@ async fn kill_removes_clean_worktree() {
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
// Give the daemon a moment to remove the worktree, then verify.
@@ -199,12 +200,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/unsaved".into(),
cleanup_on_kill: true,
create_branch: true,
}),
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -262,12 +258,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/existing".into(),
cleanup_on_kill: true,
create_branch: false,
}),
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
};
let s = client
@@ -280,7 +271,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -328,7 +319,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
)
.await;
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -376,7 +367,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
"original worktree must be untouched"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill");
}
@@ -419,10 +410,481 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
"worktrees must never nest inside another worktree"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
// ---------------------------------------------------------------------------
// Worktree lifecycle: collisions, retention, reclaim (#88)
// ---------------------------------------------------------------------------
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
/// registered worktree the operator made by hand. The daemon sidesteps to the
/// suffixed directory and never touches the occupant — and both trees keep
/// working: two registered worktrees, two sessions, each running in its own.
#[tokio::test(flavor = "multi_thread")]
async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// An operator-made worktree sits exactly where `feature/collide` would go.
// Its branch is a different one, so the requested branch stays creatable.
let squatter = base
.path()
.join("worktrees")
.join("feature")
.join("collide");
run_git(
&[
"worktree",
"add",
"-q",
"-b",
"operator/manual",
&squatter.to_string_lossy(),
],
base.path(),
);
let s = client
.spawn_session(&worktree_req(base.path(), "feature/collide", true))
.await
.expect("spawn onto occupied path");
let suffixed = base
.path()
.join("worktrees")
.join("feature")
.join("collide-2");
assert_eq!(
s.cwd, suffixed,
"the daemon must take the suffixed directory, not the occupied one"
);
assert!(suffixed.is_dir(), "suffixed worktree must exist on disk");
// Both trees are registered, distinct, and the squatter is byte-intact.
let registrations = git_worktree_list(base.path());
assert!(
registrations
.iter()
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
"operator's worktree must stay registered: {registrations:?}"
);
assert!(
registrations.iter().any(|l| l.contains("collide-2")),
"suffixed worktree must be registered too: {registrations:?}"
);
// Both sessions run inside their own tree at the same time.
let sessions = client.list_sessions().await.expect("list");
assert!(
sessions.iter().any(|x| x.id == s.id && x.cwd == suffixed),
"session must keep running in its suffixed tree"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
// Only the daemon-made trees go; the operator's manual one survives.
let after = git_worktree_list(base.path());
assert!(
after
.iter()
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
"operator's worktree must outlive the sessions: {after:?}"
);
}
/// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone.
#[tokio::test(flavor = "multi_thread")]
async fn foreign_directory_is_sidestepped_never_adopted() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let squatter = base
.path()
.join("worktrees")
.join("feature")
.join("foreign");
std::fs::create_dir_all(&squatter).unwrap();
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
let s = client
.spawn_session(&worktree_req(base.path(), "feature/foreign", true))
.await
.expect("spawn must sidestep, not adopt or fail");
let suffixed = base
.path()
.join("worktrees")
.join("feature")
.join("foreign-2");
assert_eq!(s.cwd, suffixed, "session must run in the suffixed tree");
assert_eq!(
std::fs::read(squatter.join("operator-notes.txt")).unwrap(),
b"mine\n",
"the foreign directory must be untouched"
);
let registrations = git_worktree_list(base.path());
let registered_paths: Vec<&str> = registrations
.iter()
.filter_map(|l| l.strip_prefix("worktree "))
.collect();
assert!(
!registered_paths
.iter()
.any(|p| *p == squatter.canonicalize().unwrap().display().to_string()),
"a plain directory must never be adopted as a worktree: {registered_paths:?}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
assert!(squatter.join("operator-notes.txt").exists());
}
/// #79/#88: killing with uncommitted changes is refused; committing and
/// pushing that work clears the guard and the very same kill then succeeds
/// and removes the tree. The session is the handle on the work: while it is
/// unsaved the session stays, once it is saved the tree goes.
#[tokio::test(flavor = "multi_thread")]
async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("guard");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/guard", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"work in progress\n").unwrap();
// Refused: the edit is uncommitted.
let err = client
.kill_session(s.id)
.await
.expect_err("kill must refuse");
match err {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.unpushed_commits, 0);
}
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
assert!(
client
.list_sessions()
.await
.unwrap()
.iter()
.any(|x| x.id == s.id),
"a refused kill keeps the session running"
);
assert_eq!(
std::fs::read(wt.join("README")).unwrap(),
b"work in progress\n",
"the worktree is untouched by the refusal"
);
// The operator does exactly what the refusal asked for: save and push.
commit_all(&wt, "wip");
push_all(base.path());
client.kill_session(s.id).await.expect("kill after saving");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if !wt.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(
!wt.exists(),
"a cleared worktree must be removed after kill"
);
let registrations = git_worktree_list(base.path());
assert!(
!registrations.iter().any(|l| l.contains("feature/guard")),
"removal must deregister the tree: {registrations:?}"
);
}
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
/// session anyway but RETAINS the worktree — force never destroys blocked
/// work, it parks it in the retained store with reason `force-killed`.
#[tokio::test(flavor = "multi_thread")]
async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("local");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/local", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"local only\n").unwrap();
commit_all(&wt, "not pushed yet");
// Refused: the commit is on no remote-tracking ref.
let err = client
.kill_session(s.id)
.await
.expect_err("kill must refuse");
match err {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.unpushed_commits, 1);
assert_eq!(breakdown.uncommitted_files, 0);
}
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
// Force overrides the refusal and nothing else: session gone, tree kept.
let outcome = client.kill_session_forced(s.id).await.expect("forced kill");
let retained = outcome
.retained
.expect("force must retain the guarded tree");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert_eq!(retained.path, wt);
assert_eq!(retained.branch, "feature/local");
assert_eq!(retained.in_the_way.unpushed_commits, 1);
assert!(
!client
.list_sessions()
.await
.unwrap()
.iter()
.any(|x| x.id == s.id),
"the forced kill ends the session"
);
assert!(wt.is_dir(), "a force-retained worktree must stay on disk");
assert!(
git_worktree_list(base.path())
.iter()
.any(|l| l.contains("feature/local")),
"the retained tree stays registered in git"
);
}
/// #82/#88: a session that exits on its own never reaches the kill path, but
/// its worktree follows the same rule. Dirty → retained with reason
/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind.
#[tokio::test(flavor = "multi_thread")]
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let (_daemon, client) = bring_up().await;
// Two repos so the two cases cannot see each other's state.
let dirty_repo = TempDir::new().unwrap();
init_repo(dirty_repo.path());
let _origin = throwaway_origin(dirty_repo.path());
let pristine_repo = TempDir::new().unwrap();
init_repo(pristine_repo.path());
let _origin_pristine = throwaway_origin(pristine_repo.path());
let dirty_wt = dirty_repo
.path()
.join("worktrees")
.join("feature")
.join("messy");
let pristine_wt = pristine_repo
.path()
.join("worktrees")
.join("feature")
.join("tidy");
let _dirty = client
.spawn_session(&CreateSessionRequest {
cwd: dirty_repo.path().to_path_buf(),
command: "sh -c 'echo unsaved > scratch.txt'".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
})
.await
.expect("spawn dirty");
let _pristine = client
.spawn_session(&create_req_cmd(
pristine_repo.path(),
"true",
"feature/tidy",
))
.await
.expect("spawn pristine");
assert!(dirty_wt.is_dir() && pristine_wt.is_dir());
// The watcher reaps dead tmux sessions every couple of ticks and settles
// each worktree accordingly. Poll for both outcomes.
wait_for_async(
"dirty exit retains its worktree",
Duration::from_secs(20),
|| {
let client = client.clone();
let path = dirty_wt.clone();
async move {
client
.list_worktrees()
.await
.ok()
.map(|wts| {
wts.iter().any(|w| {
w.path == path
&& w.reason
== maestro_protocol::RetainedReason::ExitedWithUnsavedWork
})
})
.unwrap_or(false)
}
},
)
.await;
wait_for_async(
"pristine exit removes its worktree entirely",
Duration::from_secs(20),
|| {
let client = client.clone();
let repo = pristine_repo.path().to_path_buf();
let path = pristine_wt.clone();
async move {
if path.exists() {
return false;
}
let listed = client.list_worktrees().await.ok();
let listed_clear = listed
.map(|wts| !wts.iter().any(|w| w.path == path))
.unwrap_or(false);
let registered = git_worktree_list(&repo);
!registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear
}
},
)
.await;
// The dirty tree is still fully there, file included.
assert!(dirty_wt.is_dir());
assert_eq!(
std::fs::read(dirty_wt.join("scratch.txt")).unwrap(),
b"unsaved\n"
);
}
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(cwd, branch, true)),
}
}
/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the
/// reason and a live in-the-way breakdown; reclaim removes the directory and
/// the store entry but NEVER the branch — its commits outlive the reclaim.
#[tokio::test(flavor = "multi_thread")]
async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("parked");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/parked", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"worth keeping\n").unwrap();
commit_all(&wt, "worth keeping");
let sha = branch_sha(&wt, "feature/parked");
let outcome = client.kill_session_forced(s.id).await.expect("force kill");
assert!(outcome.retained.is_some());
let listed = client.list_worktrees().await.expect("list worktrees");
assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}");
let entry = &listed[0];
assert_eq!(entry.path, wt);
assert_eq!(entry.branch, "feature/parked");
assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled);
assert_eq!(entry.in_the_way.unpushed_commits, 1);
client.reclaim_worktree(entry.id).await.expect("reclaim");
assert!(!wt.exists(), "reclaim removes the directory");
assert!(
client.list_worktrees().await.unwrap().is_empty(),
"reclaim drops the store entry"
);
// The whole point of reclaim: the branch and its commits survive.
assert_eq!(
branch_sha(base.path(), "feature/parked"),
sha,
"the branch must keep pointing at the same commit after reclaim"
);
}
/// The retained store is state-dir backed (#80): a daemon restart must not
/// orphan the trees it is holding — they are still on disk with entries
/// pointing at them, and still reclaimable afterwards.
#[tokio::test(flavor = "multi_thread")]
async fn retained_store_survives_a_daemon_restart() {
let (mut daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base
.path()
.join("worktrees")
.join("feature")
.join("durable");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/durable", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"survives restarts\n").unwrap();
commit_all(&wt, "survives restarts");
client.kill_session_forced(s.id).await.expect("force kill");
daemon.restart().await;
let listed = client.list_worktrees().await.expect("list after restart");
assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}");
assert_eq!(listed[0].path, wt);
assert_eq!(
listed[0].reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert!(wt.is_dir(), "the tree itself must survive the restart too");
client
.reclaim_worktree(listed[0].id)
.await
.expect("reclaim");
assert!(!wt.exists());
}
// ---------------------------------------------------------------------------
// GET /api/projects + /api/branches (#54/#55/#66)
// ---------------------------------------------------------------------------
@@ -788,10 +1250,12 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
.collect()
}
/// Push every branch of `repo` to a throwaway bare remote, so its commits are
/// reachable from remote-tracking refs. Without one the kill guard (#79)
/// reads every commit as unpushed, and any worktree session refuses to die.
fn push_to_throwaway_origin(repo: &std::path::Path) {
/// Bare remote registered as `origin` on `repo`, seeded with every current
/// branch, so existing commits are reachable from remote-tracking refs.
/// Without one the kill guard (#79) reads every commit as unpushed, and any
/// worktree session refuses to die. The caller must keep the `TempDir` alive
/// while the remote is still needed; later pushes go through [`push_all`].
fn throwaway_origin(repo: &std::path::Path) -> TempDir {
let origin = tempfile::TempDir::new().unwrap();
run_git(
&["init", "-q", "--bare", origin.path().to_str().unwrap()],
@@ -803,9 +1267,34 @@ fn push_to_throwaway_origin(repo: &std::path::Path) {
.args(["remote", "add", "origin", origin.path().to_str().unwrap()])
.output()
.expect("git remote add");
push_all(repo);
origin
}
fn push_all(repo: &std::path::Path) {
run_git(&["push", "-q", "--all", "origin"], repo);
}
fn commit_all(dir: &std::path::Path, message: &str) {
run_git(&["add", "-A"], dir);
run_git(&["commit", "-q", "-m", message], dir);
}
fn branch_sha(repo: &std::path::Path, branch: &str) -> String {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.expect("git rev-parse");
assert!(
out.status.success(),
"branch {branch} missing: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "maestro-hooks"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Agent activity hooks (#106): shipped script + idempotent settings.json installer"
[dependencies]
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true
+261
View File
@@ -0,0 +1,261 @@
//! Claude Code activity hook (#106).
//!
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
//! the daemon's first-start auto-install:
//!
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
//! posts an [`ActivityState`](maestro-protocol) to
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
//! global hook runs in every Claude Code session on the machine, so it exits
//! 0 and silently when either #103 env var is unset, and it never lets a
//! daemon failure escape (short curl timeout, status ignored).
//! * [`install`] — idempotent registration of that script in
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
//! in the file is preserved verbatim.
use std::path::Path;
/// Marker substring every maestro-installed hook command carries. The
/// installer rewrites or removes exactly these entries — nothing else in the
/// user's settings file.
pub const HOOK_MARKER: &str = "maestro-claude-hook";
/// File name of the installed script (carries [`HOOK_MARKER`]).
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
/// Claude Code hook event → `ActivityState` spelling posted to the daemon.
///
/// `PreToolUse` is what makes a session `Active` *during* a long tool call —
/// `PostToolUse` alone only fires once it ends (#114). `StopFailure` is
/// Claude Code's own name for "the turn ended on an API error"; there is no
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
/// `Error`.
pub const HOOK_EVENTS: [(&str, &str); 5] = [
// Tool starting: the agent is demonstrably producing work, even if the
// tool itself runs for minutes.
("PreToolUse", "Active"),
// Tool finished successfully.
("PostToolUse", "Active"),
// Permission / idle notices: Claude is blocked on the user.
("Notification", "AwaitingInput"),
// Turn ended: waiting on the user again.
("Stop", "AwaitingInput"),
// Turn ended on an API error; the payload's `error` field is forwarded
// as the report's `detail` (#114).
("StopFailure", "Error"),
];
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
# Installed by maestro (#106): report Claude Code activity to its daemon.
# Exits silently unless running inside a session maestro spawned (#103 env).
# Event -> ActivityState mapping:
# PreToolUse -> Active (tool started; agent is working)
# PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (permission / idle notice)
# Stop -> AwaitingInput (turn ended; waiting on the user)
# StopFailure -> Error (turn ended on an API error)
state="$1"
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
[ -n "$MAESTRO_URL" ] || exit 0
payload=$(cat 2>/dev/null) || true
body="{\"state\":\"$state\"}"
# StopFailure payloads carry the outcome in "error". Only a plain enum
# spelling is reported as the detail — anything else (quotes, backslashes,
# prose) would risk breaking the JSON body, and the spelling already names
# the failure.
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
if [ -n "$detail" ]; then
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
fi
curl -fsS --connect-timeout 1 --max-time 2 \
-H 'Content-Type: application/json' \
-d "$body" \
"$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity" \
>/dev/null 2>&1 || true
exit 0
"#;
#[derive(Debug, thiserror::Error)]
pub enum HooksError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
/// Where the script is written for a given home directory:
/// `<home>/.local/share/maestro/maestro-claude-hook.sh`.
pub fn script_path(home: &Path) -> std::path::PathBuf {
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
}
/// The user-level Claude Code settings file for a given home directory.
pub fn settings_path(home: &Path) -> std::path::PathBuf {
home.join(".claude/settings.json")
}
/// Whether `settings_path` already carries a maestro hook (#106).
pub fn is_installed(settings_path: &Path) -> bool {
settings_json(settings_path)
.map(|root| find_maestro_commands(&root).next().is_some())
.unwrap_or(false)
}
/// Write [`HOOK_SCRIPT`] to `path`, executable. Parent directories are
/// created as needed — this is always under the caller's chosen data dir,
/// never inside a repository.
pub fn write_script(path: &Path) -> Result<(), HooksError> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, HOOK_SCRIPT)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
/// Install the full hook setup for `home`: write the script under
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
/// Idempotent; returns whether anything changed on disk.
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
let script = script_path(home);
let settings = settings_path(home);
let before = std::fs::read_to_string(&settings).unwrap_or_default();
write_script(&script)?;
install(&settings, &script)?;
let after = std::fs::read_to_string(&settings).unwrap_or_default();
Ok(before != after)
}
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
/// settings file at `settings_path`. Idempotent: existing maestro entries are
/// updated in place wherever they sit, stale ones removed, duplicates folded;
/// every non-maestro entry and every unrelated top-level field survives.
///
/// Never inspects or writes anywhere but `settings_path` — callers pass the
/// user's real home or a temp one in tests, never a repository.
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
let mut root = settings_json(settings_path)?;
// Strip every maestro entry wherever it currently sits, so an older
// install under different events is replaced rather than duplicated.
strip_maestro_entries(&mut root);
let hooks = ensure_object(&mut root, "hooks");
for (event, state) in HOOK_EVENTS {
let groups = ensure_array(hooks, event);
let mut group = serde_json::Map::new();
group.insert(
"hooks".into(),
serde_json::json!([{
"type": "command",
"command": format!("{} {state}", script_path.display()),
}]),
);
groups.push(serde_json::Value::Object(group));
}
write_atomic(settings_path, &root)
}
fn settings_json(path: &Path) -> Result<serde_json::Value, HooksError> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(serde_json::from_str(&s)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(serde_json::Value::Object(Default::default()))
}
Err(e) => Err(e.into()),
}
}
/// Every command string in the file belonging to a maestro hook.
fn find_maestro_commands(root: &serde_json::Value) -> impl Iterator<Item = &str> {
root.get("hooks")
.and_then(|h| h.as_object())
.into_iter()
.flat_map(|events| events.values())
.filter_map(|groups| groups.as_array())
.flatten()
.filter_map(|group| group.get("hooks"))
.filter_map(|inner| inner.as_array())
.flatten()
.filter_map(|hook| hook.get("command"))
.filter_map(|cmd| cmd.as_str())
.filter(|cmd| cmd.contains(HOOK_MARKER))
}
/// Remove all maestro inner-hook entries from every event group, dropping
/// groups left empty. Non-maestro siblings survive untouched.
fn strip_maestro_entries(root: &mut serde_json::Value) {
let Some(events) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
return;
};
for (_, groups) in events.iter_mut() {
let Some(list) = groups.as_array_mut() else {
continue;
};
for group in list.iter_mut() {
let Some(inner) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
continue;
};
inner.retain(|hook| {
!hook
.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains(HOOK_MARKER))
});
}
list.retain(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|h| !h.is_empty())
});
}
// An event left with no groups at all (ours emptied it, or the user's
// file carried an empty list) reads better gone.
events.retain(|_, groups| groups.as_array().is_some_and(|g| !g.is_empty()));
}
fn ensure_object<'a>(
root: &'a mut serde_json::Value,
key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
if !root.get(key).map(|v| v.is_object()).unwrap_or(false) {
root[key] = serde_json::json!({});
}
root.get_mut(key).unwrap().as_object_mut().unwrap()
}
fn ensure_array<'a>(
parent: &'a mut serde_json::Map<String, serde_json::Value>,
key: &str,
) -> &'a mut Vec<serde_json::Value> {
let v = parent
.entry(key.to_string())
.or_insert_with(|| serde_json::json!([]));
if !v.is_array() {
*v = serde_json::json!([]);
}
v.as_array_mut().unwrap()
}
fn write_atomic(path: &Path, value: &serde_json::Value) -> Result<(), HooksError> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.maestro-tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(value)? + "\n")?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests;
+340
View File
@@ -0,0 +1,340 @@
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
//! and the settings-file installer. Everything happens under a temp `$HOME`;
//! the developer's own `~/.claude/settings.json` is never touched.
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use super::*;
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
let mut cmd = Command::new("/bin/sh");
// `sh -c CMD NAME ARGS…`: NAME fills $0, so the real arguments start at $1.
let mut argv = vec!["-c", script, HOOK_MARKER];
argv.extend(args.iter().copied());
cmd.args(&argv);
// A clean env: only what the test passes, plus PATH so curl can spawn.
cmd.env_clear();
cmd.env("PATH", std::env::var("PATH").unwrap_or_default());
for (k, v) in envs {
cmd.env(k, v);
}
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn /bin/sh");
// Feed a Claude-Code-style payload on stdin; the script must drain it.
child.stdin.take().unwrap().write_all(payload).ok();
child.wait_with_output().expect("wait")
}
const SESSION: &str = "01234567-89ab-cdef-0123-456789abcdef";
/// Accept exactly one HTTP request on a throwaway listener, answer 204, and
/// hand back the raw request text. Runs `run` with the base URL once the
/// acceptor thread is in place.
fn capture_request(run: impl FnOnce(String) -> std::process::Output) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = [0u8; 4096];
let n = sock.read(&mut buf).unwrap();
sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
.unwrap();
String::from_utf8_lossy(&buf[..n]).to_string()
});
let out = run(format!("http://{addr}"));
assert!(out.status.success(), "{:?}", out.stderr);
handle.join().unwrap()
}
fn session_envs(url: &str) -> Vec<(&'static str, String)> {
vec![
("MAESTRO_URL", url.to_string()),
("MAESTRO_SESSION_ID", SESSION.to_string()),
]
}
fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
envs.iter().map(|(k, v)| (*k, v.as_str())).collect()
}
/// The hook must be invisible outside maestro: no env vars (#103 unset) ⇒
/// exit 0, no output, stdin drained.
#[test]
fn script_exits_silently_without_maestro_env() {
for (event, _) in HOOK_EVENTS {
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
assert!(out.stderr.is_empty(), "{event:?}: {:?}", out.stderr);
}
}
/// With both env vars set the script POSTs the mapped state to the daemon's
/// activity endpoint and still exits 0.
#[test]
fn script_posts_mapped_state_to_the_activity_endpoint() {
// The installer bakes the mapped state into the command's argv; the
// script relays whatever it is given.
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["Active"],
&owned_envs(&session_envs(&url)),
br#"{"session_id":"x"}"#,
)
});
assert!(request.starts_with("POST "), "{request}");
assert!(
request.contains(&format!("/api/sessions/{SESSION}/activity")),
"{request}"
);
assert!(request.contains(r#""state":"Active""#), "{request}");
}
/// Every event maps to the state the contract specifies.
#[test]
fn event_state_mapping_matches_the_contract() {
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
assert_eq!(map["PreToolUse"], "Active");
assert_eq!(map["PostToolUse"], "Active");
assert_eq!(map["Notification"], "AwaitingInput");
assert_eq!(map["Stop"], "AwaitingInput");
assert_eq!(map["StopFailure"], "Error");
}
/// A `StopFailure` payload names the API error that ended the turn; the
/// script reports its `error` spelling as the report's `detail` so a card
/// can show why (#114).
#[test]
fn script_reports_the_error_spelling_as_detail() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["Error"],
&owned_envs(&session_envs(&url)),
br#"{"session_id":"abc","hook_event_name":"StopFailure","error":"rate_limit","error_details":"429 Too Many Requests"}"#,
)
});
assert!(request.contains(r#""state":"Error""#), "{request}");
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
}
/// An `error` value that is not a plain enum spelling (or a payload without
/// one at all) must never produce a broken body — no detail is posted.
#[test]
fn script_posts_no_detail_for_an_unexpected_error_value() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["Error"],
&owned_envs(&session_envs(&url)),
br#"{"hook_event_name":"StopFailure","error":"429 \"Too Many\" \\ requests"}"#,
)
});
assert!(request.contains(r#""state":"Error""#), "{request}");
assert!(!request.contains("detail"), "{request}");
}
/// Events whose payload carries no error fields post a bare body — never an
/// empty or stale detail.
#[test]
fn script_posts_no_detail_for_events_without_an_error() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["AwaitingInput"],
&owned_envs(&session_envs(&url)),
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
)
});
assert!(request.contains(r#""state":"AwaitingInput""#), "{request}");
assert!(!request.contains("detail"), "{request}");
}
/// A dead or unreachable daemon must never fail — or slow down — a session.
#[test]
fn script_ignores_daemon_failure() {
// Port 1 on localhost is closed in practice; curl fails fast.
let out = sh(
HOOK_SCRIPT,
&["Stop"],
&[
("MAESTRO_URL", "http://127.0.0.1:1"),
("MAESTRO_SESSION_ID", "00000000-0000-0000-0000-000000000000"),
],
br#"{"session_id":"x"}"#,
);
assert!(out.status.success());
}
// --- installer -------------------------------------------------------------
fn installed_commands(settings: &Path) -> Vec<String> {
serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(settings).unwrap())
.unwrap()
.get("hooks")
.and_then(|h| h.as_object())
.into_iter()
.flat_map(|events| events.values())
.filter_map(|g| g.as_array())
.flatten()
.filter_map(|g| g.get("hooks"))
.filter_map(|h| h.as_array())
.flatten()
.filter_map(|h| h.get("command"))
.filter_map(|c| c.as_str())
.map(str::to_string)
.collect()
}
/// Fresh install registers exactly one command per event, all pointing at the
/// script with the right state argument.
#[test]
fn fresh_install_registers_every_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
write_script(&script_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
let cmds = installed_commands(&settings_path(home));
assert_eq!(cmds.len(), HOOK_EVENTS.len());
let joined = cmds.join("\n");
assert_eq!(joined.matches("Active").count(), 2);
assert!(joined.contains("Error"));
assert_eq!(joined.matches("AwaitingInput").count(), 2);
for cmd in &cmds {
assert!(cmd.contains(SCRIPT_FILE_NAME));
}
}
/// Running install twice changes nothing further — no duplicates.
#[test]
fn install_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(&settings_path(home), &script_path(home)).unwrap();
let once = std::fs::read_to_string(settings_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
assert_eq!(once, twice);
assert_eq!(
installed_commands(&settings_path(home)).len(),
HOOK_EVENTS.len()
);
}
/// An older maestro entry under the same events is updated in place, not
/// duplicated next to the new one.
#[test]
fn install_updates_an_older_maestro_hook_in_place() {
let dir = tempfile::tempdir().unwrap();
let settings = dir.path().join(".claude/settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
let old_script = "/old/location/maestro-claude-hook.sh Active";
std::fs::write(
&settings,
serde_json::json!({
"model": "sonnet",
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": old_script}]}],
},
})
.to_string(),
)
.unwrap();
let new_script = dir
.path()
.join(".local/share/maestro")
.join(SCRIPT_FILE_NAME);
install(&settings, &new_script).unwrap();
let cmds = installed_commands(&settings);
assert_eq!(
cmds.len(),
HOOK_EVENTS.len(),
"old entry replaced, not kept: {cmds:?}"
);
assert!(!cmds.iter().any(|c| c == old_script));
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(root["model"], "sonnet", "unrelated fields survive");
}
/// A stale maestro entry under an event we no longer use is cleaned up too;
/// the user's own hooks are never touched.
#[test]
fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
let dir = tempfile::tempdir().unwrap();
let settings = dir.path().join(".claude/settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(
&settings,
serde_json::json!({
"hooks": {
"PreToolUse": [{"matcher": "Bash", "hooks": [
{"type": "command", "command": "my-own-linter.sh"},
]}],
"UserPromptSubmit": [{"hooks": [
{"type": "command", "command": "/old/maestro-claude-hook.sh Active"},
]}],
},
})
.to_string(),
)
.unwrap();
let new_script = dir
.path()
.join(".local/share/maestro")
.join(SCRIPT_FILE_NAME);
install(&settings, &new_script).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
// Foreign hook untouched.
let pre = root["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre[0]["hooks"][0]["command"], "my-own-linter.sh");
// Stale maestro event gone.
assert!(root["hooks"].get("UserPromptSubmit").is_none());
// Our events present, nothing else maestro-related anywhere.
let cmds = installed_commands(&settings);
assert_eq!(
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
HOOK_EVENTS.len(),
"{cmds:?}"
);
}
/// `is_installed` flips only once a maestro entry exists.
#[test]
fn is_installed_tracks_reality() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(!is_installed(&settings_path(home)));
install_for_home(home).unwrap();
assert!(is_installed(&settings_path(home)));
}
/// The full home-level install leaves the script executable.
#[test]
fn install_for_home_writes_an_executable_script() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(script_path(home))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111);
}
}
+28 -1
View File
@@ -16,7 +16,8 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
KillOutcome, Project, RecentDirs, RetainedWorktree, Session,
KillOutcome, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest,
RetainedWorktree, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -134,6 +135,32 @@ impl DaemonClient {
json(resp).await
}
/// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name
/// clears the custom one, so the session falls back to its command. The
/// daemon trims and caps the name and answers with the updated session.
pub async fn rename_session(&self, id: Uuid, name: &str) -> Result<Session, ClientError> {
let req = RenameSessionRequest { name: name.into() };
let resp = self
.send(Method::PATCH, &routes::session(id), &[], Some(&req))
.await?;
json(check(resp).await?).await
}
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
/// state. The daemon holds it above its own inference for a TTL, then
/// resumes inference. Answers `204 No Content`.
pub async fn report_activity(
&self,
id: Uuid,
req: &ReportActivityRequest,
) -> Result<(), ClientError> {
let resp = self
.send(Method::POST, &routes::session_activity(id), &[], Some(req))
.await?;
check(resp).await?;
Ok(())
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await
}
+321
View File
@@ -0,0 +1,321 @@
//! Path-abbreviation helpers shared by every client that renders a session's
//! directory (CLI, TUI, web (#117)). Ports of `web/src/types.ts` and
//! `web/src/tree.ts`'s `abbreviateCwd`, `abbreviateFolderName`, `mergeLinear`,
//! and `sessionTreePath` — keep them in sync.
use std::path::{Path, PathBuf};
use crate::Session;
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
/// untouched. Mirrors the web's `abbreviateCwd`.
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
return "~".into();
}
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
return format!("~/{rest}");
}
}
p.into_owned()
}
/// Result of [`abbreviate_folder_name`]: the last path segment kept verbatim,
/// and — when there was something to collapse — the shortened lead-in a
/// caller can render dimmed (the web's `.folder-stem`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbbreviatedFolderName {
/// The collapsed earlier segments, joined by `/`, with no trailing
/// slash. `None` when `name` had two or fewer segments — nothing to
/// gain from collapsing, so `last` carries the name unabbreviated.
pub stem: Option<String>,
pub last: String,
}
/// `~/git.naps.pt/naps62/maestro` → stem `~/g/n`, last `maestro`: keep the
/// last segment verbatim, collapse every earlier one to its first character.
/// Two-or-fewer segments are left alone. Mirrors the web's
/// `abbreviateFolderName`, minus the HTML escaping/wrapping — a ratatui
/// caller styles `stem` dim itself rather than parsing markup out of it.
pub fn abbreviate_folder_name(name: &str) -> AbbreviatedFolderName {
let parts: Vec<&str> = name.split('/').collect();
if parts.len() <= 2 {
return AbbreviatedFolderName {
stem: None,
last: name.to_string(),
};
}
let last = parts[parts.len() - 1].to_string();
let stem = parts[..parts.len() - 1]
.iter()
.map(|p| {
if *p == "~" {
"~".to_string()
} else {
p.chars().next().map(String::from).unwrap_or_default()
}
})
.collect::<Vec<_>>()
.join("/");
AbbreviatedFolderName {
stem: Some(stem),
last,
}
}
/// The directory a session lives in: its live `current_cwd` if the daemon
/// has observed one, else the spawn `cwd`. Mirrors the web's `sessionCwd`.
fn session_cwd(s: &Session) -> Option<PathBuf> {
if let Some(cur) = &s.current_cwd {
if !cur.as_os_str().is_empty() {
return Some(cur.clone());
}
}
if !s.cwd.as_os_str().is_empty() {
return Some(s.cwd.clone());
}
None
}
/// Where a session sits in the directory tree. A worktree session belongs to
/// its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
/// grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
/// session of the same name, and the repository stops being where you look
/// for the work. Mirrors the web's `sessionTreePath`.
pub fn session_tree_path(s: &Session) -> Option<PathBuf> {
if let Some(wt) = &s.worktree {
return Some(wt.base_repo.clone());
}
session_cwd(s)
}
/// One node of a directory tree grouping sessions by path, generic over the
/// leaf payload since building the tree itself (grouping key, sort order,
/// filtering) is not this crate's job — only [`merge_linear`] is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeNode<T> {
Folder(TreeFolder<T>),
Leaf(T),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeFolder<T> {
pub name: String,
pub path: String,
pub children: Vec<TreeNode<T>>,
}
/// Fold a folder with a single folder-child into one node (`a` + `b` →
/// `a/b`), repeating down the chain, then recurse into the surviving
/// branches. The merged node keeps the deepest path so collapse/expand state
/// stays stable as it grows. Mirrors the web's `mergeLinear`.
pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
let mut cur = folder;
loop {
let single_folder_child =
cur.children.len() == 1 && matches!(cur.children[0], TreeNode::Folder(_));
if !single_folder_child {
break;
}
let child = match cur.children.pop().expect("checked len == 1 above") {
TreeNode::Folder(f) => f,
TreeNode::Leaf(_) => unreachable!("checked matches! above"),
};
cur = TreeFolder {
name: format!("{}/{}", cur.name, child.name),
path: child.path,
children: child.children,
};
}
cur.children = cur
.children
.into_iter()
.map(|c| match c {
TreeNode::Folder(f) => TreeNode::Folder(merge_linear(f)),
leaf => leaf,
})
.collect();
cur
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SessionStatus, SessionWorktree};
use chrono::{TimeZone, Utc};
use std::collections::HashMap;
use uuid::Uuid;
fn session(cwd: &str) -> Session {
Session {
id: Uuid::from_u128(1),
cwd: PathBuf::from(cwd),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
#[test]
fn abbreviate_dir_collapses_home() {
assert_eq!(
abbreviate_dir(Path::new("/home/naps/maestro"), "/home/naps"),
"~/maestro"
);
assert_eq!(abbreviate_dir(Path::new("/home/naps"), "/home/naps"), "~");
}
#[test]
fn abbreviate_dir_leaves_paths_outside_home_untouched() {
assert_eq!(
abbreviate_dir(Path::new("/srv/work/api"), "/home/naps"),
"/srv/work/api"
);
assert_eq!(
abbreviate_dir(Path::new("/srv/work/api"), ""),
"/srv/work/api"
);
}
#[test]
fn abbreviate_folder_name_collapses_the_stem() {
let got = abbreviate_folder_name("~/git.naps.pt/naps62/maestro");
assert_eq!(
got,
AbbreviatedFolderName {
stem: Some("~/g/n".into()),
last: "maestro".into(),
}
);
}
/// Two-or-fewer segments are left alone — nothing to gain from collapsing.
#[test]
fn abbreviate_folder_name_two_segment_no_op() {
assert_eq!(
abbreviate_folder_name("~/work"),
AbbreviatedFolderName {
stem: None,
last: "~/work".into(),
}
);
assert_eq!(
abbreviate_folder_name("work"),
AbbreviatedFolderName {
stem: None,
last: "work".into(),
}
);
}
#[test]
fn session_tree_path_prefers_worktree_base_repo() {
let mut s = session("/home/n/tea/agent-skills/worktrees/largo-clarinet");
s.worktree = Some(SessionWorktree {
path: PathBuf::from("/home/n/tea/agent-skills/worktrees/largo-clarinet"),
base_repo: PathBuf::from("/home/n/tea/agent-skills"),
});
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/agent-skills"))
);
}
#[test]
fn session_tree_path_falls_back_to_cwd_without_a_worktree() {
let s = session("/home/n/tea/arr");
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/arr"))
);
}
#[test]
fn session_tree_path_prefers_live_current_cwd_over_spawn_cwd() {
let mut s = session("/home/n/tea/arr");
s.current_cwd = Some(PathBuf::from("/home/n/tea/arr/crates"));
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/arr/crates"))
);
}
fn leaf(name: &str) -> TreeNode<String> {
TreeNode::Leaf(name.into())
}
fn folder(name: &str, path: &str, children: Vec<TreeNode<String>>) -> TreeFolder<String> {
TreeFolder {
name: name.into(),
path: path.into(),
children,
}
}
/// The web's mergeLinear test: keeps the deepest path so collapse state
/// survives growth.
#[test]
fn merge_linear_folds_a_chain_and_keeps_the_deepest_path() {
let tree = folder("a", "a", vec![TreeNode::Folder(folder("b", "a/b", vec![]))]);
let merged = merge_linear(tree);
assert_eq!(merged.name, "a/b");
assert_eq!(merged.path, "a/b");
}
#[test]
fn merge_linear_stops_where_a_directory_branches() {
let tree = folder(
"git",
"git",
vec![
TreeNode::Folder(folder("one", "git/one", vec![])),
TreeNode::Folder(folder("two", "git/two", vec![])),
],
);
let merged = merge_linear(tree);
assert_eq!(merged.name, "git");
assert_eq!(merged.children.len(), 2);
}
#[test]
fn merge_linear_stops_at_a_leaf() {
let tree = folder("a", "a", vec![leaf("session")]);
let merged = merge_linear(tree);
assert_eq!(merged.name, "a");
assert_eq!(merged.children, vec![leaf("session")]);
}
#[test]
fn merge_linear_recurses_into_surviving_branches() {
let tree = folder(
"root",
"root",
vec![
TreeNode::Folder(folder(
"a",
"root/a",
vec![TreeNode::Folder(folder("b", "root/a/b", vec![leaf("s")]))],
)),
leaf("other"),
],
);
let merged = merge_linear(tree);
assert_eq!(merged.children.len(), 2);
let TreeNode::Folder(nested) = &merged.children[0] else {
panic!("expected folder")
};
assert_eq!(nested.name, "a/b");
}
}
+282 -9
View File
@@ -9,6 +9,7 @@
#[cfg(feature = "client")]
pub mod client;
pub mod display;
pub mod routes;
#[cfg(feature = "client")]
@@ -75,11 +76,24 @@ pub struct Session {
/// byte stream.
#[serde(default)]
pub activity: ActivityState,
/// Short human-readable string an agent reported alongside its activity
/// (#105) — e.g. why it is in `Error`. Shown on a sidebar card's meta line
/// while the report is in force; cleared when the report lapses or is
/// replaced. `None` unless an agent reported one.
#[serde(default)]
pub activity_detail: Option<String>,
/// Wall-clock time of the session's most recent observed PTY output.
/// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads
/// from daemons that predate the field.
#[serde(default = "epoch")]
pub last_activity_at: DateTime<Utc>,
/// Wall-clock time at which the session entered its current
/// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or
/// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only
/// tracks the last observed PTY output. Populated by #104; defaults to the
/// Unix epoch on payloads from daemons that predate the field.
#[serde(default = "epoch")]
pub activity_since: DateTime<Utc>,
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
/// from [`Session::cwd`], the directory it was spawned in. The activity
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
@@ -87,6 +101,13 @@ pub struct Session {
/// from daemons that predate the field.
#[serde(default)]
pub current_cwd: Option<PathBuf>,
/// Whether the session's worktree currently holds anything that would
/// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`.
/// Refreshed by the daemon's watcher; `None` for sessions without a
/// worktree and until the first probe. Defaults on the wire so payloads
/// from daemons that predate the field stay forward-compatible.
#[serde(default)]
pub blocked_work: Option<InTheWay>,
}
/// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -103,7 +124,12 @@ pub enum SessionStatus {
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
/// inspection — never by parsing the terminal byte stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
///
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
/// whole `Session`. Non-string values still error — only variant names are
/// forgiven.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
pub enum ActivityState {
/// Producing output, or the foreground process is busy.
Active,
@@ -112,6 +138,64 @@ pub enum ActivityState {
Idle,
/// Blocked on a `read()` from the pty — waiting on the user.
AwaitingInput,
/// The agent running in the session reported a failure (#102). Never
/// inferred by the daemon; only set when an agent reports it (#105).
Error,
}
impl<'de> Deserialize<'de> for ActivityState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"Active" => Self::Active,
"AwaitingInput" => Self::AwaitingInput,
"Error" => Self::Error,
// Known or not, any other spelling degrades to the neutral default
_ => Self::Idle,
})
}
}
/// HTTP `POST /api/sessions/:id/activity` body (#105): an agent's report of
/// its own state. The daemon holds the reported state above its own inference
/// for a fixed TTL, then resumes inference.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportActivityRequest {
/// Parsed strictly, unlike [`ActivityState`]'s own lenient `Deserialize`
/// (#110). Leniency exists so a client reading a *newer* daemon's snapshot
/// degrades an unrecognised state to `Idle` instead of dropping the whole
/// session. A report travelling the other way has no such excuse: an agent
/// posting a state this daemon does not know is a bug in the agent, and
/// silently recording it as `Idle` would hide that behind a 204.
#[serde(deserialize_with = "strict_activity_state")]
pub state: ActivityState,
/// Short human string shown next to the state (e.g. an error message).
/// The daemon caps its length server-side.
#[serde(default)]
pub detail: Option<String>,
}
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
/// `Idle`. Used only by [`ReportActivityRequest`]; see the note there.
fn strict_activity_state<'de, D>(deserializer: D) -> Result<ActivityState, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let s = String::deserialize(deserializer)?;
match s.as_str() {
"Active" => Ok(ActivityState::Active),
"Idle" => Ok(ActivityState::Idle),
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
"Error" => Ok(ActivityState::Error),
other => Err(D::Error::unknown_variant(
other,
&["Active", "Idle", "AwaitingInput", "Error"],
)),
}
}
/// Optional worktree request attached to a `CreateSessionRequest`.
@@ -119,11 +203,6 @@ pub enum ActivityState {
pub struct WorktreeSpec {
pub base_repo: PathBuf,
pub branch: String,
/// Kept on the wire for CLI compatibility (#56) but no longer a
/// user-facing choice: the daemon always cleans up on kill, guarded
/// against unsaved work. Defaults to `true`.
#[serde(default = "default_true")]
pub cleanup_on_kill: bool,
/// When false, `branch` must already exist and is checked out as-is
/// (#56); when true (the default, and what every payload meant before
/// the field existed) the daemon creates it with `worktree add -b`.
@@ -416,6 +495,31 @@ pub struct NameBatch {
pub names: Vec<String>,
}
/// Longest session name the daemon stores (#125), in characters. A name is a
/// sidebar card's line 1; past this it only truncates on every client anyway.
pub const MAX_SESSION_NAME_LEN: usize = 64;
/// HTTP `PATCH /api/sessions/:id` body (#125): rename a live session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenameSessionRequest {
pub name: String,
}
/// Trim a requested session name and cap it at [`MAX_SESSION_NAME_LEN`]
/// characters. `None` when nothing is left — that clears `metadata.name` and
/// the card falls back to the command, the same rule the spawn form uses when
/// its name field is blank.
///
/// Shared so the daemon and every Rust client agree on what a name becomes;
/// `web/src/types.ts` carries the same function for the browser.
pub fn normalize_session_name(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.chars().take(MAX_SESSION_NAME_LEN).collect())
}
// ---------------------------------------------------------------------------
// Catalog events (`WS /api/events`)
// ---------------------------------------------------------------------------
@@ -532,10 +636,15 @@ mod tests {
.with_timezone(&Utc),
foreground: Some("claude".into()),
activity: ActivityState::Active,
activity_detail: None,
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
.unwrap()
.with_timezone(&Utc),
activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z")
.unwrap()
.with_timezone(&Utc),
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
}
}
@@ -555,6 +664,63 @@ mod tests {
assert_eq!(s.activity, ActivityState::Idle);
assert_eq!(s.last_activity_at, epoch());
assert!(s.current_cwd.is_none());
assert!(s.blocked_work.is_none());
}
#[test]
fn blocked_work_defaults_to_none_on_legacy_payload() {
// A pre-#83 daemon omits the indicator; it must deserialize to `None`
// rather than failing, and a present value round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.blocked_work, None);
let mut with_blocker = sample_session();
with_blocker.blocked_work = Some(InTheWay {
uncommitted_files: 3,
..InTheWay::default()
});
assert_eq!(
with_blocker.clone().blocked_work,
serde_json::from_value::<Session>(serde_json::to_value(&with_blocker).unwrap())
.unwrap()
.blocked_work
);
}
#[test]
fn activity_since_defaults_to_epoch_on_legacy_payload() {
// A daemon predating #102 omits `activity_since`; it must deserialize
// to the forward-compat default rather than failing, and a present
// value round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.activity_since, epoch());
let full = sample_session();
assert_eq!(full, round_trip(&full));
}
// #102: nothing infers `Error`; only #105's agent-reported failure sets it.
#[test]
fn error_activity_state_serializes_pascal_case_and_idle_stays_default() {
assert_eq!(
serde_json::to_value(ActivityState::Error).unwrap(),
serde_json::json!("Error")
);
assert_eq!(ActivityState::default(), ActivityState::Idle);
}
#[test]
@@ -566,6 +732,78 @@ mod tests {
assert_eq!(ActivityState::default(), ActivityState::Idle);
}
// #110 / SIDEBAR.md Precedence: a future daemon may send an
// `ActivityState` variant this client does not know; it must degrade to
// the neutral default instead of failing the whole `Session`.
#[test]
fn unknown_activity_state_variant_deserializes_to_idle() {
assert_eq!(
serde_json::from_value::<ActivityState>(serde_json::json!("Teleported")).unwrap(),
ActivityState::Idle
);
let mut future = serde_json::to_value(sample_session()).unwrap();
future["activity"] = serde_json::json!("QuantumEntangled");
let s: Session = serde_json::from_value(future).unwrap();
assert_eq!(s.activity, ActivityState::Idle);
}
#[test]
fn non_string_activity_payload_still_fails_deserialization() {
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z",
"activity": 42,
});
assert!(serde_json::from_value::<Session>(json).is_err());
}
#[test]
fn activity_detail_defaults_to_none_on_legacy_payload() {
// #105: a daemon predating the detail field omits it; it must
// deserialize to `None` rather than failing, and a present value
// round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.activity_detail, None);
let mut reported = sample_session();
reported.activity = ActivityState::Error;
reported.activity_detail = Some("build failed".into());
assert_eq!(reported.clone(), round_trip(&reported));
}
#[test]
fn report_activity_request_defaults_detail_and_rejects_unknown_state() {
let bare: ReportActivityRequest =
serde_json::from_value(serde_json::json!({ "state": "Error" })).unwrap();
assert_eq!(bare.state, ActivityState::Error);
assert_eq!(bare.detail, None);
let full = ReportActivityRequest {
state: ActivityState::AwaitingInput,
detail: Some("waiting on permission".into()),
};
assert_eq!(full, round_trip(&full));
// Unknown state values are rejected rather than coerced (#105).
assert!(
serde_json::from_value::<ReportActivityRequest>(serde_json::json!({
"state": "Teleported"
}))
.is_err()
);
}
#[test]
fn session_round_trips() {
let s = sample_session();
@@ -594,7 +832,6 @@ mod tests {
worktree: Some(WorktreeSpec {
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
@@ -624,7 +861,6 @@ mod tests {
});
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
assert!(spec.create_branch);
assert!(spec.cleanup_on_kill);
}
#[test]
@@ -632,7 +868,6 @@ mod tests {
let spec = WorktreeSpec {
base_repo: PathBuf::from("/work"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: false,
};
assert_eq!(spec, round_trip(&spec));
@@ -754,4 +989,42 @@ mod tests {
assert_eq!(c, round_trip(&c));
}
}
/// #125: a rename request is trimmed and capped; a blank one clears the
/// name so the card falls back to the command.
#[test]
fn session_names_are_trimmed_capped_and_blankable() {
assert_eq!(
normalize_session_name(" amber-falcon "),
Some("amber-falcon".into())
);
assert_eq!(normalize_session_name(""), None);
assert_eq!(normalize_session_name(" \t "), None);
let long = "x".repeat(MAX_SESSION_NAME_LEN + 10);
assert_eq!(
normalize_session_name(&long).unwrap().chars().count(),
MAX_SESSION_NAME_LEN
);
}
/// The cap counts characters, not bytes — a multi-byte name must not be
/// sliced mid-codepoint.
#[test]
fn session_name_cap_counts_characters() {
let name = "é".repeat(MAX_SESSION_NAME_LEN + 5);
let capped = normalize_session_name(&name).unwrap();
assert_eq!(capped.chars().count(), MAX_SESSION_NAME_LEN);
}
#[test]
fn rename_request_round_trips() {
let req = RenameSessionRequest {
name: "amber-falcon".into(),
};
assert_eq!(req, round_trip(&req));
assert_eq!(
serde_json::to_value(&req).unwrap(),
serde_json::json!({"name": "amber-falcon"})
);
}
}
+14
View File
@@ -47,6 +47,11 @@ pub fn session(id: Uuid) -> String {
format!("{SESSIONS}/{id}")
}
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
pub fn session_activity(id: Uuid) -> String {
format!("{SESSIONS}/{id}/activity")
}
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
pub fn worktree(id: Uuid) -> String {
format!("{WORKTREES}/{id}")
@@ -61,6 +66,7 @@ pub fn attach(id: Uuid) -> String {
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const ATTACH: &str = "/api/attach/:id";
pub const WORKTREE: &str = "/api/worktrees/:id";
}
@@ -169,6 +175,10 @@ mod tests {
session(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
session_activity(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
);
assert_eq!(
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -191,6 +201,10 @@ mod tests {
patterns::SESSION.trim_end_matches(":id"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
+3
View File
@@ -41,8 +41,11 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
+70 -42
View File
@@ -31,9 +31,14 @@ pub enum Action {
FocusSidebar,
FocusPane(Direction),
// Session scrollback
ScrollPanePageUp,
ScrollPanePageDown,
// Sidebar highlight movement
SidebarNext,
SidebarPrev,
ToggleSidebarGroup,
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
@@ -43,16 +48,12 @@ pub enum Action {
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
/// Rename the highlighted session (#125): opens a prompt pre-filled with
/// its current name.
RenameHighlighted,
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Tabs
NewTab,
CloseTab,
NextTab,
PrevTab,
GotoTab(u8),
// Chrome
ToggleSidebar,
ShowHelp,
@@ -70,8 +71,40 @@ pub enum ActionParseError {
}
impl Action {
/// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`,
/// or `"GotoTab(3)"`.
/// Plain-language label for the in-app help menu. Configuration keeps the
/// stable enum spelling; operators should not have to decode it.
pub fn help_label(&self) -> String {
match self {
Action::Noop => "Do nothing".into(),
Action::EnterInsert => "Send keys to session".into(),
Action::LeaveInsert => "Return to sidebar".into(),
Action::FocusSidebar => "Focus sidebar".into(),
Action::FocusPane(direction) => {
format!(
"Focus session pane ({})",
format!("{direction:?}").to_lowercase()
)
}
Action::ScrollPanePageUp => "Scroll session up one page".into(),
Action::ScrollPanePageDown => "Scroll session down one page".into(),
Action::SidebarNext => "Select next sidebar item".into(),
Action::SidebarPrev => "Select previous sidebar item".into(),
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
Action::AttachHighlighted => "Open selected item".into(),
Action::ToggleAttachHighlighted => "Open selected item".into(),
Action::NewDraftPane => "Create a session".into(),
Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
Action::ShowWorktrees => "Show retained worktrees".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(),
Action::Quit => "Quit Maestro".into(),
}
}
/// Parse a canonical action string like `"EnterInsert"` or
/// `"FocusPane(Left)"`.
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
let s = s.trim();
match s {
@@ -79,18 +112,18 @@ impl Action {
"EnterInsert" => return Ok(Action::EnterInsert),
"LeaveInsert" => return Ok(Action::LeaveInsert),
"FocusSidebar" => return Ok(Action::FocusSidebar),
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
"SidebarNext" => return Ok(Action::SidebarNext),
"SidebarPrev" => return Ok(Action::SidebarPrev),
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
"NextTab" => return Ok(Action::NextTab),
"PrevTab" => return Ok(Action::PrevTab),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
@@ -111,13 +144,6 @@ impl Action {
})?;
Ok(Action::FocusPane(dir))
}
"GotoTab" => {
let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg {
action: name.to_string(),
detail: format!("expected u8 0..=255, got {arg:?}"),
})?;
Ok(Action::GotoTab(n))
}
_ => Err(ActionParseError::Unknown(name.to_string())),
}
}
@@ -137,7 +163,6 @@ impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
Action::GotoTab(n) => write!(f, "GotoTab({n})"),
other => write!(f, "{other:?}"),
}
}
@@ -154,6 +179,13 @@ mod tests {
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
}
#[test]
fn help_labels_are_plain_language() {
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item");
assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane"));
}
#[test]
fn parses_focus_pane_directions() {
assert_eq!(
@@ -166,12 +198,6 @@ mod tests {
);
}
#[test]
fn parses_goto_tab() {
assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3));
assert_eq!(Action::parse("GotoTab(0)").unwrap(), Action::GotoTab(0));
}
#[test]
fn rejects_unknown_action() {
let err = Action::parse("Frobnicate").unwrap_err();
@@ -186,22 +212,10 @@ mod tests {
);
}
#[test]
fn rejects_non_numeric_tab_arg() {
let err = Action::parse("GotoTab(abc)").unwrap_err();
assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "GotoTab"));
}
#[test]
fn rejects_overflow_tab_arg() {
let err = Action::parse("GotoTab(9999)").unwrap_err();
assert!(matches!(err, ActionParseError::BadArg { .. }));
}
#[test]
fn rejects_unclosed_paren() {
let err = Action::parse("GotoTab(3").unwrap_err();
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
let err = Action::parse("FocusPane(Left").unwrap_err();
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
}
/// The fast-forward pair sits next to the draft-pane key (#67).
@@ -214,14 +228,28 @@ mod tests {
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
}
#[test]
fn rename_action_round_trips() {
assert_eq!(
Action::parse("RenameHighlighted").unwrap(),
Action::RenameHighlighted
);
assert_eq!(Action::RenameHighlighted.to_string(), "RenameHighlighted");
assert_eq!(
Action::RenameHighlighted.help_label(),
"Rename selected session"
);
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::RenameHighlighted,
Action::Quit,
Action::NewDraftPane,
Action::ScrollPanePageDown,
Action::FocusPane(Direction::Right),
Action::GotoTab(7),
] {
let s = a.to_string();
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
File diff suppressed because it is too large Load Diff
+3
View File
@@ -193,8 +193,11 @@ mod tests {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
+75 -11
View File
@@ -8,13 +8,15 @@
use std::time::Duration;
use maestro_config::TuiSection;
use maestro_config::{TuiSection, TuiTheme};
use crate::input::InputKey;
use crate::keymap::{Keymap, KeymapError, Mode};
use crate::palette::Palette;
#[derive(Debug)]
pub struct TuiConfig {
pub palette: Palette,
pub leader: InputKey,
pub chord_timeout: Duration,
pub keymap: Keymap,
@@ -38,11 +40,12 @@ pub enum TuiConfigError {
const DEFAULT_NORMAL: &[(&str, &str)] = &[
// Mode
("i", "EnterInsert"),
// Sidebar nav
// Sidebar navigation
("j", "SidebarNext"),
("k", "SidebarPrev"),
("down", "SidebarNext"),
("up", "SidebarPrev"),
("c", "ToggleSidebarGroup"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "NewDraftPane"),
@@ -50,20 +53,19 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("r", "RenameHighlighted"),
("w", "ShowWorktrees"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
("g T", "PrevTab"),
// Focus
("ctrl-w h", "FocusSidebar"),
// Session scrollback
("pageup", "ScrollPanePageUp"),
("pagedown", "ScrollPanePageDown"),
// Chrome
("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"),
("ctrl-w q", "Quit"),
// Mouse
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
("mouse:click(tab)", "NextTab"),
];
/// Default Insert-mode bindings. Empty by default — the only Insert binding
@@ -76,6 +78,20 @@ impl TuiConfig {
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
/// applied first, then user overrides on top.
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
TuiTheme::Dark => Palette::DARK,
TuiTheme::Light => Palette::LIGHT,
TuiTheme::Ansi => Palette::ANSI,
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
TuiTheme::Catppuccin => Palette::CATPPUCCIN,
TuiTheme::GithubDark => Palette::GITHUB_DARK,
TuiTheme::GithubLight => Palette::GITHUB_LIGHT,
TuiTheme::TokyoNight => Palette::TOKYO_NIGHT,
TuiTheme::Gruvbox => Palette::GRUVBOX,
TuiTheme::Nord => Palette::NORD,
TuiTheme::Dracula => Palette::DRACULA,
};
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
@@ -131,6 +147,7 @@ impl TuiConfig {
}
Ok(TuiConfig {
palette,
leader,
chord_timeout,
keymap,
@@ -143,6 +160,37 @@ impl TuiConfig {
mod tests {
use super::*;
use crate::action::Action;
use maestro_config::{TuiSection, TuiTheme};
#[test]
fn absent_theme_selects_the_dark_default() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(cfg.palette, Palette::DARK);
}
#[test]
fn named_themes_select_the_matching_complete_palette() {
for (theme, expected) in [
(TuiTheme::Dark, Palette::DARK),
(TuiTheme::Light, Palette::LIGHT),
(TuiTheme::Ansi, Palette::ANSI),
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
(TuiTheme::Catppuccin, Palette::CATPPUCCIN),
(TuiTheme::GithubDark, Palette::GITHUB_DARK),
(TuiTheme::GithubLight, Palette::GITHUB_LIGHT),
(TuiTheme::TokyoNight, Palette::TOKYO_NIGHT),
(TuiTheme::Gruvbox, Palette::GRUVBOX),
(TuiTheme::Nord, Palette::NORD),
(TuiTheme::Dracula, Palette::DRACULA),
] {
let section = TuiSection {
theme: Some(theme),
..Default::default()
};
assert_eq!(TuiConfig::load(&section).unwrap().palette, expected);
}
}
#[test]
fn defaults_cover_core_actions() {
@@ -155,10 +203,6 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "enter"),
Some(Action::AttachHighlighted),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
@@ -238,6 +282,26 @@ mod tests {
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
}
/// Regression for #70: the previous canonical form of a real Shift+S
/// keypress was `"shift-S"`, which the default `"S"` binding never
/// matched. Drive the actual crossterm event, not the binding string,
/// so the dead-key bug can't come back unnoticed.
#[test]
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
use crate::input::{canonicalise, Region};
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
assert_eq!(
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
Some(Action::FastForwardSpawn),
);
}
#[test]
fn custom_chord_timeout_is_applied() {
let mut section = TuiSection::default();
File diff suppressed because it is too large Load Diff
-1
View File
@@ -80,7 +80,6 @@ impl FastForwardDefaults {
.clone()
.unwrap_or_else(|| project.path.clone()),
branch: name.to_string(),
cleanup_on_kill: true,
create_branch: true,
}),
})
+30 -5
View File
@@ -36,6 +36,8 @@ pub enum InputKey {
Right,
Home,
End,
PageUp,
PageDown,
Delete,
/// Bracketed paste: crossterm delivers the whole payload as one event, so
/// text fields can insert it as a single operation.
@@ -63,7 +65,6 @@ pub enum MouseKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
SidebarRow,
Tab,
Pane,
Modal,
Unknown,
@@ -117,7 +118,9 @@ impl InputKey {
if *alt {
out.push_str("alt-");
}
if *shift {
// Uppercase already implies shift; crossterm sets the flag
// on every such char, so skip the redundant prefix (#70).
if *shift && !c.is_uppercase() {
out.push_str("shift-");
}
out.push(*c);
@@ -134,6 +137,8 @@ impl InputKey {
InputKey::Right => "right".into(),
InputKey::Home => "home".into(),
InputKey::End => "end".into(),
InputKey::PageUp => "pageup".into(),
InputKey::PageDown => "pagedown".into(),
InputKey::Delete => "delete".into(),
// Pastes are not bindable; the canonical form exists only to
// satisfy exhaustiveness and never round-trips through `parse`.
@@ -148,7 +153,6 @@ impl InputKey {
};
let r = match region {
Region::SidebarRow => "sidebar-row",
Region::Tab => "tab",
Region::Pane => "pane",
Region::Modal => "modal",
Region::Unknown => "unknown",
@@ -180,7 +184,6 @@ impl InputKey {
};
let region = match r {
"sidebar-row" => Region::SidebarRow,
"tab" => Region::Tab,
"pane" => Region::Pane,
"modal" => Region::Modal,
"unknown" => Region::Unknown,
@@ -205,6 +208,8 @@ impl InputKey {
"right" => return Ok(InputKey::Right),
"home" => return Ok(InputKey::Home),
"end" => return Ok(InputKey::End),
"pageup" => return Ok(InputKey::PageUp),
"pagedown" => return Ok(InputKey::PageDown),
"delete" => return Ok(InputKey::Delete),
_ => {}
}
@@ -279,6 +284,8 @@ where
KeyCode::Right => InputKey::Right,
KeyCode::Home => InputKey::Home,
KeyCode::End => InputKey::End,
KeyCode::PageUp => InputKey::PageUp,
KeyCode::PageDown => InputKey::PageDown,
KeyCode::Delete => InputKey::Delete,
_ => return None,
})
@@ -580,12 +587,28 @@ mod tests {
#[test]
fn canonical_round_trips_simple_chars() {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
/// the uppercase letter already says "shifted"; a redundant prefix would
/// make the default `S` binding unreachable from the keyboard (#70).
#[test]
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
let k = InputKey::Char {
c: 'S',
ctrl: false,
alt: false,
shift: true,
};
assert_eq!(k.canonical(), "S");
}
#[test]
fn canonical_round_trips_specials() {
for s in [
@@ -599,6 +622,8 @@ mod tests {
"right",
"home",
"end",
"pageup",
"pagedown",
"delete",
] {
let k = InputKey::parse(s).unwrap();
+4 -4
View File
@@ -107,8 +107,8 @@ mod tests {
#[test]
fn resolves_chord() {
let k = build(&[(Mode::Normal, "g t", "NextTab")]);
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
let k = build(&[(Mode::Normal, "g h", "FocusSidebar")]);
assert_eq!(k.resolve(Mode::Normal, "g h"), Some(Action::FocusSidebar));
}
#[test]
@@ -123,11 +123,11 @@ mod tests {
#[test]
fn known_bindings_sees_everything() {
let k = build(&[
(Mode::Normal, "g t", "NextTab"),
(Mode::Normal, "g h", "FocusSidebar"),
(Mode::Normal, "i", "EnterInsert"),
]);
let kb = k.known_bindings(Mode::Normal);
assert!(kb.contains("g t"));
assert!(kb.contains("g h"));
assert!(kb.contains("i"));
assert_eq!(kb.len(), 2);
}
+2 -1
View File
@@ -13,13 +13,13 @@ pub mod fast_forward;
pub mod input;
pub mod keymap;
pub mod overlays;
pub mod palette;
pub mod pane;
pub mod projects;
pub mod render;
pub mod session_task;
pub mod sidebar;
pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
pub mod worktrees;
@@ -89,6 +89,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
Err(_) => maestro_config::MaestroConfigFile::default(),
};
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
palette::set_active(tui_cfg.palette);
let catalog = CatalogStore::default();
let ws_url = daemon
+98 -12
View File
@@ -1,4 +1,5 @@
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay,
//! transient toast.
//!
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]).
@@ -7,30 +8,82 @@
//! `Clear` widget wipes the underlying buffer before drawing.
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::text_input::TextInput;
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
/// #83: what the daemon's watcher last saw in the worktree, phrased by
/// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused"
/// is visible before the attempt. `None` while nothing blocks.
pub blockers: Option<String>,
}
impl KillConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 50, 5);
let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 });
f.render_widget(Clear, area);
let block = Block::default().title(" kill ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
if let Some(blockers) = &self.blockers {
// Same phrasing the reclaim confirmation uses for its own guard.
lines.push(Line::from(format!("Currently in the way: {blockers}")));
lines.push(Line::from("The kill will be refused unless forced."));
}
lines.push(Line::from(""));
lines.push(Line::from("[y] yes [n] no"));
f.render_widget(Paragraph::new(lines), inner);
}
}
/// Rename prompt for the highlighted session (#125).
///
/// Borrows its text buffer rather than owning it: the buffer lives on `App`
/// alongside the worktrees view's entries, so [`crate::state::OverlayKind`]
/// stays a plain comparable enum.
#[derive(Debug)]
pub struct RenamePrompt<'a> {
/// What the card shows today — the session's name, or the command it
/// falls back to when it has none.
pub current: &'a str,
pub input: &'a TextInput,
}
impl RenamePrompt<'_> {
/// Draws the prompt and returns where the terminal cursor belongs.
pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> {
let area = centered(parent, 64, 7);
f.render_widget(Clear, area);
let block = Block::default().title(" rename ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
if inner.height < 4 || inner.width == 0 {
return None;
}
let row = |offset: u16| Rect {
x: inner.x,
y: inner.y + offset,
width: inner.width,
height: 1,
};
f.render_widget(
Paragraph::new(vec![
Line::from(format!("Kill session {}?", self.label)),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
Paragraph::new(Line::from(format!("Rename {}", self.current))),
row(0),
);
let field = row(1);
let col = self.input.render(field, f.buffer_mut(), Style::default());
f.render_widget(
Paragraph::new(Line::from("[enter] save [esc] cancel")),
row(3),
);
Some((col, field.y))
}
}
@@ -121,6 +174,7 @@ mod tests {
fn kill_confirm_renders_prompt() {
let modal = KillConfirm {
label: "session-7".into(),
blockers: None,
};
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
assert!(rendered.contains("Kill"));
@@ -129,16 +183,48 @@ mod tests {
assert!(rendered.contains("no"));
}
/// #83: when the worktree blocks, the confirm names it before the
/// operator commits — in `InTheWay`'s own words.
#[test]
fn kill_confirm_names_what_blocks() {
let modal = KillConfirm {
label: "session-7".into(),
blockers: Some("2 uncommitted files".into()),
};
let rendered = render_to_string(70, 10, |f| modal.render(f, f.area()));
assert!(
rendered.contains("Currently in the way: 2 uncommitted files"),
"{rendered}"
);
assert!(rendered.contains("refused"), "{rendered}");
}
#[test]
fn rename_prompt_shows_the_current_name_and_the_edit_buffer() {
let input = TextInput::with_value("crimson-otter");
let prompt = RenamePrompt {
current: "amber-falcon",
input: &input,
};
let rendered = render_to_string(70, 12, |f| {
prompt.render(f, f.area());
});
assert!(rendered.contains("Rename amber-falcon"), "{rendered}");
assert!(rendered.contains("crimson-otter"), "{rendered}");
assert!(rendered.contains("[enter] save"), "{rendered}");
assert!(rendered.contains("[esc] cancel"), "{rendered}");
}
#[test]
fn help_overlay_lists_lines() {
let help = HelpOverlay {
lines: vec![
"i EnterInsert".into(),
"s NewDraftPane".into(),
"i Send keys to session".into(),
"s Create a session".into(),
],
};
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("NewDraftPane"));
assert!(rendered.contains("Send keys to session"));
assert!(rendered.contains("Create a session"));
}
}
+851
View File
@@ -0,0 +1,851 @@
//! Named colour roles for the TUI.
//!
//! Every colour the TUI paints on its own chrome comes from here, named for
//! what it means rather than what it looks like: the card surface, the
//! selection surface, the panel border in its focused and unfocused forms,
//! the text weights (`fg` / `muted` / `dim`), and one role per
//! [`ActivityState`]. Call sites ask the palette for a role; they never spell
//! a colour out.
//!
//! Values are [`Color::Rgb`], so contrast is a property of the palette rather
//! than of the user's terminal theme — and can therefore be asserted (see
//! [`contrast_ratio`] and this module's tests).
//!
//! Each surface carries its own activity colours. A state colour picked for
//! the dark card background is not legible on the selection background, so
//! the selected card uses the selection surface's own state colours; the dot
//! keeps carrying the state either way (docs/SIDEBAR.md).
//!
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
//! [`Palette::ANSI`] uses terminal-provided ANSI colours. The remaining
//! presets ([`Palette::presets`]) adapt well-known editor themes — Solarized,
//! Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord, Dracula — with individual
//! colours lifted or darkened where the originals fall short of the contrast
//! floors asserted below.
//!
//! Out of scope: PTY cell colours. Those belong to the program running in the
//! pane and pass through `pane::translate_color` verbatim.
use maestro_protocol::ActivityState;
use ratatui::style::{Color, Modifier, Style};
/// A background and the foreground roles legible on it.
///
/// The sidebar has two: the card surface every card sits on, and the
/// selection surface the highlighted card swaps in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Surface {
/// The surface itself.
pub bg: Color,
/// Primary text on this surface.
pub fg: Color,
/// Secondary text — the blocked-work, exited, detail and command
/// fragments of a card's meta line.
pub muted: Color,
/// The quietest legible text on this surface, for decoration that must
/// not compete with content.
pub dim: Color,
/// `ActivityState::Active`.
pub active: Color,
/// `ActivityState::Idle`.
pub idle: Color,
/// `ActivityState::AwaitingInput`.
pub awaiting: Color,
/// `ActivityState::Error`.
pub error: Color,
}
impl Surface {
/// The bare surface: a background with no foreground override.
pub fn base(&self) -> Style {
Style::default().bg(self.bg)
}
/// Primary text on this surface.
pub fn text_style(&self) -> Style {
Style::default().bg(self.bg).fg(self.fg)
}
/// Secondary text on this surface.
pub fn muted_style(&self) -> Style {
Style::default().bg(self.bg).fg(self.muted)
}
/// The quietest text on this surface.
pub fn dim_style(&self) -> Style {
Style::default().bg(self.bg).fg(self.dim)
}
/// The colour carrying `state` on this surface.
pub fn activity(&self, state: ActivityState) -> Color {
match state {
ActivityState::Active => self.active,
ActivityState::Idle => self.idle,
ActivityState::AwaitingInput => self.awaiting,
ActivityState::Error => self.error,
}
}
/// Every foreground role on this surface, paired with its role name.
/// Used by the contrast tests, and by anything that needs to reason about
/// the surface as a whole.
pub fn roles(&self) -> [(&'static str, Color); 7] {
[
("fg", self.fg),
("muted", self.muted),
("dim", self.dim),
("active", self.active),
("idle", self.idle),
("awaiting", self.awaiting),
("error", self.error),
]
}
}
/// The full set of colour roles the TUI paints with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
/// The surface an unselected card sits on.
pub card: Surface,
/// The surface the highlighted card swaps in.
pub selection: Surface,
/// Background for TUI-owned chrome outside cards. Fixed-RGB chrome must
/// paint this background too; otherwise its contrast depends on the
/// operator's terminal theme.
pub chrome_bg: Color,
/// Subtle full-panel background while the sidebar owns focus.
pub sidebar_focus_bg: Color,
/// Panel border while that panel holds focus.
pub border_focused: Color,
/// Panel border while it does not.
pub border_unfocused: Color,
/// Focus affordances that are not the border itself — markers, titles and
/// the like on the panel that holds focus.
pub focus_accent: Color,
/// Sidebar group headings.
pub group_row: Color,
/// Status-bar mode indicator, normal mode.
pub mode_normal_bg: Color,
pub mode_normal_fg: Color,
/// Status-bar mode indicator, insert mode.
pub mode_insert_bg: Color,
pub mode_insert_fg: Color,
/// Error text outside a card — form errors, failed actions.
pub error_text: Color,
}
impl Palette {
/// The dark terminal-native palette and the default preset.
pub const DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x1f, 0x23, 0x2b),
fg: Color::Rgb(0xe5, 0xe7, 0xeb),
muted: Color::Rgb(0xae, 0xb7, 0xc4),
dim: Color::Rgb(0x85, 0x8f, 0x9e),
active: Color::Rgb(0x72, 0xd6, 0x87),
idle: Color::Rgb(0xae, 0xb7, 0xc4),
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
error: Color::Rgb(0xff, 0x82, 0x7a),
},
selection: Surface {
bg: Color::Rgb(0x3d, 0x51, 0x44),
fg: Color::Rgb(0xf3, 0xf7, 0xf4),
muted: Color::Rgb(0xd0, 0xd9, 0xd3),
dim: Color::Rgb(0xa1, 0xb0, 0xa6),
active: Color::Rgb(0x9a, 0xf0, 0xa6),
idle: Color::Rgb(0xc4, 0xcf, 0xc7),
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
error: Color::Rgb(0xff, 0xa8, 0xa3),
},
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
sidebar_focus_bg: Color::Rgb(0x28, 0x30, 0x2a),
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
group_row: Color::Rgb(0xb6, 0xc1, 0xd3),
mode_normal_bg: Color::Rgb(0x72, 0xd6, 0x87),
mode_normal_fg: Color::Rgb(0x1f, 0x23, 0x2b),
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b),
error_text: Color::Rgb(0xff, 0x82, 0x7a),
};
/// A high-contrast light peer to [`Self::DARK`].
pub const LIGHT: Palette = Palette {
card: Surface {
bg: Color::Rgb(0xff, 0xff, 0xff),
fg: Color::Rgb(0x27, 0x31, 0x3d),
muted: Color::Rgb(0x4c, 0x5b, 0x6b),
dim: Color::Rgb(0x68, 0x78, 0x8a),
active: Color::Rgb(0x21, 0x7a, 0x3c),
idle: Color::Rgb(0x4c, 0x5b, 0x6b),
awaiting: Color::Rgb(0x8f, 0x63, 0x00),
error: Color::Rgb(0xb4, 0x23, 0x18),
},
selection: Surface {
bg: Color::Rgb(0xb5, 0xcf, 0xba),
fg: Color::Rgb(0x1d, 0x2a, 0x24),
muted: Color::Rgb(0x3f, 0x55, 0x45),
dim: Color::Rgb(0x52, 0x68, 0x5a),
active: Color::Rgb(0x15, 0x5b, 0x2e),
idle: Color::Rgb(0x3f, 0x55, 0x45),
awaiting: Color::Rgb(0x69, 0x46, 0x00),
error: Color::Rgb(0xa5, 0x1d, 0x14),
},
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
sidebar_focus_bg: Color::Rgb(0xee, 0xf5, 0xef),
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
group_row: Color::Rgb(0x3f, 0x55, 0x45),
mode_normal_bg: Color::Rgb(0x21, 0x7a, 0x3c),
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
mode_insert_bg: Color::Rgb(0xb6, 0x81, 0x08),
mode_insert_fg: Color::Rgb(0x0b, 0x12, 0x0e),
error_text: Color::Rgb(0xb4, 0x23, 0x18),
};
/// A palette of named ANSI colours for terminals without truecolour.
pub const ANSI: Palette = Palette {
card: Surface {
bg: Color::Black,
fg: Color::White,
muted: Color::Gray,
dim: Color::DarkGray,
active: Color::Green,
idle: Color::Gray,
awaiting: Color::Yellow,
error: Color::Red,
},
selection: Surface {
bg: Color::Blue,
fg: Color::White,
muted: Color::White,
dim: Color::LightBlue,
active: Color::LightGreen,
idle: Color::White,
awaiting: Color::LightYellow,
error: Color::LightRed,
},
chrome_bg: Color::Black,
sidebar_focus_bg: Color::DarkGray,
border_focused: Color::LightGreen,
border_unfocused: Color::DarkGray,
focus_accent: Color::LightGreen,
group_row: Color::LightCyan,
mode_normal_bg: Color::Green,
mode_normal_fg: Color::Black,
mode_insert_bg: Color::Yellow,
mode_insert_fg: Color::Black,
error_text: Color::LightRed,
};
/// Solarized Dark — Ethan Schoonover's low-contrast classic,
/// accents lifted where the originals fall short of WCAG AA.
pub const SOLARIZED_DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x00, 0x2b, 0x36),
fg: Color::Rgb(0x93, 0xa1, 0xa1),
muted: Color::Rgb(0x83, 0x94, 0x96),
dim: Color::Rgb(0x65, 0x7b, 0x83),
active: Color::Rgb(0x85, 0x99, 0x00),
idle: Color::Rgb(0x83, 0x94, 0x96),
awaiting: Color::Rgb(0xb5, 0x89, 0x00),
error: Color::Rgb(0xe5, 0x68, 0x65),
},
selection: Surface {
bg: Color::Rgb(0x10, 0x53, 0x62),
fg: Color::Rgb(0xb6, 0xc0, 0xc0),
muted: Color::Rgb(0xb5, 0xc0, 0xc1),
dim: Color::Rgb(0x8b, 0x9f, 0xa6),
active: Color::Rgb(0xaf, 0xca, 0x00),
idle: Color::Rgb(0xb5, 0xc0, 0xc1),
awaiting: Color::Rgb(0xef, 0xb5, 0x00),
error: Color::Rgb(0xf1, 0xad, 0xab),
},
chrome_bg: Color::Rgb(0x00, 0x2b, 0x36),
sidebar_focus_bg: Color::Rgb(0x00, 0x1a, 0x21),
border_focused: Color::Rgb(0x2a, 0xa1, 0x98),
border_unfocused: Color::Rgb(0x5e, 0x76, 0x7e),
focus_accent: Color::Rgb(0x2a, 0xa1, 0x98),
group_row: Color::Rgb(0x93, 0xa1, 0xa1),
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
mode_insert_bg: Color::Rgb(0xb5, 0x89, 0x00),
mode_insert_fg: Color::Rgb(0x00, 0x2b, 0x36),
error_text: Color::Rgb(0xe5, 0x68, 0x65),
};
/// Solarized Light — the warm cream-paper peer of Solarized Dark.
pub const SOLARIZED_LIGHT: Palette = Palette {
card: Surface {
bg: Color::Rgb(0xfd, 0xf6, 0xe3),
fg: Color::Rgb(0x58, 0x6e, 0x75),
muted: Color::Rgb(0x5e, 0x73, 0x7a),
dim: Color::Rgb(0x7e, 0x8f, 0x92),
active: Color::Rgb(0x67, 0x76, 0x00),
idle: Color::Rgb(0x5e, 0x73, 0x7a),
awaiting: Color::Rgb(0x8d, 0x6a, 0x00),
error: Color::Rgb(0xd7, 0x27, 0x24),
},
selection: Surface {
bg: Color::Rgb(0xcb, 0xbd, 0x93),
fg: Color::Rgb(0x3f, 0x4e, 0x53),
muted: Color::Rgb(0x40, 0x4e, 0x53),
dim: Color::Rgb(0x59, 0x67, 0x69),
active: Color::Rgb(0x46, 0x50, 0x00),
idle: Color::Rgb(0x40, 0x4e, 0x53),
awaiting: Color::Rgb(0x60, 0x48, 0x00),
error: Color::Rgb(0x94, 0x1b, 0x19),
},
chrome_bg: Color::Rgb(0xfd, 0xf6, 0xe3),
sidebar_focus_bg: Color::Rgb(0xf7, 0xf4, 0xed),
border_focused: Color::Rgb(0x29, 0x9b, 0x93),
border_unfocused: Color::Rgb(0x7f, 0x8f, 0x8f),
focus_accent: Color::Rgb(0x29, 0x9b, 0x93),
group_row: Color::Rgb(0x58, 0x6e, 0x75),
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
mode_insert_bg: Color::Rgb(0x8d, 0x6a, 0x00),
mode_insert_fg: Color::Rgb(0xfd, 0xf6, 0xe3),
error_text: Color::Rgb(0xd7, 0x27, 0x24),
};
/// Catppuccin Mocha — soothing pastels on a soft dark base.
pub const CATPPUCCIN: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x1e, 0x1e, 0x2e),
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
muted: Color::Rgb(0xa6, 0xad, 0xc8),
dim: Color::Rgb(0x7f, 0x84, 0x9c),
active: Color::Rgb(0xa6, 0xe3, 0xa1),
idle: Color::Rgb(0xa6, 0xad, 0xc8),
awaiting: Color::Rgb(0xf9, 0xe2, 0xaf),
error: Color::Rgb(0xf3, 0x8b, 0xa8),
},
selection: Surface {
bg: Color::Rgb(0x45, 0x47, 0x5a),
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
muted: Color::Rgb(0xb2, 0xb8, 0xcf),
dim: Color::Rgb(0x92, 0x97, 0xab),
active: Color::Rgb(0xb5, 0xe8, 0xb1),
idle: Color::Rgb(0xb2, 0xb8, 0xcf),
awaiting: Color::Rgb(0xfa, 0xe9, 0xc2),
error: Color::Rgb(0xf5, 0xa1, 0xb8),
},
chrome_bg: Color::Rgb(0x1e, 0x1e, 0x2e),
sidebar_focus_bg: Color::Rgb(0x2a, 0x2a, 0x3c),
border_focused: Color::Rgb(0xcb, 0xa6, 0xf7),
border_unfocused: Color::Rgb(0x6c, 0x70, 0x86),
focus_accent: Color::Rgb(0xcb, 0xa6, 0xf7),
group_row: Color::Rgb(0xb4, 0xbe, 0xfe),
mode_normal_bg: Color::Rgb(0xcb, 0xa6, 0xf7),
mode_normal_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
mode_insert_bg: Color::Rgb(0xf9, 0xe2, 0xaf),
mode_insert_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
error_text: Color::Rgb(0xf3, 0x8b, 0xa8),
};
/// GitHub Dark — the github.com dark default.
pub const GITHUB_DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x0d, 0x11, 0x17),
fg: Color::Rgb(0xe6, 0xed, 0xf3),
muted: Color::Rgb(0x91, 0x98, 0xa1),
dim: Color::Rgb(0x6e, 0x76, 0x81),
active: Color::Rgb(0x3f, 0xb9, 0x50),
idle: Color::Rgb(0x91, 0x98, 0xa1),
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
error: Color::Rgb(0xf8, 0x51, 0x49),
},
selection: Surface {
bg: Color::Rgb(0x1f, 0x3a, 0x5f),
fg: Color::Rgb(0xe6, 0xed, 0xf3),
muted: Color::Rgb(0x9f, 0xa5, 0xad),
dim: Color::Rgb(0x7e, 0x86, 0x91),
active: Color::Rgb(0x40, 0xbc, 0x51),
idle: Color::Rgb(0x9f, 0xa5, 0xad),
awaiting: Color::Rgb(0xd4, 0x9b, 0x22),
error: Color::Rgb(0xfa, 0x81, 0x7b),
},
chrome_bg: Color::Rgb(0x0d, 0x11, 0x17),
sidebar_focus_bg: Color::Rgb(0x16, 0x1b, 0x26),
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
border_unfocused: Color::Rgb(0x58, 0x64, 0x70),
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
group_row: Color::Rgb(0x91, 0x98, 0xa1),
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
mode_normal_fg: Color::Rgb(0x0d, 0x11, 0x17),
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
mode_insert_fg: Color::Rgb(0x0d, 0x11, 0x17),
error_text: Color::Rgb(0xf8, 0x51, 0x49),
};
/// GitHub Light — the github.com light default.
pub const GITHUB_LIGHT: Palette = Palette {
card: Surface {
bg: Color::Rgb(0xff, 0xff, 0xff),
fg: Color::Rgb(0x1f, 0x23, 0x28),
muted: Color::Rgb(0x59, 0x63, 0x6e),
dim: Color::Rgb(0x81, 0x8b, 0x98),
active: Color::Rgb(0x1a, 0x7f, 0x37),
idle: Color::Rgb(0x59, 0x63, 0x6e),
awaiting: Color::Rgb(0x9a, 0x67, 0x00),
error: Color::Rgb(0xd1, 0x24, 0x2f),
},
selection: Surface {
bg: Color::Rgb(0xa5, 0xcd, 0xf0),
fg: Color::Rgb(0x1f, 0x23, 0x28),
muted: Color::Rgb(0x4b, 0x53, 0x5d),
dim: Color::Rgb(0x64, 0x6d, 0x7a),
active: Color::Rgb(0x14, 0x5f, 0x29),
idle: Color::Rgb(0x4b, 0x53, 0x5d),
awaiting: Color::Rgb(0x71, 0x4b, 0x00),
error: Color::Rgb(0xa1, 0x1c, 0x24),
},
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
sidebar_focus_bg: Color::Rgb(0xf0, 0xf6, 0xfc),
border_focused: Color::Rgb(0x09, 0x69, 0xda),
border_unfocused: Color::Rgb(0x89, 0x93, 0x9d),
focus_accent: Color::Rgb(0x09, 0x69, 0xda),
group_row: Color::Rgb(0x59, 0x63, 0x6e),
mode_normal_bg: Color::Rgb(0x09, 0x69, 0xda),
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
mode_insert_bg: Color::Rgb(0x9a, 0x67, 0x00),
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
error_text: Color::Rgb(0xd1, 0x24, 0x2f),
};
/// Tokyo Night — neon-tinted blues of the VS Code / vim favourite.
pub const TOKYO_NIGHT: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x1a, 0x1b, 0x26),
fg: Color::Rgb(0xc0, 0xca, 0xf5),
muted: Color::Rgb(0xa9, 0xb1, 0xd6),
dim: Color::Rgb(0x5d, 0x67, 0x95),
active: Color::Rgb(0x9e, 0xce, 0x6a),
idle: Color::Rgb(0xa9, 0xb1, 0xd6),
awaiting: Color::Rgb(0xe0, 0xaf, 0x68),
error: Color::Rgb(0xf7, 0x76, 0x8e),
},
selection: Surface {
bg: Color::Rgb(0x33, 0x46, 0x7c),
fg: Color::Rgb(0xc0, 0xca, 0xf5),
muted: Color::Rgb(0xb0, 0xb8, 0xd9),
dim: Color::Rgb(0x8e, 0x96, 0xb8),
active: Color::Rgb(0xa8, 0xd3, 0x79),
idle: Color::Rgb(0xb0, 0xb8, 0xd9),
awaiting: Color::Rgb(0xe1, 0xb1, 0x6b),
error: Color::Rgb(0xf9, 0xa0, 0xb0),
},
chrome_bg: Color::Rgb(0x1a, 0x1b, 0x26),
sidebar_focus_bg: Color::Rgb(0x24, 0x28, 0x3b),
border_focused: Color::Rgb(0x7a, 0xa2, 0xf7),
border_unfocused: Color::Rgb(0x5d, 0x67, 0x95),
focus_accent: Color::Rgb(0x7a, 0xa2, 0xf7),
group_row: Color::Rgb(0xa9, 0xb1, 0xd6),
mode_normal_bg: Color::Rgb(0x7a, 0xa2, 0xf7),
mode_normal_fg: Color::Rgb(0x1a, 0x1b, 0x26),
mode_insert_bg: Color::Rgb(0xe0, 0xaf, 0x68),
mode_insert_fg: Color::Rgb(0x1a, 0x1b, 0x26),
error_text: Color::Rgb(0xf7, 0x76, 0x8e),
};
/// Gruvbox Dark — retro warm earth tones.
pub const GRUVBOX: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x28, 0x28, 0x28),
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
muted: Color::Rgb(0xbd, 0xae, 0x93),
dim: Color::Rgb(0x92, 0x83, 0x74),
active: Color::Rgb(0xb8, 0xbb, 0x26),
idle: Color::Rgb(0xbd, 0xae, 0x93),
awaiting: Color::Rgb(0xfa, 0xbd, 0x2f),
error: Color::Rgb(0xfb, 0x58, 0x44),
},
selection: Surface {
bg: Color::Rgb(0x50, 0x49, 0x45),
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
muted: Color::Rgb(0xc7, 0xbb, 0xa4),
dim: Color::Rgb(0xa4, 0x98, 0x8b),
active: Color::Rgb(0xbf, 0xc3, 0x28),
idle: Color::Rgb(0xc7, 0xbb, 0xa4),
awaiting: Color::Rgb(0xfa, 0xc3, 0x43),
error: Color::Rgb(0xfd, 0xa5, 0x9a),
},
chrome_bg: Color::Rgb(0x28, 0x28, 0x28),
sidebar_focus_bg: Color::Rgb(0x32, 0x30, 0x2f),
border_focused: Color::Rgb(0xfe, 0x80, 0x19),
border_unfocused: Color::Rgb(0x7e, 0x71, 0x67),
focus_accent: Color::Rgb(0xfe, 0x80, 0x19),
group_row: Color::Rgb(0xd5, 0xc4, 0xa1),
mode_normal_bg: Color::Rgb(0xfe, 0x80, 0x19),
mode_normal_fg: Color::Rgb(0x28, 0x28, 0x28),
mode_insert_bg: Color::Rgb(0xfa, 0xbd, 0x2f),
mode_insert_fg: Color::Rgb(0x28, 0x28, 0x28),
error_text: Color::Rgb(0xfb, 0x58, 0x44),
};
/// Nord — arctic, bluish and calm; the red lifted to stay legible.
pub const NORD: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x2e, 0x34, 0x40),
fg: Color::Rgb(0xec, 0xef, 0xf4),
muted: Color::Rgb(0xd8, 0xde, 0xe9),
dim: Color::Rgb(0x9a, 0xa5, 0xb5),
active: Color::Rgb(0xa3, 0xbe, 0x8c),
idle: Color::Rgb(0xd8, 0xde, 0xe9),
awaiting: Color::Rgb(0xeb, 0xcb, 0x8b),
error: Color::Rgb(0xd0, 0x8b, 0x91),
},
selection: Surface {
bg: Color::Rgb(0x4c, 0x56, 0x6a),
fg: Color::Rgb(0xec, 0xef, 0xf4),
muted: Color::Rgb(0xd8, 0xde, 0xe9),
dim: Color::Rgb(0x9f, 0xaa, 0xb9),
active: Color::Rgb(0xc0, 0xd3, 0xb0),
idle: Color::Rgb(0xe5, 0xe9, 0xf0),
awaiting: Color::Rgb(0xee, 0xd3, 0x9c),
error: Color::Rgb(0xe7, 0xc5, 0xc8),
},
chrome_bg: Color::Rgb(0x2e, 0x34, 0x40),
sidebar_focus_bg: Color::Rgb(0x35, 0x3b, 0x49),
border_focused: Color::Rgb(0x88, 0xc0, 0xd0),
border_unfocused: Color::Rgb(0x72, 0x80, 0x9a),
focus_accent: Color::Rgb(0x88, 0xc0, 0xd0),
group_row: Color::Rgb(0xd8, 0xde, 0xe9),
mode_normal_bg: Color::Rgb(0x88, 0xc0, 0xd0),
mode_normal_fg: Color::Rgb(0x2e, 0x34, 0x40),
mode_insert_bg: Color::Rgb(0xeb, 0xcb, 0x8b),
mode_insert_fg: Color::Rgb(0x2e, 0x34, 0x40),
error_text: Color::Rgb(0xd0, 0x8b, 0x91),
};
/// Dracula — high-saturation accents on a dark violet-grey base.
pub const DRACULA: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x28, 0x2a, 0x36),
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
dim: Color::Rgb(0x65, 0x74, 0xa6),
active: Color::Rgb(0x50, 0xfa, 0x7b),
idle: Color::Rgb(0xbf, 0xc3, 0xd9),
awaiting: Color::Rgb(0xf1, 0xfa, 0x8c),
error: Color::Rgb(0xff, 0x59, 0x59),
},
selection: Surface {
bg: Color::Rgb(0x4a, 0x4e, 0x62),
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
dim: Color::Rgb(0x94, 0x9f, 0xc1),
active: Color::Rgb(0x64, 0xfb, 0x8a),
idle: Color::Rgb(0xcc, 0xcf, 0xe1),
awaiting: Color::Rgb(0xf3, 0xfb, 0xa0),
error: Color::Rgb(0xff, 0xad, 0xad),
},
chrome_bg: Color::Rgb(0x28, 0x2a, 0x36),
sidebar_focus_bg: Color::Rgb(0x31, 0x33, 0x42),
border_focused: Color::Rgb(0xbd, 0x93, 0xf9),
border_unfocused: Color::Rgb(0x65, 0x74, 0xa6),
focus_accent: Color::Rgb(0xbd, 0x93, 0xf9),
group_row: Color::Rgb(0xbf, 0xc3, 0xd9),
mode_normal_bg: Color::Rgb(0xbd, 0x93, 0xf9),
mode_normal_fg: Color::Rgb(0x28, 0x2a, 0x36),
mode_insert_bg: Color::Rgb(0xf1, 0xfa, 0x8c),
mode_insert_fg: Color::Rgb(0x28, 0x2a, 0x36),
error_text: Color::Rgb(0xff, 0x59, 0x59),
};
/// The default preset, retained as a named compatibility constant.
pub const DEFAULT: Palette = Self::DARK;
/// All preset names and their complete palettes.
pub fn presets() -> [(&'static str, Palette); 12] {
[
("dark", Self::DARK),
("light", Self::LIGHT),
("ansi", Self::ANSI),
("solarized-dark", Self::SOLARIZED_DARK),
("solarized-light", Self::SOLARIZED_LIGHT),
("catppuccin", Self::CATPPUCCIN),
("github-dark", Self::GITHUB_DARK),
("github-light", Self::GITHUB_LIGHT),
("tokyo-night", Self::TOKYO_NIGHT),
("gruvbox", Self::GRUVBOX),
("nord", Self::NORD),
("dracula", Self::DRACULA),
]
}
/// Resolve a config name to a palette.
pub fn named(name: &str) -> Option<Palette> {
Self::presets()
.into_iter()
.find(|(n, _)| *n == name)
.map(|(_, p)| p)
}
/// Every role, including the backgrounds it is painted against.
pub fn roles(&self) -> [(&'static str, Color); 27] {
[
("card.bg", self.card.bg),
("card.fg", self.card.fg),
("card.muted", self.card.muted),
("card.dim", self.card.dim),
("card.active", self.card.active),
("card.idle", self.card.idle),
("card.awaiting", self.card.awaiting),
("card.error", self.card.error),
("selection.bg", self.selection.bg),
("selection.fg", self.selection.fg),
("selection.muted", self.selection.muted),
("selection.dim", self.selection.dim),
("selection.active", self.selection.active),
("selection.idle", self.selection.idle),
("selection.awaiting", self.selection.awaiting),
("selection.error", self.selection.error),
("chrome_bg", self.chrome_bg),
("sidebar_focus_bg", self.sidebar_focus_bg),
("border_focused", self.border_focused),
("border_unfocused", self.border_unfocused),
("focus_accent", self.focus_accent),
("group_row", self.group_row),
("mode_normal_bg", self.mode_normal_bg),
("mode_normal_fg", self.mode_normal_fg),
("mode_insert_bg", self.mode_insert_bg),
("mode_insert_fg", self.mode_insert_fg),
("error_text", self.error_text),
]
}
/// The surface a card sits on: the selection surface when it is the
/// highlighted card, the card surface otherwise.
pub fn surface(&self, selected: bool) -> &Surface {
if selected {
&self.selection
} else {
&self.card
}
}
/// Border style for a panel, by whether it holds focus.
pub fn border_style(&self, focused: bool) -> Style {
if focused {
Style::default().bg(self.chrome_bg).fg(self.border_focused)
} else {
Style::default()
.bg(self.chrome_bg)
.fg(self.border_unfocused)
}
}
/// Style for a sidebar group heading.
pub fn group_row_style(&self) -> Style {
Style::default()
.bg(self.chrome_bg)
.fg(self.group_row)
.add_modifier(Modifier::BOLD)
}
/// Style for the status bar's mode indicator.
pub fn mode_style(&self, insert: bool) -> Style {
if insert {
Style::default()
.fg(self.mode_insert_fg)
.bg(self.mode_insert_bg)
} else {
Style::default()
.fg(self.mode_normal_fg)
.bg(self.mode_normal_bg)
}
}
/// Style for error text outside a card.
pub fn error_style(&self) -> Style {
Style::default().bg(self.chrome_bg).fg(self.error_text)
}
}
impl Default for Palette {
fn default() -> Self {
Self::DEFAULT
}
}
fn active_slot() -> &'static std::sync::RwLock<Palette> {
static ACTIVE: std::sync::OnceLock<std::sync::RwLock<Palette>> = std::sync::OnceLock::new();
ACTIVE.get_or_init(|| std::sync::RwLock::new(Palette::DEFAULT))
}
/// Set the palette used by the running TUI.
pub(crate) fn set_active(palette: Palette) {
*active_slot().write().expect("active palette lock poisoned") = palette;
}
/// The palette in force. Config-driven preset selection lands here at TUI
/// startup, before the first frame is drawn.
pub fn active() -> Palette {
*active_slot().read().expect("active palette lock poisoned")
}
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
/// luminance — they are whatever the terminal theme says — so they return
/// `None` and cannot be contrast-checked.
pub fn relative_luminance(color: Color) -> Option<f64> {
let Color::Rgb(r, g, b) = color else {
return None;
};
fn channel(v: u8) -> f64 {
let v = v as f64 / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b))
}
/// WCAG contrast ratio between two RGB roles, 1.0 (identical) to 21.0
/// (black on white). `None` if either colour is not RGB.
pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
let (la, lb) = (relative_luminance(a)?, relative_luminance(b)?);
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
Some((hi + 0.05) / (lo + 0.05))
}
#[cfg(test)]
mod tests {
use super::*;
const STATES: [ActivityState; 4] = [
ActivityState::Active,
ActivityState::Idle,
ActivityState::AwaitingInput,
ActivityState::Error,
];
/// Every preset except `ansi`, whose colours the terminal owns and which
/// therefore has no fixed luminance to assert against.
fn rgb_presets() -> impl Iterator<Item = (&'static str, Palette)> {
Palette::presets()
.into_iter()
.filter(|(_, p)| relative_luminance(p.card.bg).is_some())
}
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
/// decoration and only has to clear the 3:1 non-text threshold.
#[test]
fn every_role_is_legible_on_its_own_surface() {
for (preset, p) in rgb_presets() {
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
for (role, color) in surface.roles() {
let ratio = contrast_ratio(surface.bg, color)
.unwrap_or_else(|| panic!("{preset}.{surface_name}.{role} must be RGB"));
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{preset}.{surface_name}.{role} contrast {ratio:.2} is below {floor}"
);
}
}
}
}
#[test]
fn every_preset_supplies_every_role() {
for (name, p) in Palette::presets() {
assert_eq!(p.roles().len(), 27, "{name} role set is incomplete");
assert_eq!(Palette::named(name), Some(p));
}
assert_eq!(Palette::named("unknown"), None);
}
/// The selection background exists to be seen as a different surface, not
/// merely a slightly different shade of the card.
#[test]
fn selection_background_is_distinguishable_from_the_card_background() {
for (name, p) in rgb_presets() {
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
assert!(
ratio >= 1.6,
"{name} card and selection backgrounds differ by only {ratio:.2}"
);
}
}
/// The bug this palette fixes: a state colour chosen for the dark card
/// background is not legible on the selection background, so the
/// selection surface carries its own — distinct — state colours.
#[test]
fn selection_state_colours_are_not_the_card_state_colours() {
for (name, p) in Palette::presets() {
for state in STATES {
assert_ne!(
p.card.activity(state),
p.selection.activity(state),
"{name} {state:?} must have a selection-specific colour"
);
}
}
}
/// Other panels' roles are RGB too, so a preset swap moves all of them.
/// Foregrounds are checked against the chrome background they are
/// actually painted on; they must never rely on the terminal default.
#[test]
fn chrome_roles_are_rgb_values() {
for (preset, p) in rgb_presets() {
for (name, color) in p.roles().into_iter().skip(16) {
assert!(
relative_luminance(color).is_some(),
"{preset}.{name} must be an RGB value"
);
}
// The mode indicator paints fg on bg; both pairs must stay readable.
for (name, fg, bg) in [
("normal", p.mode_normal_fg, p.mode_normal_bg),
("insert", p.mode_insert_fg, p.mode_insert_bg),
] {
let ratio = contrast_ratio(fg, bg).unwrap();
assert!(
ratio >= 4.5,
"{preset} mode {name} contrast {ratio:.2} is below 4.5"
);
}
for (name, fg, floor) in [
("border_focused", p.border_focused, 3.0),
("border_unfocused", p.border_unfocused, 3.0),
("focus_accent", p.focus_accent, 3.0),
("group_row", p.group_row, 4.5),
("error_text", p.error_text, 4.5),
] {
let ratio = contrast_ratio(fg, p.chrome_bg).unwrap();
assert!(
ratio >= floor,
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
);
}
for (name, fg) in [
("card.fg", p.card.fg),
("card.muted", p.card.muted),
("group_row", p.group_row),
] {
let ratio = contrast_ratio(fg, p.sidebar_focus_bg).unwrap();
assert!(
ratio >= 4.5,
"{preset}.{name} contrast {ratio:.2} is below 4.5 on sidebar_focus_bg"
);
}
}
}
#[test]
fn contrast_ratio_matches_known_values() {
let black = Color::Rgb(0, 0, 0);
let white = Color::Rgb(255, 255, 255);
assert!((contrast_ratio(black, white).unwrap() - 21.0).abs() < 0.01);
assert!((contrast_ratio(white, white).unwrap() - 1.0).abs() < 0.01);
assert_eq!(contrast_ratio(Color::Cyan, white), None);
}
}
+4 -1
View File
@@ -74,7 +74,7 @@ impl Pane {
// Place the terminal cursor at vt100's cursor position when we own
// focus and the cursor is on-screen. Terminals blink it on their own.
if self.focused && !screen.hide_cursor() {
if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() {
let (cr, cc) = screen.cursor_position();
if cr < rows && cc < cols {
f.set_cursor_position((area.x + cc, area.y + cr));
@@ -106,6 +106,9 @@ fn translate_cell_style(cell: &vt100::Cell) -> Style {
s
}
/// PTY cell colours pass through verbatim: they belong to the program
/// running in the pane, not to the TUI's own chrome, so they are deliberately
/// not palette roles (see `crate::palette`).
fn translate_color(c: vt100::Color) -> Option<Color> {
match c {
vt100::Color::Default => None,
+214 -71
View File
@@ -1,20 +1,20 @@
//! Top-level draw orchestration. Composes sidebar / tab bar / pane / overlays
//! Top-level draw orchestration. Composes sidebar / pane / overlays
//! into one frame, and returns a [`RegionMap`] that the input router uses to
//! hit-test mouse clicks.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::style::Modifier;
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::input::Region;
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
use crate::palette;
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
use crate::worktrees::{ReclaimConfirm, WorktreesView};
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
@@ -22,7 +22,7 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView};
#[derive(Debug, Default, Clone)]
pub struct RegionMap {
pub sidebar: Option<Rect>,
pub tab_bar: Option<Rect>,
pub sidebar_items: Vec<SidebarItem>,
pub pane: Option<Rect>,
pub modal: Option<Rect>,
}
@@ -34,11 +34,6 @@ impl RegionMap {
return Region::Modal;
}
}
if let Some(r) = self.tab_bar {
if inside(r, col, row) {
return Region::Tab;
}
}
if let Some(r) = self.sidebar {
if inside(r, col, row) {
return Region::SidebarRow;
@@ -51,6 +46,24 @@ impl RegionMap {
}
Region::Unknown
}
/// Resolve a mouse row against the rendered sidebar sequence. The title
/// occupies one inner row; groups occupy one row and visible cards two.
pub fn sidebar_item_at(&self, row: u16) -> Option<SidebarItem> {
let area = self.sidebar?;
let mut cursor = area.y.saturating_add(1);
for item in &self.sidebar_items {
let height = match item {
SidebarItem::Group(_) => 1,
SidebarItem::Session(_) => 2,
};
if row >= cursor && row < cursor.saturating_add(height) {
return Some(item.clone());
}
cursor = cursor.saturating_add(height);
}
None
}
}
fn inside(r: Rect, col: u16, row: u16) -> bool {
@@ -62,14 +75,17 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
#[derive(Debug)]
pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub home_dir: &'a str,
pub pane_title: &'a str,
pub pane: Option<&'a Pane>,
/// Inline spawn form when the active tab's pane is a draft (issue #63).
/// Inline spawn form when the pane is a draft (issue #63).
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub help: &'a HelpOverlay,
/// Retained-worktree entries for the worktrees view (#85).
pub worktrees: Option<&'a WorktreesView>,
/// Edit buffer behind the rename prompt (#125).
pub rename_input: &'a crate::text_input::TextInput,
}
/// Draw one frame. Returns the `RegionMap` the input router will consult for
@@ -101,43 +117,36 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
let right = cols[1];
if inputs.state.sidebar_visible {
inputs
.sidebar
.render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
inputs.sidebar.render_with_focus_and_collapsed_home(
f,
sidebar_area,
inputs.state.focus == Focus::Sidebar,
&inputs.state.collapsed_sidebar_groups,
inputs.home_dir,
);
map.sidebar = Some(sidebar_area);
map.sidebar_items = inputs
.sidebar
.rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups);
}
// 1-row tab bar + 1-row horizontal rule (doubles as the pane's focus
// indicator) + flex pane area below. Saves the 4 rows the old
// `Length(3) + Borders::ALL` layout was eating before any content showed.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(right);
let tab_area = rows[0];
let separator_area = rows[1];
let pane_area = rows[2];
inputs.tab_bar.render(f, tab_area);
map.tab_bar = Some(tab_area);
let title_area = rows[0];
let pane_area = rows[1];
let pane_focused = inputs.state.focus == Focus::Pane;
f.render_widget(
Block::default()
.borders(Borders::TOP)
.border_style(focus_style(pane_focused)),
separator_area,
);
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
if let Some(p) = inputs.pane {
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_area, pane_focused);
}
// `RegionMap::pane` is also the PTY resize source. The title bar is
// chrome; every remaining cell belongs to the emulator.
map.pane = Some(pane_area);
// Status bar — always visible, never eclipsed by overlays so the user can
@@ -146,12 +155,40 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
match inputs.state.overlay() {
Some(OverlayKind::KillConfirm(id)) => {
// #83: surface what the watcher last saw in the worktree, in
// InTheWay's own words, before the operator commits to the kill.
let blockers = inputs
.sidebar
.sessions
.iter()
.find(|s| &s.id == id)
.and_then(|s| s.blocked_work)
.filter(|b| !b.is_clear())
.map(|b| b.to_string());
let kc = KillConfirm {
label: id.to_string(),
blockers,
};
kc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Rename(id)) => {
let current = inputs
.sidebar
.sessions
.iter()
.find(|s| &s.id == id)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| id.to_string());
let prompt = RenamePrompt {
current: &current,
input: inputs.rename_input,
};
if let Some((x, y)) = prompt.render(f, area) {
f.set_cursor_position((x, y));
}
map.modal = Some(area);
}
Some(OverlayKind::Help) => {
inputs.help.render(f, area);
map.modal = Some(area);
@@ -182,14 +219,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
map
}
/// Border style for a panel based on whether it currently has focus.
/// Focused = cyan; unfocused = the terminal default.
fn focus_style(focused: bool) -> Style {
if focused {
Style::default().fg(Color::Cyan)
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
let palette = palette::active();
let style = if focused {
palette.mode_style(false)
} else {
Style::default()
palette.card.text_style()
}
.add_modifier(Modifier::BOLD);
f.render_widget(Paragraph::new(format!(" {title}")).style(style), area);
}
/// Render the always-visible status bar at the bottom of the frame. Surfaces
@@ -206,12 +244,9 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
};
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
Mode::Insert => "^a: back to normal",
};
let mode_style = match state.mode {
Mode::Normal => Style::default().fg(Color::Black).bg(Color::Cyan),
Mode::Insert => Style::default().fg(Color::Black).bg(Color::Yellow),
Mode::Insert => "^a: return to sidebar",
};
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
let line = ratatui::text::Line::from(vec![
Span::styled(format!(" {mode_label} "), mode_style),
Span::raw(format!(" {focus_label} • {hint}")),
@@ -233,22 +268,16 @@ mod tests {
width: 10,
height: 20,
}),
tab_bar: Some(Rect {
sidebar_items: Vec::new(),
pane: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 3,
}),
pane: Some(Rect {
x: 10,
y: 3,
width: 30,
height: 17,
height: 20,
}),
modal: None,
};
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
assert_eq!(map.region_at(15, 1), Region::Tab);
assert_eq!(map.region_at(15, 5), Region::Pane);
assert_eq!(map.region_at(50, 50), Region::Unknown);
}
@@ -262,17 +291,12 @@ mod tests {
width: 10,
height: 20,
}),
tab_bar: Some(Rect {
sidebar_items: Vec::new(),
pane: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 3,
}),
pane: Some(Rect {
x: 10,
y: 3,
width: 30,
height: 17,
height: 20,
}),
modal: Some(Rect {
x: 5,
@@ -286,6 +310,40 @@ mod tests {
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
}
#[test]
fn sidebar_mouse_rows_follow_group_and_two_line_card_heights() {
use std::path::PathBuf;
use uuid::Uuid;
let group_a = SidebarItem::Group(PathBuf::from("/a"));
let card_a = SidebarItem::Session(Uuid::from_u128(1));
let group_b = SidebarItem::Group(PathBuf::from("/b"));
let card_b = SidebarItem::Session(Uuid::from_u128(2));
let map = RegionMap {
sidebar: Some(Rect::new(0, 0, 28, 10)),
sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b],
..RegionMap::default()
};
assert_eq!(map.sidebar_item_at(1), Some(group_a));
assert_eq!(map.sidebar_item_at(2), Some(card_a.clone()));
assert_eq!(map.sidebar_item_at(3), Some(card_a));
assert_eq!(map.sidebar_item_at(4), Some(group_b));
let collapsed = RegionMap {
sidebar: Some(Rect::new(0, 0, 28, 10)),
sidebar_items: vec![
SidebarItem::Group(PathBuf::from("/a")),
SidebarItem::Group(PathBuf::from("/b")),
SidebarItem::Session(Uuid::from_u128(2)),
],
..RegionMap::default()
};
assert_eq!(
collapsed.sidebar_item_at(2),
Some(SidebarItem::Group(PathBuf::from("/b")))
);
}
/// Issue #63: an empty pane is a draft — the frame must show the spawn
/// form where the terminal would otherwise be.
#[test]
@@ -305,19 +363,17 @@ mod tests {
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
home_dir: "",
pane_title: "new session",
pane: None,
draft: Some(draft),
state: &state,
help: &help,
worktrees: None,
rename_input: &crate::text_input::TextInput::default(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
@@ -336,7 +392,7 @@ mod tests {
out.push('\n');
}
for s in [
"draft",
"new session",
"cmd:",
"dir:",
"name:",
@@ -346,4 +402,91 @@ mod tests {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
#[test]
fn panel_rectangles_are_stable_when_focus_moves() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "session",
pane: None,
draft: None,
state: &state,
help: &help,
worktrees: None,
rename_input: &crate::text_input::TextInput::default(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut map = None;
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
map.unwrap()
};
let sidebar_focus = draw_map(Focus::Sidebar);
let pane_focus = draw_map(Focus::Pane);
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
assert_eq!(sidebar_focus.pane, pane_focus.pane);
assert_eq!(sidebar_focus.pane, Some(Rect::new(28, 1, 52, 22)));
}
#[test]
fn panel_focus_uses_backgrounds_without_borders() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "session",
pane: None,
draft: None,
state: &state,
help: &help,
worktrees: None,
rename_input: &crate::text_input::TextInput::default(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
terminal.backend().buffer().clone()
};
let sidebar_focused = render(Focus::Sidebar);
let pane_focused = render(Focus::Pane);
let p = palette::active();
assert_eq!(sidebar_focused[(0, 0)].style().bg, Some(p.sidebar_focus_bg));
assert_eq!(pane_focused[(0, 0)].style().bg, Some(p.chrome_bg));
assert_eq!(sidebar_focused[(28, 0)].style().bg, Some(p.card.bg));
assert_eq!(pane_focused[(28, 0)].style().bg, Some(p.mode_normal_bg));
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
assert!(title.contains("session"), "missing title: {title:?}");
}
}
+5 -1
View File
@@ -23,6 +23,10 @@ use maestro_protocol::ClientControl;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
/// Match the browser terminal's retained history without letting long-running
/// sessions grow the client without bound.
pub const SCROLLBACK_ROWS: usize = 5_000;
/// Input the dispatcher (or the resize tracker) pushes to a session task.
#[derive(Debug, Clone)]
pub enum SessionInput {
@@ -103,7 +107,7 @@ impl SessionTask {
/// up with `SessionInput::Resize` on every frame whose allocation changes.
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
let parser_bg = parser.clone();
File diff suppressed because it is too large Load Diff
+77 -182
View File
@@ -1,13 +1,15 @@
//! `AppState` — the top-level mutable state of the running TUI.
//!
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
//! session or a draft form), an active tab index, focus split between sidebar
//! and pane, the current input mode, and an optional overlay (kill confirm,
//! v1 keeps it small on purpose: one pane containing a session or draft form,
//! focus split between sidebar and pane, the current input mode, and an optional overlay (kill confirm,
//! help, transient toast). The spawn form is no longer an overlay — an empty
//! pane is a draft pane (issue #63).
//!
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
use std::collections::HashSet;
use std::path::PathBuf;
use uuid::Uuid;
use crate::draft::DraftPane;
@@ -19,8 +21,18 @@ pub enum Focus {
Pane,
}
/// One selectable item in the rendered sidebar sequence.
///
/// Group paths, unlike a session's position in the catalog, survive catalog
/// redraws and make a highlighted collapsed group unambiguous.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SidebarItem {
Group(PathBuf),
Session(Uuid),
}
/// What a tab's pane slot holds: a live session, or an inline draft form.
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
// The draft variant dwarfs `Session(Uuid)`; the pane is moved rarely enough
// that boxing every access costs more than the copy does.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
@@ -48,6 +60,9 @@ impl Tab {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
KillConfirm(Uuid),
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
/// like the worktrees view's entries, so this stays comparable.
Rename(Uuid),
Help,
Toast(String),
/// The retained-worktrees view (#85); its entries live on `App` because
@@ -70,7 +85,10 @@ pub struct AppState {
pub focus: Focus,
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<Uuid>,
pub sidebar_highlight: Option<SidebarItem>,
/// Group paths whose cards are hidden. This is UI state rather than
/// catalog state, so it survives every catalog redraw.
pub collapsed_sidebar_groups: HashSet<PathBuf>,
/// Private: every transition goes through [`AppState::show_overlay`],
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
/// from four call sites is what left a toast on screen with no way to
@@ -96,6 +114,7 @@ impl AppState {
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
collapsed_sidebar_groups: HashSet::new(),
overlay: None,
}
}
@@ -126,57 +145,6 @@ impl AppState {
&mut self.tabs[self.active_tab_idx]
}
/// Append a new tab and switch to it. Sidebar regains focus so the user
/// can pick something to attach.
pub fn new_tab(&mut self) {
let name = format!("{}", self.tabs.len() + 1);
self.tabs.push(Tab { name, pane: None });
self.active_tab_idx = self.tabs.len() - 1;
self.focus = Focus::Sidebar;
}
/// Drop the active tab. No-op if it's the only one — there must always
/// be at least one tab.
///
/// Returns the session that tab was showing, if any, so the caller can
/// release its emulator. Without that the tab's WebSocket task and vt100
/// parser outlive the tab forever. A draft pane holds no session and
/// releases nothing.
pub fn close_tab(&mut self) -> Option<Uuid> {
if self.tabs.len() <= 1 {
return None;
}
let closed = self.tabs.remove(self.active_tab_idx);
if self.active_tab_idx >= self.tabs.len() {
self.active_tab_idx = self.tabs.len() - 1;
}
let sid = closed.session()?;
// Only release it if no other tab still shows the same session.
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
(!still_shown).then_some(sid)
}
pub fn next_tab(&mut self) {
if self.tabs.is_empty() {
return;
}
self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
}
pub fn prev_tab(&mut self) {
if self.tabs.is_empty() {
return;
}
self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
}
pub fn goto_tab(&mut self, n: u8) {
let idx = (n as usize)
.saturating_sub(1)
.min(self.tabs.len().saturating_sub(1));
self.active_tab_idx = idx;
}
/// Bind a session to the active tab, move focus into the pane, and
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
@@ -186,14 +154,10 @@ impl AppState {
self.mode = Mode::Insert;
}
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
/// starts immediately, same flow as [`Self::attach_to_focused`].
/// Replace the pane with a draft form. Focus lands in the pane, Insert
/// mode on, so typing starts immediately, same flow as
/// [`Self::attach_to_focused`].
pub fn open_draft_pane(&mut self) {
if self.active_tab().pane.is_some() {
self.new_tab();
}
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
@@ -213,19 +177,32 @@ impl AppState {
}
/// Move the sidebar highlight by `delta` (positive = down) within the
/// given ordered list of selectable session ids. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
/// rendered item sequence. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
if order.is_empty() {
return;
}
let len = order.len() as i32;
let cur = self
.sidebar_highlight
.and_then(|id| order.iter().position(|x| *x == id))
.as_ref()
.and_then(|id| order.iter().position(|x| x == id))
.map(|i| i as i32)
.unwrap_or(-1);
let next = (cur + delta).rem_euclid(len) as usize;
self.sidebar_highlight = Some(order[next]);
self.sidebar_highlight = Some(order[next].clone());
}
/// Toggle a group. When collapsing a group containing the current card,
/// leave the highlight on the group row that remains visible.
pub fn toggle_sidebar_group(&mut self, key: PathBuf, contains_highlight: bool) {
if self.collapsed_sidebar_groups.remove(&key) {
return;
}
self.collapsed_sidebar_groups.insert(key.clone());
if contains_highlight {
self.sidebar_highlight = Some(SidebarItem::Group(key));
}
}
/// Toggle sidebar visibility.
@@ -252,88 +229,36 @@ mod tests {
assert!(state.overlay.is_none());
}
#[test]
fn new_tab_extends_and_focuses() {
let mut state = AppState::new();
state.new_tab();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
assert_eq!(state.focus, Focus::Sidebar);
}
#[test]
fn close_tab_keeps_at_least_one() {
let mut state = AppState::new();
state.close_tab();
assert_eq!(state.tabs.len(), 1);
state.new_tab();
state.close_tab();
assert_eq!(state.tabs.len(), 1);
}
#[test]
fn close_tab_advances_when_last_is_closed() {
let mut state = AppState::new();
state.new_tab();
state.new_tab();
assert_eq!(state.active_tab_idx, 2);
state.close_tab();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
}
#[test]
fn next_prev_tab_wraps() {
let mut state = AppState::new();
state.new_tab();
state.new_tab();
state.active_tab_idx = 0;
state.next_tab();
assert_eq!(state.active_tab_idx, 1);
state.next_tab();
assert_eq!(state.active_tab_idx, 2);
state.next_tab();
assert_eq!(state.active_tab_idx, 0);
state.prev_tab();
assert_eq!(state.active_tab_idx, 2);
}
#[test]
fn goto_tab_uses_one_based_clamp() {
let mut state = AppState::new();
state.new_tab();
state.new_tab();
state.goto_tab(1);
assert_eq!(state.active_tab_idx, 0);
state.goto_tab(2);
assert_eq!(state.active_tab_idx, 1);
state.goto_tab(99);
// Clamped to last available tab.
assert_eq!(state.active_tab_idx, state.tabs.len() - 1);
}
#[test]
fn move_sidebar_highlight_walks_order_and_wraps() {
let mut state = AppState::new();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let c = Uuid::from_u128(3);
let order = vec![a, b, c];
let order = vec![
SidebarItem::Session(a),
SidebarItem::Group("repo".into()),
SidebarItem::Session(b),
SidebarItem::Session(c),
];
// No prior highlight + delta +1 lands on first item.
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(a));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(b));
assert_eq!(
state.sidebar_highlight,
Some(SidebarItem::Group("repo".into()))
);
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(c));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
// Wraps forward.
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(a));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
// Wraps backward.
state.move_sidebar_highlight(&order, -1);
assert_eq!(state.sidebar_highlight, Some(c));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
}
#[test]
@@ -343,6 +268,22 @@ mod tests {
assert_eq!(state.sidebar_highlight, None);
}
#[test]
fn collapsing_highlighted_group_moves_a_card_highlight_to_the_group() {
let key = PathBuf::from("/repo");
let mut state = AppState::new();
state.sidebar_highlight = Some(SidebarItem::Session(Uuid::from_u128(9)));
state.toggle_sidebar_group(key.clone(), true);
assert_eq!(
state.sidebar_highlight,
Some(SidebarItem::Group(key.clone()))
);
assert!(state.collapsed_sidebar_groups.contains(&key));
state.toggle_sidebar_group(key.clone(), false);
assert!(!state.collapsed_sidebar_groups.contains(&key));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Group(key)));
}
#[test]
fn attach_binds_focuses_pane_and_enters_insert_mode() {
let mut state = AppState::new();
@@ -367,38 +308,6 @@ mod tests {
assert_eq!(state.focus, Focus::Pane);
}
/// The emulator leak: a closed tab's session must come back so the caller
/// can abort its WebSocket task.
#[test]
fn close_tab_reports_the_session_it_dropped() {
let mut state = AppState::new();
let sid = Uuid::from_u128(3);
state.new_tab();
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), Some(sid));
}
/// ...but not while another tab still shows it.
#[test]
fn close_tab_keeps_a_session_another_tab_still_shows() {
let mut state = AppState::new();
let sid = Uuid::from_u128(4);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
state.new_tab();
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), None);
}
#[test]
fn close_tab_is_a_no_op_on_the_last_tab() {
let mut state = AppState::new();
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
assert_eq!(state.close_tab(), None);
assert_eq!(state.tabs.len(), 1);
}
#[test]
fn dismiss_overlay_reports_whether_one_was_showing() {
let mut state = AppState::new();
@@ -420,18 +329,14 @@ mod tests {
assert_eq!(state.tabs.len(), 1);
}
/// A tab already showing a session gets a fresh tab for the draft; v1
/// has no split tree, so a new tab is the "split".
#[test]
fn open_draft_over_a_session_opens_a_new_tab() {
fn open_draft_over_a_session_reuses_the_pane() {
let mut state = AppState::new();
state.attach_to_focused(Uuid::from_u128(9));
state.open_draft_pane();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab_idx, 0);
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
// The session tab is untouched.
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
}
/// Web parity (#57): submitting fills the pane in place.
@@ -445,14 +350,4 @@ mod tests {
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
}
/// A closed draft tab holds no session, so nothing is released.
#[test]
fn close_tab_reports_nothing_for_a_draft_pane() {
let mut state = AppState::new();
state.open_draft_pane();
state.new_tab();
state.active_tab_idx = 0;
assert_eq!(state.close_tab(), None);
}
}
-61
View File
@@ -1,61 +0,0 @@
//! Tab-bar widget — top row of the main area.
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::Tabs;
use ratatui::Frame;
#[derive(Debug, Clone)]
pub struct TabBar {
pub tab_names: Vec<String>,
pub active_idx: usize,
}
impl TabBar {
pub fn render(&self, f: &mut Frame, area: Rect) {
let titles: Vec<Line> = self
.tab_names
.iter()
.map(|n| Line::from(n.as_str()))
.collect();
let tabs = Tabs::new(titles)
.select(self.active_idx)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_widget(tabs, area);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string(bar: &TabBar, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| bar.render(f, f.area())).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn renders_three_tab_names() {
let bar = TabBar {
tab_names: vec!["1".into(), "2".into(), "3".into()],
active_idx: 1,
};
let rendered = render_to_string(&bar, 40, 1);
assert!(rendered.contains("1"));
assert!(rendered.contains("2"));
assert!(rendered.contains("3"));
}
}
+49 -8
View File
@@ -19,8 +19,7 @@ use tracing_subscriber::EnvFilter;
#[command(
name = "maestro",
version,
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator",
arg_required_else_help = true
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator"
)]
struct Cli {
/// Override the daemon HTTP base clients talk to (e.g.
@@ -37,8 +36,11 @@ struct Cli {
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
/// A missing subcommand defaults to the TUI (#119): a bare `maestro` opens
/// the terminal UI. Builds without the `tui` feature have no UI surface,
/// so they print help and exit 0 instead.
#[command(subcommand)]
command: Command,
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
@@ -55,6 +57,9 @@ enum Command {
/// Kill a session by ID.
#[cfg(feature = "cli")]
Kill(maestro_cli::cli::KillArgs),
/// Install or update the agent activity hook (#106).
#[cfg(feature = "cli")]
Hooks(maestro_cli::cli::HooksArgs),
/// Per-host daemon: tmux wrapper + session API.
#[cfg(feature = "daemon")]
Daemon {
@@ -86,7 +91,22 @@ fn main() -> ExitCode {
}
async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
match cli.command {
let command = match cli.command {
Some(command) => command,
None => {
#[cfg(feature = "tui")]
{
Command::Tui(maestro_tui::cli::TuiArgs { daemon_url: None })
}
#[cfg(not(feature = "tui"))]
{
use clap::CommandFactory;
let _ = <Cli as CommandFactory>::command().print_help();
return Ok(());
}
}
};
match command {
#[cfg(feature = "cli")]
Command::List(args) => {
maestro_cli::dispatch(
@@ -123,6 +143,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
)
.await?
}
#[cfg(feature = "cli")]
Command::Hooks(args) => {
maestro_cli::dispatch(
maestro_cli::cli::Command::Hooks(args),
cli.config,
cli.daemon_url,
)
.await?
}
#[cfg(feature = "daemon")]
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
#[cfg(feature = "tui")]
@@ -154,15 +183,19 @@ fn init_tracing(cli: &Cli) {
}
#[cfg(feature = "cli")]
fn is_cli_subcommand(command: &Command) -> bool {
fn is_cli_subcommand(command: &Option<Command>) -> bool {
matches!(
command,
Command::List(_) | Command::Spawn(_) | Command::Attach(_) | Command::Kill(_)
Some(Command::List(_))
| Some(Command::Spawn(_))
| Some(Command::Attach(_))
| Some(Command::Kill(_))
| Some(Command::Hooks(_))
)
}
#[cfg(not(feature = "cli"))]
fn is_cli_subcommand(_command: &Command) -> bool {
fn is_cli_subcommand(_command: &Option<Command>) -> bool {
false
}
@@ -200,12 +233,20 @@ mod tests {
}
/// Parse argv the way `main` does, returning just the dispatched subcommand.
fn command_of(argv: &[&str]) -> Command {
fn command_of(argv: &[&str]) -> Option<Command> {
Cli::try_parse_from(argv)
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
.command
}
#[test]
fn bare_invocation_parses_to_no_subcommand() {
// The TUI-vs-help default is applied in `dispatch`; parsing must not
// reject an empty argv.
assert!(command_of(&["maestro"]).is_none());
assert!(command_of(&["maestro", "--verbose"]).is_none());
}
#[cfg(feature = "cli")]
#[test]
fn cli_subcommands_are_classified_as_cli() {
+27 -3
View File
@@ -54,11 +54,35 @@ fn version_flag_succeeds() {
assert!(text.contains("maestro"), "--version output: {text}");
}
/// A bare `maestro` must never fall back to help on a build that has a TUI:
/// the missing subcommand defaults to `tui` (#119). Point at a port with
/// nothing listening so the TUI's health probe fails fast instead of blocking
/// on a terminal.
#[cfg(feature = "tui")]
#[test]
fn no_args_prints_help_and_fails() {
// `arg_required_else_help = true` => exit non-zero but show usage.
fn bare_invocation_dispatches_to_the_tui() {
let (ok, text) = run(&["--daemon-url", "http://127.0.0.1:9"]);
assert!(
!ok,
"TUI against a dead daemon should exit non-zero:\n{text}"
);
assert!(
text.contains("daemon unreachable"),
"bare invocation should have run the TUI's daemon probe:\n{text}"
);
assert!(
!text.contains("Usage"),
"bare invocation must not print help when a TUI exists:\n{text}"
);
}
/// Builds without the `tui` feature keep the historical behaviour: no args =>
/// help, exit 0 (#119).
#[cfg(not(feature = "tui"))]
#[test]
fn bare_invocation_prints_help_and_exits_zero() {
let (ok, text) = run(&[]);
assert!(!ok, "bare invocation should fail:\n{text}");
assert!(ok, "bare invocation should exit 0:\n{text}");
assert!(text.contains("Usage"), "expected usage text:\n{text}");
}
+30 -4
View File
@@ -119,6 +119,9 @@ The wizard always installs the default-feature build.
`maestro tui` is the keyboard-and-mouse driven terminal UI client. It ships
in the default feature set, so a vanilla `cargo install maestro` builds it.
A bare `maestro` opens the TUI too — the missing subcommand defaults to `tui`
(#119), so `maestro --daemon-url http://host:7070` works as well. Builds
without the `tui` feature have no UI to open and print help instead (exit 0).
Drop the `tui` feature to skip it on hosts that don't need the UI surface
(`--no-default-features --features cli,daemon`).
@@ -131,24 +134,47 @@ cover every v1 action — only override what you want to change:
```toml
[tui]
theme = "dark" # see the theme list below
leader = "ctrl-a" # one byte that escapes Insert mode
chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
[tui.keymap.normal]
"i" = "EnterInsert"
"s" = "OpenSpawnModal"
"s" = "NewDraftPane"
"x" = "KillHighlighted"
"r" = "RenameHighlighted"
"?" = "ShowHelp"
"ctrl-t" = "NewTab"
"g t" = "NextTab"
"ctrl-w h" = "FocusSidebar"
"enter" = "AttachHighlighted"
"pageup" = "ScrollPanePageUp"
"pagedown" = "ScrollPanePageDown"
[tui.keymap.insert]
# (leader is added automatically as LeaveInsert; only override here when you
# want extra Insert-mode bindings)
```
`theme` selects the complete TUI palette. `dark` is the default and follows
Maestro's terminal-native dark product palette; `light` is its light peer;
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
The remaining presets adapt well-known editor themes, with individual colours
lifted or darkened where the originals fall short of the WCAG AA contrast
floors the palette tests assert:
| name | based on |
|-------------------|------------------|
| `solarized-dark` | Solarized Dark |
| `solarized-light` | Solarized Light |
| `catppuccin` | Catppuccin Mocha |
| `github-dark` | GitHub Dark |
| `github-light` | GitHub Light |
| `tokyo-night` | Tokyo Night |
| `gruvbox` | Gruvbox Dark |
| `nord` | Nord |
| `dracula` | Dracula |
An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`.
+148
View File
@@ -0,0 +1,148 @@
# 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.
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>] [<blocked work> | 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.
`<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.
## 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 blocked-work, exited and
command fragments use the surface's `muted` role, since the ⚠ glyph already
signals severity.
## 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.
+1 -1
View File
@@ -125,7 +125,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
};
buildPhase = ''
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
} from "./activity.js";
describe("activityHeldMs", () => {
it("measures time spent in the current state", () => {
const now = Date.now();
const since = new Date(now - 90_000).toISOString();
expect(activityHeldMs(since, now)).toBe(90_000);
});
it("returns null for payloads that predate #102 (epoch default)", () => {
expect(activityHeldMs(undefined, Date.now())).toBeNull();
expect(activityHeldMs(new Date(0).toISOString(), Date.now())).toBeNull();
});
it("returns null for unparseable timestamps", () => {
expect(activityHeldMs("not-a-time", Date.now())).toBeNull();
});
it("never goes negative", () => {
const now = Date.now();
expect(activityHeldMs(new Date(now + 5_000).toISOString(), now)).toBe(0);
});
});
describe("activityDurationLabel (#102)", () => {
it("floors whole seconds under a minute", () => {
expect(activityDurationLabel(30_400)).toBe("30s");
expect(activityDurationLabel(59_900)).toBe("59s");
});
it("floors whole minutes under an hour", () => {
expect(activityDurationLabel(60_000)).toBe("1m");
expect(activityDurationLabel(4 * 60_000 + 59_999)).toBe("4m");
});
it("floors whole hours from an hour up, with no days unit", () => {
expect(activityDurationLabel(60 * 60_000)).toBe("1h");
expect(activityDurationLabel(2 * 3_600_000 + 59 * 60_000 + 59_999)).toBe("2h");
expect(activityDurationLabel(26 * 3_600_000)).toBe("26h");
});
});
describe("activityColorSlug", () => {
it("maps each known state to its contract slug", () => {
expect(activityColorSlug("Active")).toBe("active");
expect(activityColorSlug("Idle")).toBe("idle");
expect(activityColorSlug("AwaitingInput")).toBe("awaiting-input");
expect(activityColorSlug("Error")).toBe("error");
});
it("treats an unknown variant as idle so rendering never fails", () => {
expect(activityColorSlug("Teleported")).toBe("idle");
});
});
+44
View File
@@ -0,0 +1,44 @@
// docs/SIDEBAR.md: the session card's meta line — activity colour mapping and
// the shared duration format. Both clients compute it identically; the TUI
// mirrors this in crates/maestro-tui.
import type { ActivityState, IsoTimestamp } from "./types.js";
/** A state held shorter than this has no duration fragment. */
export const META_LINE_MIN_HOLD_MS = 30_000;
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
// implausibly old is treated as "never observed" rather than a huge duration.
const PLAUSIBLE_EPOCH_MS = Date.UTC(2010, 0, 1);
export function activityHeldMs(since: IsoTimestamp | undefined, now: number): number | null {
if (!since) return null;
const t = Date.parse(since);
if (Number.isNaN(t) || t < PLAUSIBLE_EPOCH_MS) return null;
return Math.max(0, now - t);
}
/** `<60s` → whole seconds, `<60m` → whole minutes, else whole hours. Floored
* everywhere so the counter never shows a duration the state hasn't reached. */
export function activityDurationLabel(heldMs: number): string {
const s = Math.floor(heldMs / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
return `${Math.floor(m / 60)}h`;
}
/** CSS hook for the state's colour (`styles.css` maps slugs to tokens). An
* unknown variant degrades to idle so rendering never fails (Precedence). */
export function activityColorSlug(state: ActivityState | string): string {
switch (state) {
case "Active":
return "active";
case "AwaitingInput":
return "awaiting-input";
case "Error":
return "error";
default:
return "idle";
}
}
+14
View File
@@ -44,6 +44,20 @@ export async function createSession(req: CreateSessionRequest): Promise<Session>
);
}
/** Rename a session (#125). An empty name clears the custom one, so the card
* falls back to the command. The daemon trims and caps what it stores and
* broadcasts the update, so callers need not patch the store themselves. */
export async function renameSession(id: Uuid, name: string): Promise<Session> {
return jsonOrError(
await fetch(routes.session(id), {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name }),
}),
);
}
export async function killSession(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(routes.session(id), {
+2 -1
View File
@@ -123,7 +123,8 @@ export function createAttachConnection(
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
// scrolling *its* content (the only scroll that exists on the alt screen).
// • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
// gesture stream; only the terminal surface opts out of native page panning.
{
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
+271
View File
@@ -0,0 +1,271 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
// happy-dom runs without a backing store; renderSidebar reads the persisted
// sidebar width on mount.
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
// No daemon here; renderSidebar treats a failed info fetch as "absolute
// paths are a fine fallback".
window.fetch = () => Promise.reject(new Error("offline"));
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
// Like the real store, deliver an immediate snapshot so the sidebar
// renders its rows during construction.
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
}
describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session with the shared phrasing", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000001",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toContain("⚠");
expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
});
it("does not mark clear or unprobed sessions", () => {
const clear = session({
id: "aaaaaaaa-0000-0000-0000-000000000002",
blocked_work: {} as InTheWay,
});
const unprobed = session({
id: "aaaaaaaa-0000-0000-0000-000000000003",
});
const el = renderSidebar(storeWith([clear, unprobed])).el;
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
});
it("restates the warning at the kill confirm", async () => {
const confirmations: string[] = [];
window.confirm = (msg?: string) => {
confirmations.push(msg ?? "");
return false;
};
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000004",
command: "bash",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill");
kill?.click();
await Promise.resolve();
expect(confirmations).toHaveLength(1);
expect(confirmations[0]).toContain("1 untracked file");
expect(confirmations[0]).toContain("refused unless forced");
});
it("confirms plainly when nothing blocks", async () => {
const confirmations: string[] = [];
window.confirm = (msg?: string) => {
confirmations.push(msg ?? "");
return false;
};
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await Promise.resolve();
expect(confirmations).toEqual(['Kill session "claude"?']);
});
});
describe("agent-reported detail (#112)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("renders the reported detail right after the duration on line 2", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000020",
status: "Dead",
activity: "Error",
activity_since: ago(90_000),
activity_detail: "cargo build failed",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
const order = [...line2!.children].map((c) => c.className);
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
expect(order.indexOf("activity-detail")).toBeLessThan(
order.findIndex((c) => c.startsWith("card-flag")),
);
});
it("omits the detail when no report is in force", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000021",
activity: "Active",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
});
});
describe("session cards (#107)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
const el = renderSidebar(storeWith([s])).el;
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
expect(line1).not.toBeNull();
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
});
it("always renders line 2 for a freshly spawned session", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000011",
activity: "Active",
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
});
it("shows dot + floored duration once the state has been held past 30s", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000012",
activity: "Active",
activity_since: ago(4 * 60_000 + 30_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("renders the command when the session name equals it", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001b",
command: "claude",
metadata: { name: "claude" },
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
});
it("maps each activity state to its contract slug", () => {
const mk = (id: string, activity: string) =>
session({
id,
activity: activity as Session["activity"],
status: "Running",
activity_since: ago(90_000),
});
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
const el = renderSidebar(storeWith([idle, wait, error])).el;
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"idle",
);
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"error",
);
});
it("treats an unknown activity variant as idle instead of failing to render", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000016",
activity: "Teleported" as Session["activity"],
activity_since: ago(120_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
expect(line2?.getAttribute("data-activity")).toBe("idle");
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
});
it("moves blocked work onto line 2 in the shared phrasing", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000017",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("⚠");
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
});
it("shows exited on line 2 for dead sessions", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000018",
status: "Dead",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("exited");
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019",
command: "claude",
metadata: { name: "worker" },
activity: "AwaitingInput",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
const dot = row.querySelector<HTMLElement>(".activity-dot");
expect(dot?.getAttribute("title")).toContain("Awaiting input");
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
it("keeps the attach navigation and depth indentation", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLAnchorElement;
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
});
});
+168 -20
View File
@@ -1,9 +1,24 @@
import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession } from "./api.js";
import { getDaemonInfo, killSession, renameSession } from "./api.js";
import { openContextMenu } from "./contextMenu.js";
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { abbreviateFolderName, sessionName } from "./types.js";
import {
abbreviateFolderName,
customSessionName,
formatInTheWay,
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
sessionName,
} from "./types.js";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
@@ -17,6 +32,44 @@ export function requestOpenSpawn(): void {
const collapsed = new Set<string>();
/** Session whose card is currently being renamed inline (#125). The tree
* re-renders on every catalog delta, and an activity update arriving mid-edit
* would otherwise replace the field the user is typing into. */
let renamingId: string | null = null;
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
"awaiting-input": "Awaiting input",
error: "Error",
};
function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
const held = activityHeldMs(s.activity_since, now);
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
blockers
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
dead ? `<span class="card-flag">exited</span>` : "",
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -52,26 +105,31 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
}
const s = node.session;
const now = Date.now();
const row = document.createElement("a");
row.className = "tree-leaf";
row.className = "tree-leaf session-card";
row.href = `#/attach/${encodeURIComponent(s.id)}`;
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
const stateWord =
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
<span class="leaf-name">${escapeHtml(name)}</span>
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
${running ? "" : `<span class="leaf-badge">exited</span>`}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${metaLine(s, now)}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
@@ -80,19 +138,96 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", async (ev) => {
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (!confirm(`Kill session "${sessionName(s)}"?`)) return;
try {
await killSession(s.id);
} catch (e) {
alert(`Failed to kill: ${(e as Error).message}`);
}
void confirmAndKill(s, name);
});
row.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, s) },
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
},
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
]);
});
return row;
}
/** #83: the confirm restates the warning at the moment of decision. */
async function confirmAndKill(s: Session, name: string): Promise<void> {
const blockers = sessionBlockers(s);
const question = blockers
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
: `Kill session "${name}"?`;
if (!confirm(question)) return;
try {
await killSession(s.id);
} catch (e) {
alert(`Failed to kill: ${(e as Error).message}`);
}
}
/**
* Swap the card's name for a text field (#125). Enter commits, Escape and
* blur cancel. An emptied field clears the name, so the card falls back to
* the command — the same rule the spawn form's blank name field follows.
*/
function beginRename(row: HTMLElement, s: Session): void {
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
if (!nameEl) return;
const input = document.createElement("input");
input.type = "text";
input.className = "leaf-name leaf-rename";
input.value = customSessionName(s) ?? "";
input.maxLength = MAX_SESSION_NAME_LEN;
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
// The card is an <a>, and the tree starts a drag on pointerdown: without
// these, clicking into the field navigates away or begins dragging the row.
for (const type of ["click", "pointerdown", "dblclick"]) {
input.addEventListener(type, (ev) => {
ev.preventDefault();
ev.stopPropagation();
input.focus();
});
}
renamingId = s.id;
let settled = false;
const finish = () => {
if (settled) return false;
settled = true;
renamingId = null;
document.dispatchEvent(new CustomEvent("catalog-rerender"));
return true;
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
// Normalised here so what is sent is what the daemon will store.
const name = normalizeSessionName(input.value);
if (!finish()) return;
void renameSession(s.id, name).catch((e: Error) => {
alert(`Failed to rename: ${e.message}`);
});
} else if (ev.key === "Escape") {
ev.preventDefault();
finish();
}
});
input.addEventListener("blur", () => finish());
nameEl.replaceWith(input);
input.focus();
input.select();
}
function renderStatusbar(sessions: Session[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "statusbar";
@@ -353,6 +488,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
};
const refresh = () => {
// Rebuilding the tree now would blow away the field being typed into.
if (renamingId !== null) return;
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, homeDir, filter);
@@ -386,6 +523,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const unsubscribe = store.subscribe(refresh);
// Contract granularity: re-render at least every 30s while visible so the
// duration cutover points land within one tick.
const clock = setInterval(() => {
if (document.visibilityState !== "visible") return;
refresh();
}, 30_000);
return {
el,
setActive(id: string | null) {
@@ -401,6 +545,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyHighlights();
},
dispose() {
clearInterval(clock);
// An unfinished rename would otherwise leave every later sidebar
// refusing to refresh.
renamingId = null;
document.removeEventListener("catalog-rerender", rerenderHandler);
unsubscribe();
},
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { closeContextMenu, openContextMenu } from "./contextMenu.js";
afterEach(() => {
closeContextMenu();
document.body.replaceChildren();
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
}
describe("context menu (#125)", () => {
it("renders one item per entry, in order", () => {
openContextMenu(10, 20, [
{ label: "Rename…", onSelect: () => {} },
{ label: "Kill session", onSelect: () => {}, danger: true },
]);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe(
"Kill session",
);
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]);
(document.querySelector(".context-menu-item") as HTMLButtonElement).click();
expect(chosen).toHaveBeenCalledOnce();
expect(document.querySelector(".context-menu")).toBeNull();
});
it("opening a second menu replaces the first", () => {
openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]);
openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]);
expect(document.querySelectorAll(".context-menu")).toHaveLength(1);
expect(labels()).toEqual(["second"]);
});
it("closes on Escape and on a press outside it", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(document.querySelector(".context-menu")).toBeNull();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).toBeNull();
});
it("stays open for a press inside itself", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
const menu = document.querySelector(".context-menu") as HTMLElement;
menu.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).not.toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
/** Custom right-click menu (#125).
*
* One menu exists at a time: opening a second closes the first. The menu is
* appended to `<body>` rather than to the row it belongs to, so an overflowing
* sidebar cannot clip it.
*/
export interface ContextMenuItem {
label: string;
onSelect: () => void;
/** Renders the entry in the destructive style. */
danger?: boolean;
}
let open: { el: HTMLElement; dispose: () => void } | null = null;
/** Close the menu that is currently up, if any. */
export function closeContextMenu(): void {
open?.dispose();
}
/**
* Open a menu at viewport coordinates `x`/`y`. The menu closes on selection,
* on Escape, on a pointer press anywhere outside it, and on scroll or resize —
* a menu anchored to coordinates goes stale the moment the page moves under it.
*/
export function openContextMenu(x: number, y: number, items: ContextMenuItem[]): HTMLElement {
closeContextMenu();
const el = document.createElement("div");
el.className = "context-menu";
el.setAttribute("role", "menu");
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = item.danger ? "context-menu-item is-danger" : "context-menu-item";
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
closeContextMenu();
item.onSelect();
});
el.appendChild(btn);
}
document.body.appendChild(el);
// Nudge back inside the viewport when the click was near an edge. happy-dom
// reports 0 for every measurement, which lands the menu at the click point —
// the same place it would sit with room to spare.
const rect = el.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
el.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
el.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
const onPointerDown = (ev: Event) => {
if (!el.contains(ev.target as Node)) closeContextMenu();
};
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape") closeContextMenu();
};
const dispose = () => {
if (open?.el !== el) return;
open = null;
el.remove();
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("scroll", closeContextMenu, true);
window.removeEventListener("resize", closeContextMenu);
};
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("scroll", closeContextMenu, true);
window.addEventListener("resize", closeContextMenu);
open = { el, dispose };
(el.firstElementChild as HTMLElement | null)?.focus();
return el;
}
-1
View File
@@ -43,7 +43,6 @@ describe("resolveFastForward (#67)", () => {
expect(req.worktree).toEqual({
base_repo: "/repos/fresh",
branch: "amber-falcon",
cleanup_on_kill: true,
create_branch: true,
});
});
-1
View File
@@ -42,7 +42,6 @@ export function buildFastForwardRequest(
worktree: {
base_repo: project.base_repo ?? project.path,
branch: name,
cleanup_on_kill: true,
create_branch: true,
},
};
+66 -18
View File
@@ -59,20 +59,28 @@ function leafRow(opts: {
status: "running" | "dead";
active?: boolean;
indent?: number;
activity?: "active" | "idle" | "awaiting-input" | "error";
duration?: string;
}): HTMLElement {
const row = h("a", "tree-leaf");
const row = h("a", "tree-leaf session-card");
(row as HTMLAnchorElement).href = "#/kit";
row.dataset.status = opts.status;
if (opts.active) row.classList.add("is-active");
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
const flags = [
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
].join("");
row.innerHTML = `
<span class="leaf-name">${opts.name}</span>
${opts.cmd !== opts.name ? `<span class="leaf-meta"><span class="leaf-cmd">${opts.cmd}</span></span>` : ""}
${opts.status === "dead" ? `<span class="leaf-badge">exited</span>` : ""}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${opts.name}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${opts.name}">✕</button>
</span>`;
<span class="card-line1">
<span class="leaf-name">${opts.name}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
</span>
</span>
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
`;
return row;
}
@@ -190,14 +198,17 @@ export function renderKit(): HTMLElement {
// --- chips & badges ---
{
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
const activeLine = h(
"span",
"card-line2",
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
);
activeLine.setAttribute("data-activity", "active");
body.append(
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
specimen("folder-count", h("span", "folder-count", "4")),
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
specimen(
"attach-state · open",
h("span", "attach-state", "live"),
),
specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")),
);
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
"data-state",
@@ -215,19 +226,56 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Session rows — nested by host, then directory",
"The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
);
const list = h("div", "kit-tree");
list.append(
folderHeader("~/git/maestro", 3, 0),
folderHeader("web", 2, 1),
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }),
leafRow({
name: "dev-server",
cmd: "vite dev",
status: "running",
indent: 2,
active: true,
activity: "active",
duration: "4m",
}),
folderHeader("crates/daemon", 1, 1),
leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }),
leafRow({
name: "daemon-work",
cmd: "claude",
status: "running",
indent: 2,
activity: "awaiting-input",
duration: "12s",
}),
leafRow({
name: "flaky-agent",
cmd: "claude",
status: "running",
indent: 2,
activity: "error",
duration: "2h",
}),
folderHeader("~/work/acme", 2, 0),
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
leafRow({
name: "billing-api",
cmd: "claude",
status: "running",
indent: 1,
activity: "idle",
duration: "26m",
}),
leafRow({
name: "scratch",
cmd: "bash",
status: "dead",
indent: 1,
activity: "idle",
duration: "5h",
}),
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
);
body.classList.add("kit-tree-host");
+5 -1
View File
@@ -272,7 +272,11 @@ function showMultiView(): void {
// cloned session's dir + command.
function requestOpenSpawn(): void {
showMultiView();
mv.openDraft();
// A new tab, not a split of the pane you were reading (#100). "Spawn
// similar" and the fast-forward key below deliberately keep the old
// behaviour: both act on the session in front of you, so a split beside it
// is the point.
mv.openDraft(undefined, { newTab: true });
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
+173
View File
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
});
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
// Let the mounted form's loadProjects() settle and auto-sync its picked
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
await flush();
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
mv.openDraft();
await flush();
expect(leaves(tab.root)).toHaveLength(2);
expect(tab.focusedLeafId).toBe(empty);
});
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
await flush();
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
expect(leaf.draft?.cwd).toBe("/home/user/proj");
expect(leaf.draft?.command).toBe("claude");
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
expect(cwdRaw.value).toBe("/home/user/proj");
});
it("seeds a clone's cwd and command into the draft", async () => {
const layout = emptyLayout();
const mv = makeMv(layout);
@@ -160,3 +202,134 @@ describe("multiview openDraft (#62)", () => {
expect(leaf.draft?.command).toBe("claude");
});
});
describe("multiview pane-drag swap (#73)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
* drag handler does — modelled after the pointer sequence in runDrag()/
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
// The real drag-start gate requires the pointerdown target to sit inside
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
// no such element, so stand one in for the gate check itself.
const marker = document.createElement("div");
marker.className = "attach-header";
sourcePane.appendChild(marker);
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
left: 100,
top: 0,
width: 100,
height: 100,
right: 200,
bottom: 100,
x: 100,
y: 0,
toJSON() {},
} as DOMRect);
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
targetPane,
];
marker.dispatchEvent(
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
);
window.dispatchEvent(
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
);
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
await flush();
}
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const leaf1 = splitLeaf(tab, "", null, "row", true);
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
nameInputOf(leaf1).value = "west";
nameInputOf(leaf1).dispatchEvent(new Event("input"));
nameInputOf(leaf2).value = "east";
nameInputOf(leaf2).dispatchEvent(new Event("input"));
await flush();
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
// The leaf data swaps (already correct pre-fix)...
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
// ...and the mounted forms must follow, not stay behind showing the old
// pane's values (#73).
expect(nameInputOf(leaf1).value).toBe("east");
expect(nameInputOf(leaf2).value).toBe("west");
});
});
describe("openDraft newTab (#100)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("opens its own tab instead of splitting the focused session", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = first;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft(undefined, { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(2);
// The tab you were reading is untouched: still one pane, still its session.
const original = leaves(layout.tabs[0].root);
expect(original).toHaveLength(1);
expect(original[0].sessionId).toBe("s1");
// The draft is alone in the new, now-active tab.
const fresh = leaves(layout.tabs[1].root);
expect(fresh).toHaveLength(1);
expect(fresh[0].sessionId).toBeNull();
expect(fresh[0].draft).not.toBeNull();
expect(layout.activeTabId).toBe(layout.tabs[1].id);
});
it("still splits when newTab is not asked for", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft();
await flush();
expect(layout.tabs).toHaveLength(1);
expect(leaves(tab.root)).toHaveLength(2);
});
});
+30 -5
View File
@@ -57,7 +57,7 @@ export interface MultiView {
/** Open a draft pane (issue #62): reuse the focused pane if it is already
* empty, otherwise split the focused pane. `seed` pre-fills the form
* ("Spawn similar" clones a session's cwd + command). */
openDraft: (seed?: Partial<Draft>) => void;
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
/** Start dragging a sidebar session into the canvas (issue #32). The
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
* drag shows drop zones to place it. */
@@ -412,9 +412,18 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
opts.onOpenSessionsChange?.();
}
function openDraft(seed?: Partial<Draft>): void {
const tab = activeTab(layout);
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
if (opts?.newTab) {
// Spawn opens its own tab rather than halving whatever you were reading
// (#100). The empty-pane reuse below still applies within a tab.
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
render();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
// Empty tab (or stale focus): the draft becomes the first pane.
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
@@ -423,9 +432,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId && !target.draft) {
// An already-empty pane becomes the draft in place.
if (!target.sessionId) {
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
// picked defaults into `draft` once ready, so that's never reliably null.
target.draft = draft();
const stale = draftForms.get(target.id);
if (stale) {
stale.destroy();
draftForms.delete(target.id);
}
} else {
// Split a fresh draft beside the focused pane.
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
@@ -695,6 +710,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const dragged = findLeaf(tab.root, self)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
[dragged.draft, target.draft] = [target.draft, dragged.draft];
// The leaf data moved but each pane's mounted form is still keyed to
// its old leaf (#73) — tear both down like openDraft's reuse case
// (#72) so render() remounts fresh forms seeded from the swapped data.
for (const id of [dragged.id, target.id]) {
const stale = draftForms.get(id);
if (stale) {
stale.destroy();
draftForms.delete(id);
}
}
tab.focusedLeafId = target.id;
} else if (sessionId) {
const loc = locateSession(layout, sessionId);
+41 -25
View File
@@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
);
}
function rowPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
(r) => r.dataset.cwd ?? "",
function boxPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
(b) => b.dataset.cwd ?? "",
);
}
describe("spawnForm project picker", () => {
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
return handle;
}
it("renders one row per project, fresh before stale", async () => {
it("renders one box per project, fresh before stale", async () => {
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
const stale = project({
name: "stale",
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
last_activity: "2026-01-01T00:00:00Z",
});
const form = (await mount([stale, fresh])).el;
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
expect(rows[1].classList.contains("is-stale")).toBe(true);
expect(rows[0].classList.contains("is-stale")).toBe(false);
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
expect(boxes[1].classList.contains("is-stale")).toBe(true);
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
it("nests worktrees under their base repository", async () => {
it("shows the name as the primary line and the full path unabbreviated", async () => {
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
// projects sharing a basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
const main = project({ name: "maestro", path: "/home/user/maestro" });
const wt = project({
name: "wt",
path: "/elsewhere/wt",
path: "/home/user/maestro/worktrees/wt",
is_worktree: true,
base_repo: main.path,
});
const form = (await mount([wt, main])).el;
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
const [mainRow, wtRow] = [
...form.querySelectorAll(".project-row:not(.project-other)"),
];
expect(wtRow.classList.contains("is-worktree")).toBe(true);
expect(mainRow.classList.contains("is-worktree")).toBe(false);
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
});
it("filters rows as the operator types", async () => {
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
const a = project({ name: "maestro", path: "/home/user/maestro" });
const b = project({ name: "tea", path: "/home/user/work/tea" });
const form = (await mount([a, b])).el;
const wt = project({
name: "bulgarians",
path: "/home/user/maestro/worktrees/bulgarians",
is_worktree: true,
base_repo: a.path,
});
const form = (await mount([a, b, wt])).el;
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "tea";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
// A query that only matches a worktree still surfaces its repository box.
filter.value = "bulgarians";
filter.dispatchEvent(new Event("input"));
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
filter.value = "zzz";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual([]);
expect(boxPaths(form)).toEqual([]);
expect(form.querySelector(".picker-empty")).toBeTruthy();
});
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
onChange.mockClear();
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
row.click();
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
box.click();
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// The raw input stays hidden — the picked row is the source of truth.
// The raw input stays hidden — the picked box is the source of truth.
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
expect(row.classList.contains("is-active")).toBe(true);
expect(box.classList.contains("is-active")).toBe(true);
});
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
+178
View File
@@ -0,0 +1,178 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { renderSidebar } from "./catalog.js";
import { closeContextMenu } from "./contextMenu.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { normalizeSessionName } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
});
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
closeContextMenu();
mounted?.dispose();
mounted = null;
vi.unstubAllGlobals();
});
function session(overrides: Partial<Session> = {}): Session {
return {
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: { name: "amber-falcon" },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
/** Mount a sidebar with one session; the handle is disposed after each test. */
function mount(s: Session): HTMLElement {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const handle = renderSidebar(storeWith([s]));
mounted = handle;
return handle.el;
}
function cardIn(el: HTMLElement, s: Session): HTMLElement {
return el.querySelector<HTMLElement>(`[data-session-id="${s.id}"]`) as HTMLElement;
}
/** Mount a sidebar with one session and right-click its card. */
function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } {
const el = mount(s);
const row = cardIn(el, s);
row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true }));
return { el, row };
}
function menuItem(label: string): HTMLButtonElement {
const found = [...document.querySelectorAll<HTMLButtonElement>(".context-menu-item")].find(
(b) => b.textContent === label,
);
if (!found) throw new Error(`no "${label}" entry in the context menu`);
return found;
}
describe("session name normalisation (#125)", () => {
it("trims, and treats a blank name as a clear", () => {
expect(normalizeSessionName(" amber-falcon ")).toBe("amber-falcon");
expect(normalizeSessionName(" ")).toBe("");
});
it("caps at the daemon's limit, counting code points", () => {
expect(normalizeSessionName("x".repeat(80))).toHaveLength(64);
expect([...normalizeSessionName("é".repeat(80))]).toHaveLength(64);
});
});
describe("sidebar card context menu (#125)", () => {
it("suppresses the browser menu and offers rename, spawn similar and kill", () => {
const s = session();
const row = cardIn(mount(s), s);
const ev = new Event("contextmenu", { bubbles: true, cancelable: true });
row.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([
"Rename…",
"Spawn similar",
"Kill session",
]);
});
it("spawn similar dispatches the same event the ⧉ button does", () => {
const s = session();
openCardMenu(s);
const seen = vi.fn();
document.addEventListener("catalog-spawn-similar", seen);
menuItem("Spawn similar").click();
document.removeEventListener("catalog-spawn-similar", seen);
expect(seen).toHaveBeenCalledOnce();
});
});
describe("inline rename (#125)", () => {
it("pre-fills the field with the session's own name", () => {
const { row } = openCardMenu(session());
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
expect(input?.value).toBe("amber-falcon");
expect(row.querySelector("span.leaf-name")).toBeNull();
});
it("starts empty for a session that has no name of its own", () => {
const { row } = openCardMenu(session({ metadata: {} }));
menuItem("Rename…").click();
expect(row.querySelector<HTMLInputElement>("input.leaf-rename")?.value).toBe("");
});
it("holds the tree still while the field is open", () => {
const s = session();
const { el, row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
// A catalog delta mid-edit must not rebuild the row out from under it.
document.dispatchEvent(new CustomEvent("catalog-rerender"));
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input);
});
it("Enter PATCHes the session with the typed name", () => {
const s = session();
const { row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
input.value = "crimson-otter";
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify(s), { status: 200 })),
);
vi.stubGlobal("fetch", fetchMock);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${s.id}`);
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({ name: "crimson-otter" });
});
it("Escape cancels without a request and restores the name", () => {
const s = session();
const { el, row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
input.value = "discarded";
const fetchMock = vi.fn(() => Promise.reject(new Error("offline")));
vi.stubGlobal("fetch", fetchMock);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(fetchMock).not.toHaveBeenCalled();
// The row is rebuilt from the store, so the old name comes back.
expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon");
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull();
});
});
-2
View File
@@ -38,7 +38,6 @@ describe("buildSpawnRequest", () => {
worktree: {
base_repo: "/home/user/project",
branch: "feat/foo",
cleanup_on_kill: true,
create_branch: true,
},
},
@@ -63,7 +62,6 @@ describe("buildSpawnRequest", () => {
worktree: {
base_repo: "/home/user/project",
branch: "spawn-v2",
cleanup_on_kill: true,
create_branch: false,
},
},
+294 -87
View File
@@ -9,8 +9,8 @@ import {
} from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */
@@ -75,9 +75,6 @@ export function buildSpawnRequest(
req.worktree = {
base_repo: baseRepo,
branch,
// The daemon always cleans up, guarded against unsaved work (#56);
// the wire field stays for older daemons.
cleanup_on_kill: true,
// Existing-mode picks must check out as-is; the daemon creates
// anything else with `worktree add -b` (#56/#61).
create_branch: mode !== "existing",
@@ -97,6 +94,11 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "custom", label: "Custom…", command: "" },
];
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74).
* Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
* crates/maestro-tui/src/draft.rs so both clients debounce alike. */
const GITROOT_PROBE_DEBOUNCE_MS = 250;
export interface SpawnFormHandle {
el: HTMLFormElement;
/** Remove global listeners (dir autocomplete) — call when unmounting. */
@@ -128,10 +130,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Working directory</span>
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
autocomplete="off" />
<div class="picker projects"></div>
<button type="button" class="project-other">Other directory…</button>
<div class="raw-wrap cwd-raw-wrap" hidden>
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
@@ -145,33 +148,42 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
</div>
</div>
<fieldset class="worktree">
<legend>
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span>
<span class="folder-name">Advanced options</span>
<span class="wt-summary"></span>
</button>
<div class="wt-collapsible" hidden>
<div class="field wt-step" hidden>
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
</div>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
</legend>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
</label>
</div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
<label class="wt-exist-wrap" hidden>
Pick a branch
<select class="branch-select"></select>
</label>
</div>
<label class="wt-new-wrap">
Branch
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="wt-exist-wrap" hidden>
Branch
<select class="branch-select"></select>
</label>
</div>
</fieldset>
<p class="form-error" role="alert" aria-live="polite"></p>
@@ -185,11 +197,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
const recentsEl = form.querySelector(".projects") as HTMLElement;
const projectsEl = form.querySelector(".projects") as HTMLElement;
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
const otherBtn = form.querySelector(".project-other") as HTMLButtonElement;
const wtStep = form.querySelector(".wt-step") as HTMLElement;
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
const wtSummary = form.querySelector(".wt-summary") as HTMLElement;
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
const wtBody = form.querySelector(".wt-body") as HTMLElement;
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
@@ -340,6 +358,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
}
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
@@ -348,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => selectPreset(p.id));
tile.addEventListener("click", () => {
commandTouched = true;
selectPreset(p.id);
});
cmdTiles.appendChild(tile);
}
}
// Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the
// operator has already touched (#76).
let commandTouched = false;
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
commandTouched = true;
command = cmdRaw.value.trim();
emit();
}
@@ -370,7 +407,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
applyInitialCommand();
if (commandTouched) {
reapplyActiveTile();
} else {
applyInitialCommand();
}
}
})
.catch(() => {
@@ -397,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (first) selectPreset(first.id, false);
}
// ---- working-directory picker ---------------------------------------
// ---- working-directory picker (#95) ----------------------------------
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
// 1. Step one is one box per repository (name, then full path). A worktree
// is a box only when its main checkout is missing from the scan.
// 2. The filter keeps a repository when the query matches it or any of its
// worktrees, so a worktree name still finds its box.
// 3. Picking a box reveals step two: "+ new", then that repository's
// worktrees newest-first. "Other directory…" clears both steps.
// 4. "+ new" is the default selection — except on a box that is itself a
// linked worktree, which defaults to that worktree.
// 5. "+ new" means create: cwd is the repository, create-worktree on.
// An existing worktree means run in it: cwd is that worktree,
// create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
// these are separate acts — #91's rule, now an explicit choice.
// 6. Neither step probes /api/gitroot; the scan already reports repository
// membership. Only the free-text hatch pays for a probe.
/** "new" (create a worktree) or the path of an existing worktree to run in. */
type WorktreeChoice = "new" | string;
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
let groups: ProjectGroup[] = []; // rebuilt on every render
let selectedGroup: ProjectGroup | null = null;
let worktreeChoice: WorktreeChoice = "new";
function showRawCwd(prefillValue?: string, focus = true): void {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveProject(null);
clearProjectSelection();
if (focus) {
cwdRaw.focus();
cwdRaw.select();
@@ -416,55 +481,98 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd = cwdRaw.value.trim();
branchCache = null;
emit();
if (wtEnabled.checked) probeGitRoot();
if (wtEnabled.checked) scheduleGitRootProbe();
});
function markActiveProject(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".project-row")) {
row.classList.toggle(
"is-active",
(row as HTMLElement).dataset.cwd === activeCwd,
);
/** Every worktree of a group, including the group's own box when the scan
* found no main checkout to nest it under. */
function worktreesOf(group: ProjectGroup): Project[] {
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
}
function markActiveBox(): void {
const active = selectedGroup?.main.path ?? null;
for (const box of projectsEl.querySelectorAll(".project-box")) {
box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active);
}
}
function selectProject(project: Project): void {
cwd = project.path;
function markActiveChoice(): void {
for (const btn of wtChoices.querySelectorAll(".wt-choice")) {
const el = btn as HTMLElement;
const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? "");
el.classList.toggle("is-active", value === worktreeChoice);
}
}
function clearProjectSelection(): void {
selectedGroup = null;
worktreeChoice = "new";
wtChoices.replaceChildren();
wtStep.hidden = true;
markActiveBox();
}
function selectProject(group: ProjectGroup): void {
selectedGroup = group;
cwdRawWrap.hidden = true;
markActiveProject(project.path);
// The scan already knows this directory is inside a repo (#61): the
// toggle defaults back on and no /api/gitroot round trip is needed.
wtEnabled.checked = true;
applyProjectRepo(project.base_repo ?? project.path);
renderWorktreeChoices(group);
markActiveBox();
// Rule 4: a box that is itself a linked worktree defaults to running there.
applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new");
}
function applyWorktreeChoice(choice: WorktreeChoice): void {
const group = selectedGroup;
if (!group) return;
const target =
choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
if (!target) return;
worktreeChoice = choice;
cwd = target.path;
branchCache = null;
markActiveChoice();
if (choice === "new") {
wtEnabled.checked = true;
updateWtSummary();
applyProjectRepo(group.main.base_repo ?? group.main.path);
} else {
wtEnabled.checked = false;
updateWtSummary();
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
}
emit();
// Picking a project pre-selects the command it was last used with.
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
// The chosen directory pre-selects the command it was last used with.
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
if (match) selectPreset(match.id);
}
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
/** Groups with at least one member (itself or a worktree) matching `query`. */
function visibleGroups(query: string): ProjectGroup[] {
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
}
function renderProjects(): void {
recentsEl.replaceChildren();
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
for (const group of groupProjects(sorted)) {
recentsEl.appendChild(projectRow(group.main));
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
}
if (discovered.length > 0 && sorted.length === 0) {
projectsEl.replaceChildren();
groups = groupProjects(sortProjects(discovered, new Date()));
const visible = visibleGroups(filterInput.value);
for (const group of visible) projectsEl.appendChild(projectBox(group));
if (discovered.length > 0 && visible.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-empty";
empty.textContent = "No projects match.";
recentsEl.appendChild(empty);
projectsEl.appendChild(empty);
}
recentsEl.appendChild(otherDirRow());
markActiveProject(cwdRawWrap.hidden ? cwd : null);
markActiveBox();
}
filterInput.addEventListener("input", renderProjects);
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
async function loadProjects(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
projectsEl.replaceChildren(loadingRow());
try {
discovered = await fetchProjects();
} catch {
@@ -475,12 +583,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (seedCwd) {
// A seed sets the directory verbatim, regardless of what the scan found.
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
// picked row: keep the picker and don't steal focus. Otherwise land
// picked box: keep the picker and don't steal focus. Otherwise land
// focused in the raw input (clone / no-match flows).
if (prefill.cwdRawVisible === false) {
cwd = seedCwd;
cwdRaw.value = seedCwd;
markActiveProject(cwd);
// Restoring must not touch the worktree toggle — the seed carries it.
if (!restoreSelection(seedCwd)) clearProjectSelection();
} else {
showRawCwd(seedCwd, false);
}
@@ -497,34 +606,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
function projectRow(project: Project): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
isStale(project, new Date()) ? " is-stale" : ""
}`;
row.dataset.cwd = project.path;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
row.innerHTML = `
<span class="project-info">
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
</span>
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
row.addEventListener("click", () => selectProject(project));
return row;
/** Put the two steps back on a restored draft's cwd, without re-running the
* side effects of a fresh pick. */
function restoreSelection(path: string): boolean {
for (const group of groups) {
const wt = worktreesOf(group).find((p) => p.path === path);
const choice: WorktreeChoice | null =
wt ? wt.path : group.main.path === path ? "new" : null;
if (choice === null) continue;
selectedGroup = group;
worktreeChoice = choice;
renderWorktreeChoices(group);
markActiveChoice();
markActiveBox();
return true;
}
return false;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "project-row project-other";
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
function projectBox(group: ProjectGroup): HTMLElement {
const project = group.main;
const box = document.createElement("button");
box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
// tells two projects sharing a basename apart (#95).
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
.filter(Boolean)
.join(" · ");
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(project.path)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
}
function renderWorktreeChoices(group: ProjectGroup): void {
wtChoices.replaceChildren();
wtChoices.appendChild(
choiceButton("new", "+ new", `new worktree of ${group.main.name}`),
);
for (const wt of worktreesOf(group)) {
wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path));
}
wtStep.hidden = false;
}
function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`;
btn.dataset.kind = choice === "new" ? "new" : "existing";
if (choice !== "new") btn.dataset.cwd = choice;
btn.innerHTML = `
<span class="wt-choice-name">${escapeHtml(label)}</span>
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
btn.addEventListener("click", () => applyWorktreeChoice(choice));
return btn;
}
function loadingRow(): HTMLElement {
@@ -534,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
return p;
}
// ---- worktree: disclosure (#93), reuses the sidebar's folder-header ---
let advancedCollapsed = true;
function setAdvancedCollapsed(collapsed: boolean): void {
advancedCollapsed = collapsed;
wtToggle.setAttribute("aria-expanded", String(!collapsed));
wtCollapsible.hidden = collapsed;
}
function updateWtSummary(): void {
wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree";
}
wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed));
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
// The project scan already reports repo membership, so a picked project
// resolves its base repo locally (#61); only the free-text escape hatch
@@ -572,7 +725,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
applyProjectRepo(project.base_repo ?? project.path);
return;
}
void probeGitRoot();
scheduleGitRootProbe();
}
// Every keystroke resets this timer, so a burst of typing issues one
// request once the operator pauses. `probeToken` inside probeGitRoot still
// owns ordering: a scheduled probe that fires after a newer one started
// loses the token race and is dropped.
let probeTimer: number | undefined;
function scheduleGitRootProbe(): void {
window.clearTimeout(probeTimer);
probeTimer = window.setTimeout(() => {
probeTimer = undefined;
void probeGitRoot();
}, GITROOT_PROBE_DEBOUNCE_MS);
}
async function probeGitRoot(): Promise<void> {
@@ -601,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// Publish the untick too, or the next render seeds the box checked with
// no inferred repo and submitting fails.
wtEnabled.checked = false;
updateWtSummary();
emit();
wtBody.hidden = true;
wtHint.hidden = false;
@@ -674,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
branchSelect.addEventListener("change", () => emit());
wtEnabled.addEventListener("change", () => {
updateWtSummary();
emit();
if (wtEnabled.checked) {
resolveBaseRepo();
@@ -687,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// The toggle defaults ON (#61): the operator almost always wants a
// worktree, and every non-repo target unticks it via the gitroot probe.
wtEnabled.checked = prefill.worktreeEnabled ?? true;
updateWtSummary();
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
setBranchMode(branchMode);
@@ -697,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocomplete = attachDirAutocomplete(cwdRaw);
// ---- Enter submits from anywhere (#97) --------------------------------
// A project-box/wt-choice that isn't yet the active pick takes the Enter to
// choose it instead; the next Enter, now that it's active, submits.
// dirAutocomplete's own Enter (accept the highlight) already preventDefaults
// when it handles the key, which the `e.defaultPrevented` guard defers to.
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
// does the same) rather than folding it into the blanket-submit rule below.
form.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (target === wtEnabled) {
e.preventDefault();
wtEnabled.checked = !wtEnabled.checked;
wtEnabled.dispatchEvent(new Event("change"));
return;
}
if (target instanceof HTMLButtonElement) {
if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return;
e.preventDefault();
if (!target.classList.contains("is-active")) {
target.click();
return;
}
form.requestSubmit();
return;
}
e.preventDefault();
if (target === filterInput) {
const top = visibleGroups(filterInput.value)[0];
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
}
form.requestSubmit();
});
// ---- submit ----------------------------------------------------------
form.addEventListener("submit", async (e) => {
e.preventDefault();
@@ -733,6 +939,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
el: form,
destroy() {
destroyed = true;
window.clearTimeout(probeTimer);
autocomplete.destroy();
form.remove();
},
+258
View File
@@ -0,0 +1,258 @@
// @vitest-environment happy-dom
// Enter submits the draft form from any field, at any point (#97). The two
// exceptions: a picker button that isn't yet the active choice takes the
// Enter to *choose* it instead (the next Enter submits), and the project
// filter must resolve to the highlighted project rather than sending
// whatever cwd was already set.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
const MAESTRO: Project = {
path: "/home/user/tea/maestro",
name: "maestro",
is_worktree: false,
last_activity: "2026-08-20T10:00:00Z",
};
const WT_A: Project = {
path: "/home/user/tea/maestro/worktrees/bulgarians",
name: "bulgarians",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-22T10:00:00Z",
};
const OTHER: Project = {
path: "/home/user/tea/rev",
name: "rev",
is_worktree: false,
last_activity: "2026-08-19T10:00:00Z",
};
function stubFetch(projects: Project[]): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["amber-fox"] }
: url.includes("/api/branches")
? []
: url.includes("/api/dirs")
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
describe("spawnForm Enter-to-submit (#97)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
stubFetch(projects);
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
return handle.el;
}
function submitSpy(): ReturnType<typeof vi.fn> {
return vi.fn();
}
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
el.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
}
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
expect(toggle.checked).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(toggle);
expect(toggle.checked).toBe(false);
expect(spy).not.toHaveBeenCalled();
pressEnter(toggle);
expect(toggle.checked).toBe(true);
expect(spy).not.toHaveBeenCalled();
});
describe("submits immediately from ordinary fields", () => {
it("name field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree branch field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
expect(spy).toHaveBeenCalledTimes(1);
});
it("custom command field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("raw cwd field once the autocomplete dropdown is closed", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/xyz-nothing-here";
raw.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(300);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.focus();
raw.value = "/opt/";
raw.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(150);
const spy = submitSpy();
form.addEventListener("submit", spy);
const { defaultPrevented } = pressEnter(raw);
expect(defaultPrevented).toBe(true);
expect(spy).not.toHaveBeenCalled();
expect(raw.value).toBe("/opt/nomatch-zzz/");
});
});
describe("two-step picker: first Enter chooses, second submits", () => {
it("project box", async () => {
const form = await mount([MAESTRO]);
const b = box(form, MAESTRO.path);
b.focus();
const spy = submitSpy();
form.addEventListener("submit", spy);
const first = pressEnter(b);
expect(first.defaultPrevented).toBe(true);
expect(b.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(b);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree choice", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(choice);
expect(choice.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(choice);
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe("project filter", () => {
it("submits with the highlighted (first matching) project, not the filter text", async () => {
const form = await mount([MAESTRO, OTHER]);
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "rev";
filter.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
});
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
await drain();
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "maestro";
filter.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
"is-active",
),
).toBe(true);
});
});
});
+124
View File
@@ -0,0 +1,124 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
* everything else (names, projects, gitroot) answers immediately with an
* empty/default body. Lets a test pick a preset (or type a custom command)
* mid-flight, then observe what the late `/api/info` resolution does to it. */
function stubDeferredInfo(): { resolveInfo: () => void } {
let releaseInfo: () => void = () => {};
const infoGate = new Promise<void>((resolve) => {
releaseInfo = resolve;
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/info")) {
await infoGate;
return new Response(
JSON.stringify({
hostname: "h",
version: "0",
home_dir: "/home/user",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "fish", label: "Fish", command: "fish" },
{ id: "custom", label: "Custom…", command: "" },
],
}),
{ status: 200 },
);
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
return new Response(JSON.stringify([]), { status: 200 });
}),
);
return { resolveInfo: releaseInfo };
}
describe("spawnForm preset selection vs. late /api/info (#76)", () => {
let handle: SpawnFormHandle | null = null;
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
});
async function drain(): Promise<void> {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
it("keeps an operator-picked tile after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
expect(zshTile.classList.contains("is-active")).toBe(true);
// /api/info lands late with a preset list that doesn't even include "zsh".
resolveInfo();
await drain();
// The operator's pick must not be discarded back to the new list's first
// entry — `onChange` only starts firing once the initial load settles
// (`ready`), which happens right here, so this is the first place the
// published command is observable.
expect(values?.command).toBe("zsh");
});
it("keeps an operator-typed custom command after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
cmdRaw.value = "htop";
cmdRaw.dispatchEvent(new Event("input"));
resolveInfo();
await drain();
expect(values?.command).toBe("htop");
expect(cmdRaw.value).toBe("htop");
});
it("still applies the real presets' first entry when the operator never touched the command", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
resolveInfo();
await drain();
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
});
});
+148
View File
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in
* spawnForm.ts (#74). */
const DEBOUNCE_MS = 250;
const PROJECTS = [
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
];
/** Stub the fetches the form makes. Gitroot responses for a given path can be
* held until `releaseGitRoot(path)` is called, so tests can land answers out
* of order. */
function stubFetch(opts?: { holdGitRootFor?: string[] }): {
urls: string[];
gitRootCalls: string[];
releaseGitRoot: (path: string) => void;
} {
const urls: string[] = [];
const gitRootCalls: string[] = [];
const releases = new Map<string, () => void>();
const held = new Map<string, Promise<void>>();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url.includes("/api/dirs")) {
return new Response(JSON.stringify({ entries: [] }), { status: 200 });
}
if (url.includes("/api/gitroot")) {
const path = new URL(url, "http://localhost").searchParams.get("path") ?? "";
gitRootCalls.push(path);
if (opts?.holdGitRootFor?.includes(path)) {
let release!: () => void;
held.set(
path,
new Promise<void>((r) => {
release = r;
}),
);
releases.set(path, release);
}
await held.get(path);
return new Response(JSON.stringify({ root: path }), { status: 200 });
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
{ status: 200 },
);
}),
);
return {
urls,
gitRootCalls,
releaseGitRoot: (path: string) => releases.get(path)?.(),
};
}
describe("spawnForm gitroot probe debounce (#74)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(opts?: Parameters<typeof stubFetch>[0]) {
const stub = stubFetch(opts);
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
return { form: handle.el, stub };
}
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
function typeRaw(form: HTMLFormElement, value: string): void {
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = value;
raw.dispatchEvent(new Event("input"));
}
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
const { form, stub } = await mount();
form.querySelector<HTMLElement>(".project-other")!.click();
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
for (let i = 1; i <= "/opt/somewhere".length; i++) {
typeRaw(form, "/opt/somewhere".slice(0, i));
await drain();
expect(stub.gitRootCalls).toEqual([]);
}
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
await drain();
expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]);
expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1);
});
it("a stale probe answer cannot overwrite a newer input", async () => {
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
form.querySelector<HTMLElement>(".project-other")!.click();
// Probe for the old target, held in flight.
typeRaw(form, "/repo/old");
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
// Newer target supersedes it and answers immediately.
typeRaw(form, "/repo/new");
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
await drain();
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
// The late answer to the old target lands last but must lose.
stub.releaseGitRoot("/repo/old");
await drain();
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
});
});
+105 -6
View File
@@ -18,6 +18,7 @@ const BRANCHES = [
* its branch list is still in flight. */
function stubFetch(opts?: {
gateBranches?: (release: () => void) => void;
projects?: unknown[];
}): string[] {
const urls: string[] = [];
const gated = new Promise<void>((resolve) => {
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
return new Response(JSON.stringify(BRANCHES), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 });
return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
@@ -79,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
};
function pickProject(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
form.querySelector<HTMLButtonElement>(".project-other")!.click();
form
.querySelectorAll<HTMLButtonElement>(".project-row")
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
}
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
@@ -100,14 +101,44 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
);
});
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
urls = stubFetch({
projects: [
{
path: "/home/user/wt-91",
name: "wt-91",
is_worktree: true,
base_repo: "/home/user/maestro",
last_activity: "2026-08-20T10:00:00Z",
},
],
});
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
await drain();
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
false,
);
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
it("still probes the gitroot for a free-text directory", async () => {
const form = await mount();
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
// fire — debounced, so the quiet period must elapse first (#74).
form.querySelector<HTMLElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/somewhere";
raw.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(250);
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
@@ -149,6 +180,74 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
expect(modes.at(-1)).toBe("existing");
});
it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
const form = await mount();
pickProject(form);
await drain();
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
expect(newLabel).not.toBe("Branch");
expect(existLabel).not.toBe("Branch");
expect(newLabel).not.toBe(existLabel);
expect(newLabel).toMatch(/name/i);
expect(existLabel).toMatch(/pick|choose|select/i);
});
it("collapses the advanced (worktree) section by default (#93)", async () => {
const form = await mount();
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
});
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
const form = await mount();
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
});
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
urls = stubFetch({
projects: [
{
path: "/home/user/wt-91",
name: "wt-91",
is_worktree: true,
base_repo: "/home/user/maestro",
last_activity: "2026-08-20T10:00:00Z",
},
],
});
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
await drain();
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
// Still collapsed — picking a project must not force it open.
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"false",
);
});
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
const form = await mount();
const before = [...form.querySelectorAll(".field")];
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"true",
);
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
});
it("does not repopulate a destroyed form when the branch list lands late", async () => {
const gate: { release?: () => void } = {};
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
+296 -82
View File
@@ -727,10 +727,8 @@ textarea {
}
/* Earlier path segments collapse to their initial; the trailing segment (the
one that actually names the dir) stays full. Dim the collapsed prefix. Shared
by the sidebar tree and the spawn dialog's recent-directory picker. */
.folder-name .folder-stem,
.recent-dir .folder-stem {
one that actually names the dir) stays full. Dim the collapsed prefix. */
.folder-name .folder-stem {
color: var(--faint);
}
@@ -738,7 +736,6 @@ textarea {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 30px;
margin: 1px var(--sp-2);
padding: 4px var(--sp-3);
border-radius: var(--r-1);
@@ -789,53 +786,167 @@ textarea {
font-weight: 400;
}
/* #120: every session card has two lines, so its row height is stable. */
.tree-leaf.session-card {
flex-direction: column;
align-items: stretch;
gap: 1px;
/* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */
height: 49px;
}
.card-line1 {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.leaf-name {
font-weight: 500;
font-size: var(--fs-13);
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-meta {
font-size: var(--fs-11);
color: var(--muted);
margin-left: auto;
padding-left: var(--sp-2);
/* Grows to push the actions to the right edge but shrinks first, so the name
truncates before it can ever reach them — overlap is a defect (#107). */
flex: 1 1 auto;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
align-items: baseline;
}
/* Inline rename field (#125): sits exactly where the name it replaces did, so
the card does not jump when the edit starts. */
input.leaf-rename {
font: inherit;
font-weight: 500;
font-size: var(--fs-13);
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--accent-ring);
border-radius: var(--r-2);
padding: 0 var(--sp-1);
min-width: 0;
}
input.leaf-rename:focus {
outline: none;
box-shadow: 0 0 0 3px var(--accent-tint);
}
/* Right-click menu on a session card (#125). Positioned from the click point,
so it is fixed to the viewport rather than to the scrolling sidebar. */
.context-menu {
position: fixed;
z-index: var(--z-modal);
display: flex;
flex-direction: column;
min-width: 168px;
padding: var(--sp-1);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.context-menu-item {
appearance: none;
background: none;
border: 0;
border-radius: var(--r-2);
color: var(--ink);
font: inherit;
font-size: var(--fs-13);
text-align: left;
padding: var(--sp-1) var(--sp-2);
cursor: pointer;
}
.context-menu-item:hover,
.context-menu-item:focus-visible {
background: var(--surface-3);
outline: none;
}
.context-menu-item.is-danger {
color: var(--danger);
}
.context-menu-item.is-danger:hover,
.context-menu-item.is-danger:focus-visible {
background: var(--danger-tint);
}
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
state word itself is never spelled out here; the colour carries it. */
.card-line2 {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.leaf-cmd {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-badge {
font-size: var(--fs-11);
letter-spacing: 0.02em;
text-transform: uppercase;
min-width: 0;
color: var(--muted);
border: 1px solid var(--border);
border-radius: var(--r-pill);
padding: 1px 8px;
}
.card-line2[data-activity="active"] {
color: var(--live);
}
.card-line2[data-activity="idle"] {
color: var(--muted);
}
.card-line2[data-activity="awaiting-input"] {
color: var(--warn);
}
.card-line2[data-activity="error"] {
color: var(--danger);
}
.activity-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
}
.activity-duration {
color: inherit;
flex: 0 0 auto;
}
/* Agent-reported detail (#112): sits after the duration, shrinks with an
ellipsis so it never pushes the trailing fragments off the line. */
.activity-detail {
color: var(--faint);
/* Consume only the space left after the trailing status + command. */
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
signals severity and "exited" dims with the row. */
.card-flag {
color: var(--faint);
flex: 0 0 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Status fragments stay intact. The unconditional command is the final
truncation target when even the complete trailing set cannot fit. */
.leaf-cmd {
flex: 0 1 auto;
}
/* Actions live in line 1's flow at its right edge — never overlaid on the
name (#107). They reserve their width even while hover-faded. */
.leaf-actions {
position: absolute;
right: var(--sp-1);
top: 50%;
transform: translateY(-50%);
flex: 0 0 auto;
margin-left: auto;
display: inline-flex;
gap: 2px;
}
@@ -1093,9 +1204,11 @@ textarea {
.attach-body .xterm .xterm-viewport {
overflow-y: auto;
scrollbar-width: none;
/* We drive scrollback from touchmove (see attach.ts); claim vertical pans so
the browser doesn't fight the handler or trigger gestures mid-scroll. */
touch-action: pan-y;
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
the gesture to the browser, which may cancel the event stream before the
terminal sees it; `none` keeps a drag inside the terminal under our
control while the rest of the page retains native scrolling. */
touch-action: none;
}
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
@@ -1208,6 +1321,9 @@ textarea {
/* ---------- forms ---------- */
.spawn-form {
width: 100%;
max-width: 60ch;
margin: 0 auto;
padding: var(--sp-3);
overflow: auto;
display: flex;
@@ -1276,18 +1392,33 @@ textarea {
.worktree {
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: var(--sp-3);
padding: 0 var(--sp-3);
margin: 0;
display: flex;
flex-direction: column;
gap: var(--sp-3);
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
}
.worktree legend {
padding: 0 6px;
color: var(--ink);
font-size: var(--fs-13);
.worktree .wt-toggle {
margin: 0 calc(var(--sp-3) * -1);
padding-left: var(--sp-3);
width: calc(100% + var(--sp-3) * 2);
}
.wt-summary {
color: var(--muted);
font-size: var(--fs-12);
}
.wt-collapsible {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
}
.wt-collapsible[hidden] {
display: none;
}
.form-error {
@@ -1371,8 +1502,9 @@ textarea {
color: var(--muted);
}
/* Project picker rows (#59). Worktree rows are indented under their base
repository; rows stale beyond 7 days are dimmed but still selectable. */
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
picked project's worktree choices. Stale beyond 7 days dims but stays
selectable. */
.picker {
display: flex;
flex-direction: column;
@@ -1387,73 +1519,155 @@ textarea {
width: 100%;
}
.project-row {
/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so
step two and the raw-cwd hatch stayed visible while hidden. */
.wt-step[hidden],
.raw-wrap[hidden] {
display: none;
}
.picker.projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
align-content: start;
gap: var(--sp-2);
max-height: 300px;
padding: 1px;
}
.project-box {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: var(--sp-2) var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
text-align: left;
cursor: pointer;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.project-box:hover {
border-color: var(--border-strong);
}
.project-box.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.project-box.is-stale {
opacity: 0.55;
}
.project-name {
font-size: var(--fs-13);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The whole path, never abbreviated — it is what tells two projects sharing a
basename apart. Wraps rather than truncating so none of it is hidden. */
.project-path {
font-family: var(--font-mono);
font-size: var(--fs-12);
line-height: 1.35;
color: var(--muted);
overflow-wrap: anywhere;
}
.project-meta {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta:empty {
display: none;
}
.project-other {
align-self: flex-start;
padding: 4px 0;
min-height: 30px;
background: none;
border: 0;
color: var(--muted);
font-family: inherit;
font-size: var(--fs-13);
font-style: italic;
text-align: left;
cursor: pointer;
}
.project-other:hover {
color: var(--ink);
}
/* Step two: "+ new" (create one) or an existing worktree (run in it). */
.picker.wt-choices {
max-height: 200px;
}
.wt-choice {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
min-height: 32px;
padding: 6px var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: left;
cursor: pointer;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.project-row:hover {
.wt-choice:hover {
border-color: var(--border-strong);
}
.project-row.is-active {
.wt-choice.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.project-row.is-worktree {
margin-left: var(--sp-4);
}
.project-row.is-stale {
opacity: 0.55;
}
.project-info {
display: flex;
align-items: baseline;
gap: var(--sp-2);
min-width: 0;
}
.project-name {
.wt-choice-name {
flex: 0 0 auto;
font-size: var(--fs-13);
font-weight: 600;
white-space: nowrap;
}
.recent-dir {
.wt-choice-new .wt-choice-name {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent);
}
.recent-cmd {
flex: 0 0 auto;
.wt-choice-sub {
min-width: 0;
font-family: var(--font-mono);
font-size: var(--fs-12);
line-height: 1.35;
color: var(--muted);
}
.project-other .recent-dir {
font-family: inherit;
font-style: italic;
text-align: right;
overflow-wrap: anywhere;
}
.picker-empty {
+56
View File
@@ -95,3 +95,59 @@ describe("mergeLinear", () => {
expect(merged.path).toBe("a/b");
});
});
describe("worktree sessions group under their repository (#99)", () => {
/** A session the daemon spawned into a worktree of `repo`. */
function worktreeSession(id: string, repo: string, wt: string): Session {
return { ...session(id, wt), worktree: { path: wt, base_repo: repo } } as Session;
}
/** Top-level folder names, after linear chains are collapsed. */
function topFolders(root: TreeFolder): string[] {
return root.children.filter((c) => c.kind === "folder").map((c) => (c as TreeFolder).name);
}
it("nests under the repo, not the worktree directory", () => {
const root = buildTree(
[
worktreeSession(
"a",
"/home/n/tea/agent-skills",
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
const repo = root.children[0] as TreeFolder;
expect(repo.children).toHaveLength(1);
expect(repo.children[0].kind).toBe("leaf");
});
it("puts a repo's plain and worktree sessions side by side", () => {
const root = buildTree(
[
session("plain", "/home/n/tea/agent-skills"),
worktreeSession(
"wt",
"/home/n/tea/agent-skills",
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
const repo = root.children[0] as TreeFolder;
expect(repo.children).toHaveLength(2);
expect(repo.children.every((c) => c.kind === "leaf")).toBe(true);
});
it("leaves a session without a worktree where it is", () => {
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/arr"]);
});
});
+4 -2
View File
@@ -2,7 +2,7 @@
// so it can be tested directly.
import type { Session } from "./types.js";
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
import { abbreviateCwd, sessionName, sessionTreePath } from "./types.js";
export interface TreeFolder {
kind: "folder";
@@ -30,7 +30,9 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
const cwd = sessionCwd(s);
// Grouped by repository, so a worktree session lands under its repo
// rather than in a folder of its own (#99).
const cwd = sessionTreePath(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
if (lc) {
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
+72 -5
View File
@@ -9,8 +9,9 @@ export type IsoTimestamp = string;
export type SessionStatus = "Running" | "Dead";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput";
* Absent on payloads from daemons that predate the field. `Error` is only set
* when an agent reports failure (#105); the daemon never infers it. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
export interface Session {
id: Uuid;
@@ -25,12 +26,36 @@ export interface Session {
foreground?: string | null;
/** Inferred activity. Defaults to `Idle` on the wire when omitted. */
activity?: ActivityState;
/** Short human string an agent reported alongside its state (#105) — e.g.
* why it is in `Error`. Shown on a sidebar card's meta line while the
* report is in force; cleared when the report lapses or is replaced. */
activity_detail?: string | null;
/** Wall-clock of the most recent observed PTY output (NAP-340). */
last_activity_at?: IsoTimestamp;
/** Wall-clock of when the session entered its current `activity` state
* (#102) — distinct from `last_activity_at`, which only tracks PTY output.
* Defaults to the Unix epoch on payloads from daemons that predate the
* field. */
activity_since?: IsoTimestamp;
/** The pane's *current* working directory, as the daemon's activity watcher
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
* Drives the directory-based session tree. Absent until first observed. */
current_cwd?: string | null;
/** What currently blocks an unforced kill of this session (#83), as the
* daemon's watcher last probed it. Absent until probed and for sessions
* without a worktree; all counts default to 0 on the wire for older
* daemons. */
blocked_work?: InTheWay | null;
/** The worktree the daemon created for this session, absent when it did not
* create one. `base_repo` is the repository's main checkout, which is what
* the session tree groups by (#99). */
worktree?: SessionWorktree | null;
}
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
export interface SessionWorktree {
path: string;
base_repo: string;
}
/** One directory's immediate subdirectories (NAP-329). */
@@ -113,7 +138,6 @@ export type Projects = Project[];
export interface WorktreeSpec {
base_repo: string;
branch: string;
cleanup_on_kill?: boolean;
/** When false, `branch` already exists and is checked out as-is (#61).
* Defaults to true (create a new branch) on older daemons. */
create_branch?: boolean;
@@ -181,6 +205,19 @@ export function reasonLabel(reason: RetainedReason): string {
return reason;
}
/** #83: this session's blocked-work breakdown when something actually blocks
* an unforced kill; `null` while clear or not yet probed. Render it with
* {@link formatInTheWay} so every client phrases it identically. */
export function sessionBlockers(s: Session): InTheWay | null {
const w = s.blocked_work;
if (!w) return null;
const blocked =
(w.uncommitted_files ?? 0) > 0 ||
(w.untracked_files ?? 0) > 0 ||
(w.unpushed_commits ?? 0) > 0;
return blocked ? w : null;
}
export interface CreateSessionRequest {
cwd: string;
command: string;
@@ -207,19 +244,49 @@ export interface ApiError {
}
export function sessionName(s: Session): string {
return customSessionName(s) ?? s.command;
}
/** The session's own name, without the command fallback (#125): `undefined`
* for a session that was never given one. */
export function customSessionName(s: Session): string | undefined {
const meta = s.metadata as Record<string, unknown>;
const name = meta?.name;
if (typeof name === "string" && name.length > 0) return name;
return s.command;
return undefined;
}
/** Longest session name the daemon stores (#125), in characters. Mirrors
* `maestro_protocol::MAX_SESSION_NAME_LEN`. */
export const MAX_SESSION_NAME_LEN = 64;
/** Trim a requested session name and cap it at {@link MAX_SESSION_NAME_LEN}
* characters. Empty means "clear the name", so the card falls back to the
* command. Mirrors `maestro_protocol::normalize_session_name` — the daemon
* applies the same rule, this only keeps the UI honest about what it will
* get back. Uses the spread so the cap counts code points, not UTF-16 units. */
export function normalizeSessionName(name: string): string {
return [...name.trim()].slice(0, MAX_SESSION_NAME_LEN).join("");
}
/** The directory a session lives in: its live `current_cwd` if the daemon has
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
* observed one, else the spawn `cwd`. */
export function sessionCwd(s: Session): string | undefined {
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
return s.cwd || undefined;
}
/** Where a session sits in the directory tree. A worktree session belongs to
* its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
* grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
* session of the same name, and the repository stops being where you look for
* the work. `base_repo` is the main checkout — the daemon resolves it. */
export function sessionTreePath(s: Session): string | undefined {
const repo = s.worktree?.base_repo;
if (typeof repo === "string" && repo) return repo;
return sessionCwd(s);
}
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
export function abbreviateCwd(cwd: string, home?: string): string {
+243
View File
@@ -0,0 +1,243 @@
// @vitest-environment happy-dom
// Second step of the working-directory picker (#95). See the interaction rules
// block in spawnForm.ts for what each choice means.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
import type { Project } from "./types.js";
const MAESTRO: Project = {
path: "/home/user/tea/maestro",
name: "maestro",
is_worktree: false,
last_activity: "2026-08-20T10:00:00Z",
last_command: "claude",
};
const WT_A: Project = {
path: "/home/user/tea/maestro/worktrees/bulgarians",
name: "bulgarians",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-22T10:00:00Z",
};
const WT_B: Project = {
path: "/home/user/tea/maestro/worktrees/spawn-v2",
name: "spawn-v2",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-21T10:00:00Z",
};
const OTHER: Project = {
path: "/home/user/tea/rev",
name: "rev",
is_worktree: false,
last_activity: "2026-08-19T10:00:00Z",
};
function stubFetch(projects: Project[]): string[] {
const urls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
const body = url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["amber-fox"] }
: url.includes("/api/branches")
? []
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
return new Response(JSON.stringify(body), { status: 200 });
}),
);
return urls;
}
describe("spawnForm worktree step (#95)", () => {
let handle: SpawnFormHandle | null = null;
let urls: string[] = [];
let changes: SpawnFormValues[] = [];
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
changes = [];
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
urls = stubFetch(projects);
handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) });
document.body.appendChild(handle.el);
await drain();
return handle.el;
}
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
const choices = (form: HTMLFormElement) => [
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
];
const activeChoice = (form: HTMLFormElement) =>
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
const toggle = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
it("hides the worktree step until a project is picked", async () => {
const form = await mount([MAESTRO, WT_A]);
expect(step(form).hidden).toBe(true);
expect(choices(form)).toEqual([]);
box(form, MAESTRO.path).click();
await drain();
expect(step(form).hidden).toBe(false);
});
it("keeps the worktree step inside the collapsed Advanced options section", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
const collapsible = form.querySelector<HTMLElement>(".wt-collapsible")!;
expect(collapsible.contains(step(form))).toBe(true);
expect(collapsible.hidden).toBe(true);
});
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
await drain();
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]);
expect(activeChoice(form)!.dataset.kind).toBe("new");
});
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
expect(toggle(form).checked).toBe(true);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
// The scan already knows the repo — no probe.
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
});
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
await drain();
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
existing.click();
await drain();
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
expect(toggle(form).checked).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
expect(existing.classList.contains("is-active")).toBe(true);
// Still no probe: the scan reported this worktree's repo membership.
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
// The project box stays marked — it is what the worktree list belongs to.
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
});
it("switching back to '+ new' re-enables the create toggle", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
await drain();
choices(form)
.find((c) => c.dataset.kind === "new")!
.click();
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
expect(toggle(form).checked).toBe(true);
});
it("repopulates and resets to '+ new' when another project is picked", async () => {
const form = await mount([MAESTRO, WT_A, OTHER]);
box(form, MAESTRO.path).click();
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
await drain();
box(form, OTHER.path).click();
await drain();
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
expect(activeChoice(form)!.dataset.kind).toBe("new");
expect(changes.at(-1)!.cwd).toBe(OTHER.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
});
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
// No main checkout in the scan, so the orphan worktree is its own box.
const form = await mount([WT_A]);
box(form, WT_A.path).click();
await drain();
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
expect(toggle(form).checked).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
expect(step(form).hidden).toBe(false);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
await drain();
expect(step(form).hidden).toBe(true);
expect(choices(form)).toEqual([]);
expect(form.querySelector(".project-box.is-active")).toBeNull();
});
it("restores the picked worktree from a draft seed", async () => {
urls = stubFetch([MAESTRO, WT_A, WT_B]);
handle = createSpawnForm({
onSuccess() {},
seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false },
});
document.body.appendChild(handle.el);
await drain();
const form = handle.el;
expect(step(form).hidden).toBe(false);
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
expect(toggle(form).checked).toBe(false);
});
});