Compare commits

...
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 28778b2224 feat(maestro): add maestro send for typing into a session
ci / web (push) Successful in 1m56s
ci / rust (push) Successful in 5m13s
ci / flake (push) Successful in 8m8s
Adds POST /api/sessions/:id/input and a CLI command on top of it, so a
script (or another agent) can hand an instruction to a running session
without attaching to it.

The text goes in as one write and the Enter follows 150ms later: agents
that run a full-screen prompt coalesce a burst of input into a paste, and
a newline inside that burst becomes a line break instead of sending the
message. `--no-enter` skips the Enter and leaves the text in the prompt.

The session argument takes an ID, a unique ID prefix, or a session name;
names win over prefixes. A session whose pane has exited is refused (409)
rather than silently accepted — unlike a stray keystroke on an attach
socket, a send that goes nowhere is worth hearing about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:10:18 +01:00
Miguel Palhas 766eabbcb6 fix(maestro): bind terminal touch scroll to .xterm
ci / web (push) Successful in 38s
ci / rust (push) Successful in 4m8s
ci / flake (push) Successful in 6m39s
The touch handlers sat on `.xterm-viewport`, but xterm appends the
absolutely-positioned viewport before the `position: relative` screen, so
`.xterm-screen` paints over it and takes every touch. The viewport is not an
ancestor of the screen, so the listeners never fired and mobile drags did
nothing. Moves them, and `touch-action: none`, to `.xterm`.
2026-09-01 13:10:18 +01:00
Miguel Palhas c67fa2bd4e feat(maestro): force-kill prompt in the web client
ci / web (push) Successful in 35s
ci / rust (push) Successful in 4m1s
ci / flake (push) Successful in 6m55s
The kill button now sends the unforced DELETE immediately instead of
asking first. Only the daemon's guard refusal raises a prompt, and it is
a real dialog rather than confirm(), so it can offer the override the
daemon already implements.

The API error envelope is decoded into ApiRequestError, keeping the code
and the unsaved-work breakdown so the dialog names what is in the way.
2026-09-01 11:51:45 +01:00
Miguel Palhas 88b6bba9f2 feat(maestro): show only the program on session cards
ci / web (push) Successful in 34s
ci / rust (push) Successful in 3m56s
ci / flake (push) Successful in 7m8s
The command fragment named the full command line, so a session spawned
with `claude --dangerously-skip-permissions` spent the whole line on a
flag. Cards now name the program only — its icon in the web client, its
bare name in the TUI — and keep the full command line in the tooltip.

Adds `maestro_protocol::display::command_program`, the Rust counterpart
of the web's `splitCommand`.
2026-09-01 11:11:39 +01:00
Miguel PalhasandClaude Opus 5 1c349e5858 ci(maestro): cancel superseded runs
ci / web (push) Successful in 1m30s
ci / rust (push) Successful in 3m42s
ci / flake (push) Successful in 5m50s
Pushes queued a new run without stopping the old one. Group by ref and
cancel in-progress, except on main where runs finish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:35:31 +01:00
Miguel Palhas 9fc21b4306 Merge remote-tracking branch 'origin/main' into moderato-motif
ci / flake (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
2026-09-01 10:00:32 +01:00
naps62 19162f9952 Merge pull request 'feat(maestro): hide tab bar when a single tab' (#129) from marcato-lute into main
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
ci / rust (push) Has been cancelled
Reviewed-on: #129
2026-09-01 09:54:58 +01:00
Miguel PalhasandClaude Opus 5 2fcfc06c3c feat(maestro): drop the global app header
The header carried only the brand and a session tally; app actions had
already moved to the sidebar footer. The mobile ☰ it hosted moves to the
pane header (replacing the redundant back arrow) and to the welcome
state, so the session drawer stays reachable without a swipe. The DEV
`?kit` link moves to the sidebar footer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 09:51:22 +01:00
Miguel Palhas f82ab45766 Merge remote-tracking branch 'origin/main' into marcato-lute
ci / web (pull_request) Successful in 1m16s
ci / rust (pull_request) Successful in 7m39s
ci / flake (pull_request) Has been cancelled
2026-09-01 09:49:07 +01:00
Miguel Palhas d304171d7c feat(maestro): hide tab bar when a single tab
The bar has nothing to switch between with one tab, so it is pure chrome. It reappears as soon as a second tab exists; spawn already opens its own tab, so the "+" button stays reachable.
2026-09-01 09:48:15 +01:00
Miguel Palhas 95b459bd65 Merge remote-tracking branch 'origin/main' into largo-piano
ci / web (push) Successful in 40m34s
ci / rust (push) Successful in 44m56s
ci / flake (push) Has been cancelled
2026-08-31 22:40:53 +01:00
Miguel PalhasandClaude Opus 5 c9cfdb5668 feat(maestro): one-word handles, quieter project boxes
Session handles are one word from a single 218-entry list — instruments,
forms, notation and theory, tempo and dynamics — replacing the
adjective-noun pair. A batch is shuffled, so it repeats only once the
wordlist runs out.

Project boxes show the parent directory instead of the full path: the
last segment is already the name line above it. The last command is gone
from the box, and picking a project no longer pre-selects it — the
directory and what runs in it are independent choices. Both changes land
in the TUI picker too, which mirrors the web form's rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:40:49 +01:00
Miguel PalhasandClaude Opus 5 31158cdb1a fix(maestro): close mobile drawer on spawn
ci / web (push) Successful in 36s
ci / rust (push) Successful in 4m11s
ci / flake (push) Successful in 6m35s
Spawn and Spawn similar open a draft pane behind the open sidebar on
mobile, so the tap read as dead. Extract the drawer into its own module
and close it on both spawn events as well as a session tap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:31:05 +01:00
Miguel Palhas 7b04575ed5 Merge remote-tracking branch 'origin/main' into lento-sonata
ci / web (push) Successful in 33s
ci / rust (push) Successful in 3m48s
ci / flake (push) Successful in 6m3s
2026-08-31 22:23:30 +01:00
Miguel PalhasandClaude Opus 5 80af2e6816 feat(maestro): one pane, one tab on mobile
Below 860px the layout no longer grows a second navigation surface. The
pane pill strip is gone, the tab bar stays collapsed however many tabs
exist, and opening or spawning takes over the current pane instead of
splitting beside it or opening a tab. The sidebar drawer is the only way
to move between sessions.

The tree itself is untouched, so a desktop-built layout comes back when
the window widens again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:14:42 +01:00
Miguel Palhas 52abfc6eea Merge remote-tracking branch 'origin/main' into animato-trumpet
ci / web (push) Successful in 33s
ci / rust (push) Successful in 3m45s
ci / flake (push) Successful in 5m55s
2026-08-31 22:12:32 +01:00
Miguel Palhas eb9b4b4ae8 fix(maestro): replay pane mouse modes on attach
A full-screen app enables mouse reporting once, at startup. A client
attaching later never saw those sequences, so the web terminal read the
pane as a plain alternate screen with mouseTrackingMode "none" and its
touch and wheel gestures had nowhere to go — scrolling did nothing in a
browser opened after the session started.

Read the pane's mouse flags in the same display-message call and emit
the matching DECSETs ahead of the screen content. tmux expands an
unknown format name to the empty string, so the parser only trusts the
mouse tail when every field is present.

Closes #128
2026-08-31 22:07:22 +01:00
Miguel PalhasandClaude Opus 5 1f543d16d1 fix(web): bump pnpmDeps hash for JetBrains Mono
ci / web (push) Successful in 39s
ci / rust (push) Successful in 4m6s
ci / flake (push) Successful in 6m13s
1257ec6 added @fontsource/jetbrains-mono to web/pnpm-lock.yaml without
updating the hash, so pnpm install failed offline in configurePhase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:03:16 +01:00
Miguel Palhas f6cfcbb2c4 Merge remote-tracking branch 'origin/main' into maestoso-lyre
ci / web (push) Successful in 32s
ci / flake (push) Failing after 2m37s
ci / rust (push) Successful in 3m17s
2026-08-31 17:53:45 +01:00
Miguel Palhas 5920bbf2ec fix(maestro): fold the sole root sidebar folder into its own children
~ never distinguishes anything from a sibling — nothing else sits
beside it — so it showed as its own header even when it immediately
branched into multiple real projects. Now it folds into each child
instead, at the top only; a real divergence further down still gets
its own header.
2026-08-31 17:53:38 +01:00
Miguel Palhas eb5d28d3a6 Merge branch 'dolce-sonata'
ci / web (push) Successful in 1m7s
ci / flake (push) Failing after 2m45s
ci / rust (push) Successful in 3m48s
2026-08-31 15:32:45 +01:00
Miguel PalhasandClaude Sonnet 5 511ee3be4e fix(maestro): show tool icon in attach header instead of raw command
Session header used to print the raw command line ("claude"); mirror
the sidebar's icon-for-program treatment so both agree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:31:00 +01:00
Miguel Palhas a92202302a Merge remote-tracking branch 'origin/main' into largo-lute
ci / web (push) Successful in 53s
ci / flake (push) Failing after 3m19s
ci / rust (push) Successful in 4m1s
2026-08-31 15:30:51 +01:00
Miguel Palhas 44c67fcee7 feat(maestro): move header actions to the sidebar footer
Help, theme toggle, and Spawn now live pinned to the bottom of the
sidebar instead of the top-right app header, keeping them next to the
session list they act on.
2026-08-31 15:30:19 +01:00
Miguel Palhas d8d26d181a Merge remote-tracking branch 'origin/feroce-violin'
ci / web (push) Successful in 1m50s
ci / flake (push) Failing after 2m7s
ci / rust (push) Successful in 4m1s
2026-08-31 15:25:50 +01:00
Miguel PalhasandClaude Sonnet 5 aec7f4cae6 fix(maestro): swap focus rings for background tints on web
ci / web (push) Successful in 41s
ci / flake (push) Failing after 3m8s
ci / rust (push) Failing after 3m10s
Focused sidebar rows and multiview panes drew a green border/ring.
Swap to a background tint on the row and the pane titlebar instead,
so focus stays borderless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:24:56 +01:00
Miguel Palhas 7f3d450f63 Merge remote-tracking branch 'origin/main' into feroce-violin 2026-08-31 15:24:32 +01:00
Miguel PalhasandClaude Sonnet 5 0d088c2581 fix(maestro): remove horizontal margin on sidebar session rows
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 15:24:29 +01:00
Miguel Palhas 1257ec6eba feat(maestro): self-host JetBrains Mono for web terminal
ci / web (push) Successful in 34s
ci / flake (push) Failing after 1m37s
ci / rust (push) Successful in 3m30s
Sets it as the primary --font-mono/xterm font, system stack kept as
fallback.
2026-08-31 15:19:51 +01:00
Miguel Palhas 2dac56b43d fix(maestro): drop the removed colors field from the nav test
ci / web (push) Successful in 1m22s
ci / rust (push) Successful in 4m58s
ci / flake (push) Successful in 7m32s
2026-08-31 15:06:04 +01:00
Miguel Palhas 39e8239a7d Merge remote-tracking branch 'origin/main' into sostenuto-bassoon 2026-08-31 15:04:28 +01:00
Miguel Palhas 1afe079ca0 feat(maestro): keyboard navigation for the web sidebar
Ctrl+Shift+H moves the keyboard between the session list and the pane;
j/k walk the cards, Enter opens, Escape hands the pane back. The walk
skips expanded folder headers and stops on collapsed ones, matching the
TUI.
2026-08-31 15:04:02 +01:00
naps62 5c391f12a6 Merge pull request 'feat(maestro): sidebar cards show diff stats and tool icons' (#127) from tranquillo-nocturne into main
ci / web (push) Successful in 57s
ci / rust (push) Successful in 4m45s
ci / flake (push) Successful in 9m38s
Reviewed-on: #127
2026-08-31 15:02:57 +01:00
Miguel Palhas 8cf9bd0586 Merge remote-tracking branch 'origin/main' into tranquillo-nocturne
ci / web (pull_request) Successful in 46s
ci / rust (pull_request) Successful in 4m16s
ci / flake (pull_request) Successful in 8m45s
2026-08-31 14:58:51 +01:00
Miguel Palhas 0421a153c2 fix(maestro): sidebar j/k skips expanded group headers
Group rows are not something to focus; the cards under them are one row
away. A collapsed group still stops the walk — its header is the only row
it has, so skipping it would leave the group unreachable.
2026-08-31 14:58:07 +01:00
Miguel Palhas 1589d58ca0 feat(maestro): sidebar cards show diff stats and tool icons
Line 2 of a session card spelled out its blocked work ("1 uncommitted
file") and its command ("claude"). Both now render as marks: the
breakdown as diff notation, and the command's leading program as its
tool's icon in the web client.

The probe carries the line delta of the tracked changes — staged and
unstaged together, against HEAD — so `git add` never makes the counter
drop. Untracked files stay a file count; counting their lines means
reading every one off disk. A tracked change with no line delta falls
back to `~N` rather than claiming `+0 -0`.

The TUI keeps its command as text: a terminal cannot be assumed to have
a font carrying tool glyphs.
2026-08-31 14:54:54 +01:00
Miguel Palhas a503ec7c2b refactor(maestro): drop per-session colour and pane glow
ci / web (push) Successful in 49s
ci / rust (push) Successful in 4m47s
ci / flake (push) Successful in 7m31s
The sidebar row dot, the pane top strip and the mobile pill dot all
painted a hashed per-session colour; the focused pane added an outer
accent glow on top of its ring. Removes the colour concept entirely
along with the glow, leaving the flat accent ring as the only focus mark.
2026-08-31 14:51:24 +01:00
Miguel Palhas 904a665432 Merge branch 'crescendo-rondo'
ci / web (push) Successful in 53s
ci / rust (push) Successful in 4m36s
ci / flake (push) Successful in 8m47s
2026-08-31 14:43:44 +01:00
Miguel Palhas 8e1f26ac8d fix(maestro): spawn similar preselects the cloned project
Spawn similar dropped the session's cwd into the free-text directory hatch,
so the project picker showed nothing selected. For a worktree session that
cwd was the daemon-made checkout, and the worktree toggle defaulted on, so
the clone asked for a worktree of a worktree.

It now seeds the picker: the cloned project's box selected, the same
worktree decision, base repo plus '+ new' when the source had a worktree.
A seeded path that matches no project falls back to the raw input showing
that path instead of an empty picker.
2026-08-31 14:39:29 +01:00
Miguel Palhas f0851a62ed feat(maestro): keyboard shortcut overlay on web
ci / web (push) Successful in 37s
ci / rust (push) Successful in 4m13s
ci / flake (push) Successful in 8m48s
`?` toggles a dialog listing every web binding, mirroring the TUI's help
overlay. A `?` button in the header covers pointer-only clients, and the
welcome screen points at the key.

One registry (SHORTCUTS) drives the rendering, so a binding added without
an entry reads as missing. main.ts and the overlay now share one
isTypingTarget guard instead of two copies.
2026-08-31 14:37:53 +01:00
Miguel Palhas 5b2add06fe Merge remote-tracking branch 'origin/main' into refresh
ci / web (push) Successful in 55s
ci / rust (push) Successful in 3m27s
ci / flake (push) Successful in 6m42s
2026-08-31 09:58:18 +01:00
Miguel PalhasandClaude Opus 5 0388f06404 fix(maestro): keep pane background in the attach snapshot
capture-pane trims trailing spaces, so a line's background stopped at its
last glyph and the client's own background showed through the rest as
stripes. -N keeps the padding, and with it the colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 09:43:21 +01:00
Miguel PalhasandClaude Opus 5 c98c11b5c3 fix(maestro): replay tmux's screen to fresh attaches
A new attach replayed the tail of the raw byte buffer. That tail starts
mid-stream, long after a full-screen program set the modes, colours and
cursor position that give those bytes meaning, so a browser refresh
painted garbage over every agent TUI until something forced a repaint —
which is why opening the same session in the TUI (a resize) fixed it.

Ask tmux for the pane's current screen instead, and fall back to the byte
buffer when there is no pane left to capture, as for a dead session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 09:37:13 +01:00
Miguel Palhas d101574842 feat(maestro): close a tab when its last session dies
ci / web (push) Successful in 34s
ci / flake (push) Successful in 6m41s
ci / rust (push) Successful in 3m36s
Killing the only session in a tab left an empty tab behind for the operator to close by hand. The pane's tab now goes with it, unless it is the last tab (which stays, emptied).
2026-08-31 09:16:15 +01:00
Miguel Palhas 8110512d5b Merge remote-tracking branch 'origin/main' into brillante-fugue
ci / web (push) Successful in 32s
ci / rust (push) Successful in 2m59s
ci / flake (push) Successful in 5m49s
2026-08-31 09:03:34 +01:00
Miguel PalhasandClaude Opus 5 d39fbdbbfc feat(maestro): drop retained worktrees and header debug toggle
Removes the retained-worktree store end to end: the daemon's state-dir
store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the
TUI overlay behind `w`, and the web `#/worktrees` view and header link.

The kill guard stays. A forced kill and a dirty natural exit now leave
the worktree on disk untouched instead of parking it in a store, so
removing one is an ordinary `git worktree remove`. A pristine tree is
still removed on exit. DELETE /api/sessions/:id always answers 204.

The header's debug button also goes; Ctrl+Shift+D and `?hud` still
toggle the HUD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 09:00:52 +01:00
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
93 changed files with 7151 additions and 4222 deletions
+4
View File
@@ -7,6 +7,10 @@ on:
- 'linear/**'
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
rust:
runs-on: ubuntu-latest
+4 -3
View File
@@ -11,6 +11,7 @@ persistence, WebSocket for transport.
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── GET/POST /api/sessions list / spawn
├── DELETE /api/sessions/:id kill
├── POST /api/sessions/:id/input type text into a session
├── GET /api/info hostname, version, home dir, spawn presets
├── GET /api/dirs directory listing (spawn autocomplete)
├── GET /api/recent-dirs spawn history
@@ -18,7 +19,7 @@ maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── WS /api/events Snapshot + session deltas
└── WS /api/attach/:id raw PTY bytes
maestro list/spawn/attach/kill CLI client
maestro list/spawn/attach/kill/send CLI client
maestro tui ratatui client
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
```
@@ -63,8 +64,8 @@ nix/ # Home Manager module (`services.maestro`)
See `docs/INSTALL.md` for end-user install instructions.
`docs/WORKTREES.md` is the contract for session worktrees — placement,
collisions, what blocks a kill, what `--force` retains, and what reclaim
touches. Read it before changing anything that creates or removes one.
collisions, what blocks a kill, and what `--force` leaves behind. 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.
+53 -85
View File
@@ -41,8 +41,9 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// List retained worktrees, or reclaim one by ID (#86).
Worktrees(WorktreesArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
Send(SendArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
}
@@ -62,34 +63,6 @@ pub enum HooksCommand {
Install,
}
/// `maestro worktrees <list|reclaim>`
#[derive(Debug, clap::Args)]
pub struct WorktreesArgs {
#[command(subcommand)]
pub command: WorktreesCommand,
}
#[derive(Debug, Subcommand)]
pub enum WorktreesCommand {
/// List retained worktrees and what is in the way of reclaiming each.
List(ListWorktreesArgs),
/// Reclaim one retained worktree: removes the directory, never the branch.
Reclaim(ReclaimArgs),
}
#[derive(Debug, clap::Args)]
pub struct ListWorktreesArgs {
/// Output as JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Args)]
pub struct ReclaimArgs {
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
pub id: Uuid,
}
#[derive(Debug, clap::Args)]
pub struct ListArgs {
/// Output as JSON instead of a table.
@@ -133,13 +106,26 @@ pub struct AttachArgs {
pub detach_key: String,
}
/// `maestro send <session> <text...>`
#[derive(Debug, clap::Args)]
pub struct SendArgs {
/// Session to type into: its ID, a unique ID prefix, or its name.
pub session: String,
/// Text to send. Omit it to read the text from stdin.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub text: Vec<String>,
/// Leave the text sitting in the session's prompt instead of pressing
/// Enter after it.
#[arg(long)]
pub no_enter: bool,
}
#[derive(Debug, clap::Args)]
pub struct KillArgs {
/// Session ID to kill.
pub session_id: Uuid,
/// End the session even when its worktree holds unsaved work (#81). The
/// worktree is retained — never removed — and enters the retained store;
/// the output says where the work went.
/// worktree is left on disk — never removed.
#[arg(long)]
pub force: bool,
}
@@ -276,6 +262,40 @@ mod tests {
}
}
#[test]
fn parses_send_joining_the_message_words() {
let cli =
Cli::try_parse_from(["maestro", "send", "planner", "run", "the", "tests"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert_eq!(a.session, "planner");
assert_eq!(a.text, vec!["run", "the", "tests"]);
assert!(!a.no_enter);
}
/// The message is trailing_var_arg, so a word starting with `-` inside it
/// is text, not a flag clap should reject.
#[test]
fn send_message_may_start_with_a_dash() {
let cli =
Cli::try_parse_from(["maestro", "send", "--no-enter", "planner", "--force"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert!(a.no_enter);
assert_eq!(a.text, vec!["--force"]);
}
#[test]
fn send_text_may_be_omitted_for_stdin() {
let cli = Cli::try_parse_from(["maestro", "send", "planner"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert!(a.text.is_empty());
}
#[test]
fn rejects_env_without_equals() {
let err = Cli::try_parse_from([
@@ -301,63 +321,11 @@ mod tests {
}
_ => panic!("wrong subcommand"),
}
// No subcommand → help, like `worktrees`.
// No subcommand → help.
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();
match cli.command {
Command::Worktrees(w) => match w.command {
WorktreesCommand::List(a) => assert!(a.json),
_ => panic!("wrong worktrees subcommand"),
},
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_worktrees_reclaim() {
let id = "01234567-89ab-cdef-0123-456789abcdef";
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
match cli.command {
Command::Worktrees(w) => match w.command {
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
_ => panic!("wrong worktrees subcommand"),
},
_ => panic!("wrong subcommand"),
}
}
/// Contract rule: reclaim refuses to run without an explicit target.
/// There is no reclaim-everything convenience.
#[test]
fn reclaim_requires_an_explicit_target() {
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
assert!(
matches!(
err.kind(),
clap::error::ErrorKind::InvalidSubcommand
| clap::error::ErrorKind::MissingRequiredArgument
),
"{err}"
);
}
#[test]
fn worktrees_without_subcommand_shows_help() {
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
assert!(
matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
),
"{err}"
);
}
}
+12 -32
View File
@@ -9,9 +9,8 @@ use std::path::{Path, PathBuf};
use maestro_protocol::DaemonClient;
use crate::attach::{parse_detach_key, run_attach_url};
use crate::cli::{Command, WorktreesCommand};
use crate::sessions::{run_kill, run_list, run_spawn};
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
use crate::cli::Command;
use crate::sessions::{run_kill, run_list, run_send, run_spawn};
use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)]
@@ -25,6 +24,10 @@ pub enum CliError {
Attach(#[from] crate::attach::AttachError),
#[error("{0}")]
Hooks(#[from] maestro_hooks::HooksError),
#[error("{0}")]
Resolve(#[from] crate::sessions::ResolveError),
#[error("nothing to send: pass the text as arguments or pipe it on stdin")]
NothingToSend,
#[error("could not determine the home directory ($HOME unset?)")]
NoHome,
#[error("{0}")]
@@ -80,37 +83,14 @@ pub async fn dispatch(
if !args.force {
warn_if_blocked(&daemon, args.session_id).await;
}
let outcome = run_kill(&daemon, &args).await?;
run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id);
if let Some(w) = outcome.retained {
println!(
"worktree retained at {} (branch {}, force-killed)",
w.path.display(),
w.branch
);
}
}
Command::Send(args) => {
let id = run_send(&daemon, &args, &mut std::io::stdin()).await?;
println!("sent to {id}");
}
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();
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
}
WorktreesCommand::Reclaim(reclaim_args) => {
// The daemon removes the directory and the store entry; the
// branch is never deleted — worth saying out loud (#86).
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
match entry {
Some(wt) => println!(
"reclaimed {} ({})\nbranch '{}' kept",
wt.id,
wt.path.display(),
wt.branch
),
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
}
}
},
}
Ok(())
}
@@ -150,7 +130,7 @@ async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
{
if !b.is_clear() {
eprintln!(
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to end it anyway"
);
}
}
-1
View File
@@ -6,6 +6,5 @@ pub mod attach;
pub mod cli;
pub mod dispatch;
pub mod sessions;
pub mod worktrees;
pub use dispatch::{dispatch, CliError};
+148 -7
View File
@@ -5,12 +5,13 @@ use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
SessionStatus, WorktreeSpec,
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, WorktreeSpec,
};
use serde_json::Value;
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs};
use crate::dispatch::CliError;
/// `maestro list`
pub async fn run_list(
@@ -62,13 +63,96 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where
/// the work went.
pub async fn run_kill(
client: &DaemonClient,
args: &KillArgs,
) -> Result<maestro_protocol::KillOutcome, ClientError> {
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
client.kill_session(args.session_id, args.force).await
}
/// Nothing on the daemon answers to what the operator named.
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("no session matches {0:?} — run `maestro list` to see what is running")]
NoMatch(String),
#[error("{0:?} matches {1} sessions; use a session id instead")]
Ambiguous(String, usize),
}
/// Turn what the operator typed into a session id: a full ID, a unique ID
/// prefix, or a session's name (#125), matched case-insensitively. Names come
/// first — an operator who named a session after a hex string meant the name.
pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid, ResolveError> {
let needle = needle.trim();
let by_name: Vec<&Session> = sessions
.iter()
.filter(|s| {
maestro_protocol::display::custom_name(s)
.is_some_and(|n| n.eq_ignore_ascii_case(needle))
})
.collect();
match by_name.len() {
1 => return Ok(by_name[0].id),
0 => {}
n => return Err(ResolveError::Ambiguous(needle.to_string(), n)),
}
let by_id: Vec<&Session> = sessions
.iter()
.filter(|s| {
s.id.to_string()
.to_ascii_lowercase()
.starts_with(&needle.to_ascii_lowercase())
})
.collect();
match by_id.len() {
1 => Ok(by_id[0].id),
0 => Err(ResolveError::NoMatch(needle.to_string())),
n => Err(ResolveError::Ambiguous(needle.to_string(), n)),
}
}
/// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text.
///
/// A bare ID skips the catalog fetch; anything else needs the list to match
/// against. Text comes from the arguments, or from stdin when there is none —
/// the shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)]
pub async fn run_send(
client: &DaemonClient,
args: &SendArgs,
stdin: &mut impl std::io::Read,
) -> Result<uuid::Uuid, CliError> {
let text = if args.text.is_empty() {
let mut buf = String::new();
stdin.read_to_string(&mut buf)?;
// One trailing newline is how a pipe ends, not part of the message —
// keeping it would submit the text twice.
buf.strip_suffix('\n')
.map(|t| t.strip_suffix('\r').unwrap_or(t))
.unwrap_or(&buf)
.to_string()
} else {
args.text.join(" ")
};
if text.is_empty() {
return Err(CliError::NothingToSend);
}
let id = match uuid::Uuid::parse_str(args.session.trim()) {
Ok(id) => id,
Err(_) => {
let sessions = client.list_sessions().await?;
resolve_session(&sessions, &args.session)?
}
};
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
Ok(id)
}
#[allow(clippy::write_literal)]
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
if sessions.is_empty() {
@@ -221,6 +305,63 @@ mod tests {
assert!(out.contains("bash [claude]"), "{out}");
}
fn named(id: u128, name: &str) -> Session {
let mut s = session("/tmp", "claude", id);
s.metadata
.insert("name".into(), serde_json::Value::String(name.into()));
s
}
#[test]
fn resolves_by_name_id_prefix_and_reports_what_it_cannot() {
use super::{resolve_session, ResolveError};
// Ids that differ in their first characters, so a short prefix is
// enough to pick one — the ergonomics the prefix match exists for.
let (a, b) = (1u128 << 124, 2u128 << 124);
let sessions = vec![named(a, "planner"), named(b, "builder")];
let one = Uuid::from_u128(a);
assert_eq!(resolve_session(&sessions, "planner").unwrap(), one);
// Names match case-insensitively and ignore surrounding space.
assert_eq!(resolve_session(&sessions, " Planner ").unwrap(), one);
// An id prefix works when it picks exactly one session.
let prefix = &one.to_string()[..8];
assert_eq!(resolve_session(&sessions, prefix).unwrap(), one);
assert!(matches!(
resolve_session(&sessions, "nope"),
Err(ResolveError::NoMatch(_))
));
}
/// Two sessions sharing a name is legal — the daemon never enforced
/// uniqueness — so `send` must refuse rather than pick one.
#[test]
fn an_ambiguous_name_is_refused() {
use super::{resolve_session, ResolveError};
let sessions = vec![named(1 << 124, "worker"), named(2 << 124, "worker")];
assert!(matches!(
resolve_session(&sessions, "worker"),
Err(ResolveError::Ambiguous(_, 2))
));
}
/// A session named after its own id prefix means the name: an operator
/// who typed a name they chose should not be routed by hex coincidence.
#[test]
fn names_win_over_id_prefixes() {
use super::resolve_session;
let two = Uuid::from_u128(2 << 124);
let prefix = two.to_string()[..8].to_string();
let sessions = vec![named(1 << 124, &prefix), session("/tmp", "bash", 2 << 124)];
assert_eq!(
resolve_session(&sessions, &prefix).unwrap(),
Uuid::from_u128(1 << 124)
);
}
#[test]
fn empty_sessions() {
let mut buf = Vec::new();
-204
View File
@@ -1,204 +0,0 @@
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
//!
//! `reclaim` removes one directory; it never deletes the branch, which is
//! said out loud in the output because "reclaim" otherwise sounds like it
//! takes the commits too.
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
/// `maestro worktrees list`
pub async fn run_list(
client: &DaemonClient,
args: &ListWorktreesArgs,
out: &mut impl Write,
) -> Result<(), ClientError> {
let worktrees = client.list_worktrees().await?;
let info = client.daemon_info().await.unwrap_or_default();
if args.json {
let body = serde_json::json!({ "worktrees": worktrees });
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
return Ok(());
}
print_worktree_table(out, &worktrees, &info);
Ok(())
}
/// `maestro worktrees reclaim <id>`
///
/// Returns the entry as listed just before the reclaim, when it was found in
/// the listing, so the confirmation can name what was removed.
pub async fn run_reclaim(
client: &DaemonClient,
args: &ReclaimArgs,
) -> Result<Option<RetainedWorktree>, ClientError> {
let listed = client
.list_worktrees()
.await?
.into_iter()
.find(|w| w.id == args.id);
client.reclaim_worktree(args.id).await?;
Ok(listed)
}
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
if worktrees.is_empty() {
writeln!(out, "no retained worktrees").ok();
return;
}
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
sorted.sort_by_key(|w| w.retained_at);
if !info.hostname.is_empty() {
let warn = if maestro_protocol::is_compatible(&info.version) {
""
} else {
" (protocol mismatch)"
};
let version = if info.version.is_empty() {
String::new()
} else {
format!(" maestro {}{warn}", info.version)
};
writeln!(out, "\n{}{version}", info.hostname).ok();
}
writeln!(
out,
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
"ID", "BRANCH", "PATH", "REASON"
)
.ok();
for w in sorted {
writeln!(
out,
" {:<36} {:<24} {:<28} {:<24} {}",
w.id,
w.branch,
abbreviate_dir(&w.path, &info.home_dir),
reason_label(w.reason),
blocker_label(&w.in_the_way),
)
.ok();
}
// The branch survives every reclaim — see docs/WORKTREES.md.
writeln!(
out,
"\nreclaim removes the directory only; branches are never deleted"
)
.ok();
}
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
match reason {
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
}
}
/// The same breakdown that blocks an unforced kill, as words: what is still
/// in the way, or `clear` when the work has since been committed and pushed.
fn blocker_label(in_the_way: &InTheWay) -> String {
if in_the_way.is_clear() {
return "clear".into();
}
let mut parts: Vec<String> = Vec::new();
if in_the_way.uncommitted_files > 0 {
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
}
if in_the_way.untracked_files > 0 {
parts.push(format!("{} untracked", in_the_way.untracked_files));
}
if in_the_way.unpushed_commits > 0 {
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
use uuid::Uuid;
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
RetainedWorktree {
id: Uuid::from_u128(id),
path: PathBuf::from(path),
base_repo: PathBuf::from("/home/naps/maestro"),
branch: branch.into(),
reason: RetainedReason::ForceKilled,
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: InTheWay::default(),
}
}
fn info(home: &str) -> DaemonInfo {
DaemonInfo {
hostname: "naps-laptop".into(),
home_dir: home.into(),
..Default::default()
}
}
#[test]
fn formats_table() {
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
w.in_the_way = InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
};
let mut buf = Vec::new();
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("naps-laptop"), "{s}");
assert!(s.contains("feat/login"), "{s}");
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
assert!(s.contains("force-killed"), "{s}");
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
assert!(s.contains("IN THE WAY"), "{s}");
}
/// Contract rule, visible to the operator: reclaiming a tree must not
/// throw away commits on its branch.
#[test]
fn says_the_branch_survives() {
let mut buf = Vec::new();
super::print_worktree_table(
&mut buf,
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
&info(""),
);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("never deleted"), "{s}");
}
#[test]
fn clear_tree_says_clear() {
let mut buf = Vec::new();
super::print_worktree_table(
&mut buf,
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
&info(""),
);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("clear"), "{s}");
}
#[test]
fn empty_store() {
let mut buf = Vec::new();
super::print_worktree_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
}
}
@@ -108,3 +108,110 @@ async fn spawn_round_trips_through_the_daemon() {
.expect("kill");
assert_eq!(mock.session_count(), 0);
}
#[tokio::test]
async fn send_types_text_and_submits_by_default() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(7);
mock.add_session(session(id, "/home/mock/work", "claude"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["run".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
let inputs = mock.inputs();
assert_eq!(inputs.len(), 1);
assert_eq!(inputs[0].0, id);
assert_eq!(inputs[0].1.text, "run the tests");
assert!(inputs[0].1.submit, "Enter is the default");
}
/// `--no-enter` leaves the text in the prompt; the flag has to survive the
/// whole way to the daemon or the message is sent anyway.
#[tokio::test]
async fn send_without_enter_leaves_the_text_unsubmitted() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(8);
mock.add_session(session(id, "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["ls".into()],
no_enter: true,
};
maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert!(!mock.inputs()[0].1.submit);
}
/// No text arguments → the message comes from stdin, minus the trailing
/// newline a pipe or heredoc adds.
#[tokio::test]
async fn send_reads_stdin_and_drops_its_trailing_newline() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(9);
mock.add_session(session(id, "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: Vec::new(),
no_enter: false,
};
let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec());
maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin)
.await
.expect("send");
assert_eq!(mock.inputs()[0].1.text, "multi\nline");
}
/// A session named through `PATCH /api/sessions/:id` is addressable by that
/// name, so scripts don't have to carry a UUID around.
#[tokio::test]
async fn send_resolves_a_session_by_name() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(10);
let mut named = session(id, "/tmp", "claude");
named
.metadata
.insert("name".into(), serde_json::Value::String("planner".into()));
mock.add_session(named);
mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: "Planner".into(),
text: vec!["go".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
}
#[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: "nope".into(),
text: vec!["hi".into()],
no_enter: false,
};
let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect_err("should not resolve");
assert!(err.to_string().contains("no session matches"), "{err}");
assert!(mock.inputs().is_empty());
}
+100 -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";
@@ -146,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>,
@@ -167,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
@@ -596,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 -8
View File
@@ -70,9 +70,6 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
Some(p) => Tmux::new().with_tmpdir(p),
None => Tmux::new(),
};
let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new(
config.retained_worktree_dir(),
));
let manager = SessionManager::new_with_replay(
std::sync::Arc::new(tmux),
sidecar,
@@ -83,10 +80,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
config.spawn_history_path(),
config.spawn_history_days,
))
// 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));
@@ -108,7 +102,6 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.home_dir.clone(),
config.spawn_presets.clone(),
projects,
retained,
));
let listener = TcpListener::bind(config.bind_addr).await?;
-5
View File
@@ -107,11 +107,6 @@ impl DaemonConfig {
pub fn spawn_history_path(&self) -> PathBuf {
self.state_dir.join("spawn-history.jsonl")
}
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
pub fn retained_worktree_dir(&self) -> PathBuf {
self.state_dir.join("retained-worktrees")
}
}
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
-1
View File
@@ -20,7 +20,6 @@ pub mod panes;
pub mod proc;
pub mod projects;
pub mod pty;
pub mod retained_store;
pub mod session;
pub mod sidecar;
pub mod spawn_history;
+50 -121
View File
@@ -20,7 +20,7 @@ use axum::{Json, Router};
use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
ReportActivityRequest, Session, PROTOCOL_VERSION,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
@@ -38,24 +38,23 @@ pub struct LocalApiState {
pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>,
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
/// reclaim endpoint (#80).
pub retained: Arc<crate::retained_store::RetainedStore>,
}
pub fn router(state: LocalApiState) -> Router {
Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.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))
.route(routes::GITROOT, get(git_root))
.route(routes::BRANCHES, get(crate::branches::handler))
.route(routes::NAMES, get(names))
.route(routes::WORKTREES, get(list_worktrees))
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
.route(routes::INFO, get(daemon_info))
.route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws))
@@ -102,8 +101,9 @@ fn session_error_status(code: &str) -> StatusCode {
"invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The retention record failed before anything was torn down (#81).
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
_ => StatusCode::BAD_GATEWAY,
}
}
@@ -183,9 +183,8 @@ async fn create_session(
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
///
/// With `force=true` (#81) the refusal is overridden and the worktree
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
/// Without it the historical `204` stands.
/// With `force=true` (#81) the refusal is overridden and the worktree is left
/// on disk rather than removed. Either way the answer is `204`.
#[derive(Debug, Deserialize)]
struct KillQuery {
#[serde(default)]
@@ -198,13 +197,7 @@ async fn kill_session(
Query(q): Query<KillQuery>,
) -> Response {
match s.manager.kill(id, q.force).await {
Ok(outcome) => {
if q.force {
(StatusCode::OK, Json(outcome)).into_response()
} else {
StatusCode::NO_CONTENT.into_response()
}
}
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
let status = session_error_status(&info.code);
@@ -213,109 +206,25 @@ async fn kill_session(
}
}
/// `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
/// removed by hand are pruned on the way through.
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
let retained = s.retained.clone();
let listed = tokio::task::spawn_blocking(move || {
retained.prune_missing()?;
retained
.load_all()?
.iter()
.map(|entry| {
// A tree git cannot read (permissions, corrupt checkout)
// still gets listed; it just reports nothing in the way.
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
Ok(maestro_protocol::RetainedWorktree {
id: entry.id,
path: entry.path.clone(),
base_repo: entry.base_repo.clone(),
branch: entry.branch.clone(),
reason: entry.reason,
retained_at: entry.retained_at,
in_the_way,
})
})
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
})
.await;
match listed {
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
Ok(Err(e)) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"store_failed",
&e.to_string(),
),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
),
}
}
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
/// the directory and drops the store entry, without consulting the guard —
/// reclaim is the deliberate destructive act, chosen while looking at what is
/// in the way. The branch itself is never deleted; commits on it survive.
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
let found = {
let retained = s.retained.clone();
tokio::task::spawn_blocking(move || -> Result<
Option<crate::retained_store::RetainedWorktreeRecord>,
crate::retained_store::RetainedStoreError,
> {
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
})
.await
};
let entry = match found {
Ok(Ok(Some(entry))) => entry,
Ok(Ok(None)) => {
return error_response(
StatusCode::NOT_FOUND,
"not_found",
"no such retained worktree",
)
}
Ok(Err(e)) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"store_failed",
&e.to_string(),
)
}
/// `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) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
}
};
let outcome = {
let retained = s.retained.clone();
tokio::task::spawn_blocking(move || -> Result<(), String> {
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
.map_err(|e| e.to_string())?;
retained.remove(id).map_err(|e| e.to_string())?;
Ok(())
})
.await
};
match outcome {
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
// The directory could not be removed: leave the entry so the
// operator still sees the tree and can retry.
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
),
}
}
@@ -342,6 +251,28 @@ async fn report_activity(
}
}
/// `POST /api/sessions/:id/input`: type text into a session's pane, then
/// press Enter unless the request turns `submit` off. Answers `204 No
/// Content`. A session that has exited is refused (409) rather than accepted
/// into nowhere.
async fn send_input(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<SendInputRequest>,
) -> Response {
match s.manager.send_input(id, &body.text, body.submit).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()
}
@@ -357,7 +288,6 @@ pub fn state_for(
home_dir: impl Into<String>,
presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>,
retained: Arc<crate::retained_store::RetainedStore>,
) -> LocalApiState {
LocalApiState {
manager,
@@ -365,7 +295,6 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
projects,
retained,
}
}
+23
View File
@@ -39,6 +39,10 @@ pub trait Panes: std::fmt::Debug + Send + Sync + 'static {
fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError>;
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError>;
/// Escape-sequence snapshot of the pane's current screen, or `None` when
/// there is no pane to capture. See [`Tmux::capture_pane`].
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError>;
}
impl Panes for Tmux {
@@ -76,6 +80,10 @@ impl Panes for Tmux {
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
Tmux::resize_window(self, id, cols, rows)
}
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
Tmux::capture_pane(self, id)
}
}
// ---------------------------------------------------------------------------
@@ -92,6 +100,7 @@ pub enum Op {
SendKeys,
PipePane,
ResizeWindow,
CapturePane,
}
/// One recorded call, in the order the manager made it. Rollback ordering is a
@@ -116,6 +125,7 @@ pub enum Call {
cols: u16,
rows: u16,
},
CapturePane(Uuid),
}
/// In-memory [`Panes`] for tests.
@@ -133,6 +143,7 @@ struct FakeState {
live: HashSet<Uuid>,
panes: HashMap<Uuid, PaneInfo>,
calls: Vec<Call>,
captures: HashMap<Uuid, Vec<u8>>,
fail: HashMap<Op, String>,
}
@@ -162,6 +173,12 @@ impl FakePanes {
self.lock().live.contains(&id)
}
/// Screen snapshot `capture_pane` hands back for `id`. Unset means "no
/// pane", which is what a dead session looks like.
pub fn set_capture(&self, id: Uuid, bytes: impl Into<Vec<u8>>) {
self.lock().captures.insert(id, bytes.into());
}
pub fn set_pane_info(&self, id: Uuid, info: PaneInfo) {
self.lock().panes.insert(id, info);
}
@@ -242,6 +259,12 @@ impl Panes for FakePanes {
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
self.record(Call::ResizeWindow { id, cols, rows }, Op::ResizeWindow)
}
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
self.record(Call::CapturePane(id), Op::CapturePane)?;
let st = self.lock();
Ok(st.captures.get(&id).cloned())
}
}
#[cfg(test)]
+14 -1
View File
@@ -141,8 +141,21 @@ impl PtyBridge {
/// `subscribe()` keeps the reader task from publishing in between, so
/// the seam has no gap and no duplicates.
pub fn subscribe_with_replay(&self) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>) {
self.subscribe_with_snapshot(|| None)
}
/// As [`Self::subscribe_with_replay`], but lets the caller supply the
/// replay payload instead of the raw byte buffer — used to hand a fresh
/// client tmux's own view of the screen, which the byte tail cannot
/// reconstruct for a full-screen program. `capture` runs while the reader
/// is held off, so the payload and the live stream still meet with no gap
/// and no duplicate. Returning `None` falls back to the byte buffer.
pub fn subscribe_with_snapshot<F>(&self, capture: F) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>)
where
F: FnOnce() -> Option<Vec<u8>>,
{
let guard = self.replay.lock_for_snapshot();
let snapshot = guard.snapshot();
let snapshot = capture().unwrap_or_else(|| guard.snapshot());
let rx = match &self.sender {
Some(tx) => tx.subscribe(),
// Sealed: hand back the scrollback plus a receiver that is already
-310
View File
@@ -1,310 +0,0 @@
//! Store of retained worktrees (#78): worktrees the daemon created whose
//! session is gone.
//!
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
//! missing or malformed files tolerated on read. The store survives a daemon
//! restart; it is the recovery path for everything else in the worktree
//! lifecycle.
//!
//! It is not an ownership record: it must never be consulted to decide whether
//! a path may be adopted. Its only job is to remember what the daemon is
//! holding so it can be listed and reclaimed.
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
/// so the store and the wire payload cannot drift apart.
pub use maestro_protocol::RetainedReason;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum RetainedStoreError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RetainedWorktreeRecord {
pub id: Uuid,
pub path: PathBuf,
pub base_repo: PathBuf,
pub branch: String,
pub reason: RetainedReason,
pub retained_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct RetainedStore {
dir: PathBuf,
}
impl RetainedStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.json"))
}
/// Record a retained worktree and return the entry that was written.
pub fn retain(
&self,
path: &Path,
base_repo: &Path,
branch: &str,
reason: RetainedReason,
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
let entry = RetainedWorktreeRecord {
id: Uuid::new_v4(),
path: path.to_path_buf(),
base_repo: base_repo.to_path_buf(),
branch: branch.to_string(),
reason,
retained_at: Utc::now(),
};
self.overwrite(&entry)?;
Ok(entry)
}
/// Atomically write `entry`. Creates the directory if it does not exist.
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
std::fs::create_dir_all(&self.dir)?;
let final_path = self.path_for(entry.id);
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
let json = serde_json::to_vec_pretty(entry)?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &final_path)?;
Ok(())
}
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
/// store already lost (e.g. pruned) must not fail.
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Read every entry. Files that are missing or malformed are skipped
/// silently: a corrupt record must not hide the others, and an empty or
/// absent directory simply means nothing is retained.
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(&self.dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
out.push(worktree);
}
}
out.sort_by_key(|w| w.retained_at);
Ok(out)
}
/// Drop entries whose worktree directory no longer exists — somebody
/// removed it by hand. Returns the ids that were pruned.
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
let mut pruned = Vec::new();
for entry in self.load_all()? {
if !entry.path.exists() {
self.remove(entry.id)?;
pruned.push(entry.id);
}
}
Ok(pruned)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use std::path::PathBuf;
fn tmp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
let p = root.join(name);
std::fs::create_dir_all(&p).expect("mkdir");
p
}
#[test]
fn retain_then_load_round_trips_every_field() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
let repo = sample_path(dir.path(), "repo");
let entry = store
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
.expect("retain");
let all = store.load_all().expect("load_all");
assert_eq!(all, vec![entry.clone()]);
assert_eq!(entry.path, wt);
assert_eq!(entry.base_repo, repo);
assert_eq!(entry.branch, "feature/login");
assert_eq!(entry.reason, RetainedReason::ForceKilled);
let age = Utc::now() - entry.retained_at;
assert!(age >= Duration::zero() && age < Duration::minutes(1));
}
#[test]
fn reason_serializes_kebab_case() {
let json =
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
assert_eq!(json, r#""exited-with-unsaved-work""#);
}
#[test]
fn entries_survive_a_restart() {
let dir = tmp_dir();
let wt = sample_path(dir.path(), "wt");
let first = RetainedStore::new(dir.path());
let entry = first
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
.expect("retain");
// A fresh store over the same state_dir — what a daemon restart sees.
let second = RetainedStore::new(dir.path());
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
}
#[test]
fn missing_store_reads_as_empty() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path().join("does-not-exist"));
assert_eq!(store.load_all().expect("load_all"), Vec::new());
}
#[test]
fn malformed_entries_are_skipped_silently() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
store
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
.expect("retain");
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
let all = store.load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].branch, "kept");
}
#[test]
fn ids_are_unique() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let a = store
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
.expect("retain");
let b = store
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
.expect("retain");
assert_ne!(a.id, b.id);
}
#[test]
fn remove_drops_the_entry() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
let entry = store
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
.expect("retain");
store.remove(entry.id).expect("remove");
assert_eq!(store.load_all().expect("load_all"), Vec::new());
// Removing an unknown id is a no-op, not an error.
store.remove(entry.id).expect("remove again");
}
#[test]
fn load_all_is_sorted_by_time() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let mut first = store
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
.expect("retain");
first.retained_at -= Duration::hours(1);
store.overwrite(&first).expect("rewrite");
let second = store
.retain(
dir.path(),
dir.path(),
"second",
RetainedReason::ForceKilled,
)
.expect("retain");
let branches: Vec<_> = store
.load_all()
.expect("load_all")
.into_iter()
.map(|e| e.branch)
.collect();
assert_eq!(branches, vec!["first", "second"]);
let _ = second;
}
#[test]
fn prune_missing_drops_gone_directories_only() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let alive = sample_path(dir.path(), "alive");
let gone = sample_path(dir.path(), "gone");
let keep = store
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
.expect("retain");
let drop = store
.retain(
&gone,
dir.path(),
"drop",
RetainedReason::ExitedWithUnsavedWork,
)
.expect("retain");
std::fs::remove_dir_all(&gone).expect("rm");
let pruned = store.prune_missing().expect("prune_missing");
assert_eq!(pruned, vec![drop.id]);
let branches: Vec<_> = store
.load_all()
.expect("load_all")
.into_iter()
.map(|e| e.branch)
.collect();
assert_eq!(branches, vec!["keep"]);
let _ = keep;
}
}
+137 -147
View File
@@ -12,8 +12,8 @@ use std::time::Duration;
use chrono::Utc;
use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason,
RetainedWorktree, Session, SessionStatus, WorktreeSpec,
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
WorktreeSpec,
};
use tokio::sync::{broadcast, Mutex};
use uuid::Uuid;
@@ -40,6 +40,15 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
/// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// Gap between the text of a `send` and the Enter that submits it.
///
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
/// burst of input into one paste; a newline inside that burst becomes a line
/// break in the message instead of sending it. Landing the Enter as its own
/// write, after the reader has drained the text, is what makes the message
/// actually go.
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
/// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)]
pub struct SessionRecord {
@@ -51,11 +60,6 @@ pub struct SessionRecord {
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>,
/// Branch the worktree was created for, recorded at spawn time so a
/// forced kill (#81) can name it in the retained store without asking
/// git. Sessions adopted from a sidecar have no branch on record and
/// 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
@@ -83,16 +87,16 @@ pub enum SessionError {
NotFound(Uuid),
#[error("invalid environment variable name: {0:?}")]
InvalidEnv(String),
/// The session exists but its pane is gone, so there is nothing to type
/// into. Writing to a dead session is a client bug worth reporting, unlike
/// a stray keystroke on an attach socket, which is dropped.
#[error("session is no longer running: {0}")]
NotRunning(Uuid),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
KillGuarded(maestro_protocol::InTheWay),
/// A forced kill (#81) records the retained worktree *before* tearing
/// anything down; if that write fails nothing was destroyed, and the
/// error says so rather than silently dropping unrecorded work.
#[error("could not record the retained worktree: {0}")]
RetainFailed(String),
}
impl SessionError {
@@ -104,8 +108,8 @@ impl SessionError {
SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env",
SessionError::NotRunning(_) => "not_running",
SessionError::KillGuarded(_) => "kill_guarded",
SessionError::RetainFailed(_) => "retain_failed",
};
let unsaved_work = match self {
SessionError::KillGuarded(b) => Some(*b),
@@ -138,9 +142,6 @@ struct Inner {
/// `None` when not wired (tests, or before `with_spawn_history`); a present
/// but disabled (`0`-day) history no-ops internally.
spawn_history: Option<SpawnHistory>,
/// 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,
@@ -187,7 +188,6 @@ impl SessionManager {
fifo_dir,
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()),
@@ -212,16 +212,6 @@ impl SessionManager {
self
}
/// Attach the retained-worktree store (#78). A forced kill (#81) needs it
/// to record what it retained; without one, forcing a guarded kill fails
/// instead of destroying unrecorded work.
pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_retained_store must be called before the SessionManager is cloned");
inner.retained = Some(store);
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 {
@@ -285,8 +275,29 @@ impl SessionManager {
&self,
id: Uuid,
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
let tmux = self.inner.tmux.clone();
let sessions = self.inner.sessions.lock().await;
sessions.get(&id).map(|r| r.bridge.subscribe_with_replay())
let rec = sessions.get(&id)?;
// For a live session, tmux's own screen beats the byte tail: the tail
// starts mid-stream, so a full-screen program (an agent TUI) replayed
// from it renders as garbage until something forces a repaint. A dead
// session has no pane left to capture, so it keeps the byte tail.
let alive = rec.session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held, which
// parks the reader for the length of one tmux round-trip. That only
// happens on attach, and the alternative is a seam in the stream.
Some(rec.bridge.subscribe_with_snapshot(|| {
if !alive {
return None;
}
match tmux.capture_pane(id) {
Ok(snapshot) => snapshot,
Err(e) => {
tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes");
None
}
}
}))
}
pub async fn has(&self, id: Uuid) -> bool {
@@ -442,7 +453,6 @@ impl SessionManager {
fifo_path,
worktree_path,
worktree_base_repo,
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
bridge,
reported_activity: None,
settled: false,
@@ -494,19 +504,19 @@ impl SessionManager {
/// is removed; the error names what blocked it and how much of it there
/// is. The session stays as the operator's handle on their own unfinished
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which retains rather than destroys (WORKTREES.md).
/// `--force`, which leaves the tree alone rather than destroying it
/// (WORKTREES.md).
///
/// With `force` (#81) the refusal — and nothing else — is overridden:
/// the session ends and the guarded worktree is retained into the store
/// with reason `force-killed`, never removed. The outcome names what was
/// kept so a client can say where the work went. A tree whose guard is
/// clear never needed the override; force removes it as usual.
/// the session ends and the guarded worktree is left on disk, never
/// removed. A tree whose guard is clear never needed the override; force
/// removes it as usual.
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
/// session tmux already lost fails, and that failure is not interesting.
pub async fn kill(&self, id: Uuid, force: bool) -> Result<KillOutcome, SessionError> {
let (worktree, alive, known_branch) = {
pub async fn kill(&self, id: Uuid, force: bool) -> Result<(), SessionError> {
let (worktree, alive) = {
let sessions = self.inner.sessions.lock().await;
match sessions.get(&id) {
None => return Err(SessionError::NotFound(id)),
@@ -516,7 +526,6 @@ impl SessionManager {
.clone()
.zip(record.worktree_path.clone()),
record.session.status == SessionStatus::Running,
record.worktree_branch.clone(),
),
}
};
@@ -529,35 +538,13 @@ impl SessionManager {
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
}
let mut retained = None;
if guarded {
let entry = record_retention(
&self.inner,
worktree.as_ref().expect("guarded ⇒ worktree"),
known_branch,
RetainedReason::ForceKilled,
)
.await?;
retained = Some((entry, probe.expect("guarded ⇒ probed")));
}
let removed = {
let mut sessions = self.inner.sessions.lock().await;
sessions.remove(&id)
};
let record = removed.ok_or(SessionError::NotFound(id))?;
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await;
Ok(KillOutcome {
retained: retained.map(|(entry, in_the_way)| RetainedWorktree {
id: entry.id,
path: entry.path,
base_repo: entry.base_repo,
branch: entry.branch,
reason: entry.reason,
retained_at: entry.retained_at,
in_the_way,
}),
})
cleanup_record(&self.inner, id, record, alive, guarded).await;
Ok(())
}
/// Spawn a background task that polls tmux every `interval` and reaps any
@@ -669,9 +656,6 @@ impl SessionManager {
// directory blocks that branch forever (#54 review).
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
// The sidecar carries no branch; a forced kill of a
// recovered session probes git for it at retain time.
worktree_branch: None,
bridge,
reported_activity: None,
settled: false,
@@ -702,6 +686,33 @@ impl SessionManager {
.map_err(SessionError::Tmux)
}
/// Type `text` into a session's pane, with an Enter after it when
/// `submit` is set.
///
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
/// asked for text to land somewhere, and a silent success would let a
/// script believe an agent had been told something it never heard.
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
match self.status(id).await {
None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
Some(SessionStatus::Running) => {}
}
let tmux = self.inner.tmux.clone();
let bytes = text.as_bytes().to_vec();
tmux_blocking(move || tmux.send_keys(id, &bytes))
.await
.map_err(SessionError::Tmux)?;
if submit {
tokio::time::sleep(SUBMIT_DELAY).await;
let tmux = self.inner.tmux.clone();
tmux_blocking(move || tmux.send_keys(id, b"\r"))
.await
.map_err(SessionError::Tmux)?;
}
Ok(())
}
/// Resize a session's window. No-op for a dead session, as with
/// [`Self::send_keys`].
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
@@ -716,6 +727,38 @@ impl SessionManager {
.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
@@ -808,32 +851,6 @@ async fn probe_worktree(
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
}
/// Record a guarded worktree into the retained store with `reason`, BEFORE
/// the caller tears anything down: a failed write then leaves every artifact
/// untouched instead of destroying work nothing records anymore. Shared by
/// the forced kill (`ForceKilled`, #81) and the natural exit
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
async fn record_retention(
inner: &Arc<Inner>,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
reason: RetainedReason,
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
let store = inner
.retained
.clone()
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
let branch = known_branch
.or_else(|| crate::worktree_probe::current_branch(path))
.unwrap_or_default();
let base_repo = base_repo.clone();
let path = path.clone();
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
.await
.map_err(|e| SessionError::RetainFailed(e.to_string()))
}
/// One pass of the liveness watcher: diff the in-memory session set against
/// the tmux-reported one, and reap anything we still track but tmux has lost.
async fn run_liveness_pass(inner: &Arc<Inner>) {
@@ -1037,13 +1054,11 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
/// still attach and read the replay buffer — the last thing a crashed agent
/// printed. The worktree, though, is settled right away: a pristine tree is
/// removed, anything else is retained into the store with reason
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
/// so there is nothing to refuse. Once settled, the session drops its
/// worktree reference so a later dismissal neither re-probes nor retains
/// twice. When settling is impossible (probe or store failure) nothing is
/// destroyed: the tree stays attached to the dead record for a later forced
/// dismissal (#81).
/// removed (#82) — nobody is waiting for an answer here, so there is nothing
/// to refuse. Once settled, the session drops its worktree reference so a
/// later dismissal does not re-probe it. A tree holding unsaved work is left
/// alone and stays attached to the dead record for a later forced dismissal
/// (#81), which leaves it on disk too.
///
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
/// adopts only what tmux still holds.
@@ -1061,21 +1076,20 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
let branch = record.worktree_branch.clone();
Some((session, worktree, branch))
Some((session, worktree))
}
_ => None,
}
};
let Some((mut session, worktree, branch)) = flipped else {
let Some((mut session, worktree)) = flipped else {
return;
};
tracing::info!(%id, "session vanished from tmux — marking dead");
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt, branch).await {
// The tree is settled (removed or retained); a stale "in the
// way" would describe something the session no longer holds.
if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed); 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) {
@@ -1083,7 +1097,6 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
record.session.blocked_work = None;
record.worktree_path = None;
record.worktree_base_repo = None;
record.worktree_branch = None;
}
}
}
@@ -1102,10 +1115,8 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
/// would be lost through the kill's own predicate, then remove a pristine
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
/// before anything is torn down, exactly as the forced kill does (#81). A
/// removal that fails falls back to retaining, since whatever blocked it is
/// by definition something worth keeping.
/// tree. Anything holding unsaved work is left exactly where it is — the
/// same thing a forced kill does with a guarded tree (#81).
///
/// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record.
@@ -1113,7 +1124,6 @@ async fn settle_worktree_on_exit(
inner: &Arc<Inner>,
id: Uuid,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
) -> bool {
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
Ok(probe) => probe,
@@ -1127,45 +1137,25 @@ async fn settle_worktree_on_exit(
return false;
}
};
if probe.is_clear() {
match inner.worktree_mgr.remove(base_repo, path) {
Ok(()) => {
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
return true;
}
Err(e) => {
tracing::warn!(
%id,
path = %path.display(),
error = ?e,
"natural exit: removal failed; falling back to retention"
);
}
}
if !probe.is_clear() {
tracing::info!(
%id,
path = %path.display(),
"natural exit: worktree holds unsaved work; leaving it on disk"
);
return false;
}
match record_retention(
inner,
&(base_repo.clone(), path.clone()),
known_branch,
RetainedReason::ExitedWithUnsavedWork,
)
.await
{
Ok(entry) => {
tracing::info!(
%id,
path = %path.display(),
entry = %entry.id,
"natural exit: worktree retained with unsaved work"
);
match inner.worktree_mgr.remove(base_repo, path) {
Ok(()) => {
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
true
}
Err(e) => {
tracing::error!(
tracing::warn!(
%id,
path = %path.display(),
error = ?e,
"natural exit: could not record the retained worktree; leaving it untouched"
"natural exit: removal failed; leaving the worktree on disk"
);
false
}
@@ -1182,15 +1172,15 @@ async fn settle_worktree_on_exit(
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session).
///
/// `retain_worktree` is `true` for a forced kill (#81) that retained the
/// worktree instead of removing it: the store entry is already written, so
/// only the removal is skipped.
/// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
/// session ends but the worktree is left on disk, so only the removal is
/// skipped.
async fn cleanup_record(
inner: &Arc<Inner>,
id: Uuid,
record: SessionRecord,
kill_tmux: bool,
retain_worktree: bool,
keep_worktree: bool,
) {
if kill_tmux {
let tmux = inner.tmux.clone();
@@ -1201,11 +1191,11 @@ async fn cleanup_record(
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if retain_worktree {
if keep_worktree {
tracing::info!(
%id,
path = %path.display(),
"forced kill: worktree retained in the store"
"forced kill: worktree left on disk"
);
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
// The kill already passed the unsaved-work guard (#79), so this
+388
View File
@@ -246,6 +246,70 @@ impl Tmux {
check(out)
}
/// Escape-sequence snapshot of a pane's current screen (#…), for clients
/// that just attached.
///
/// The raw byte replay buffer cannot reconstruct a full-screen program:
/// its tail starts mid-stream, long after the app set up the modes,
/// colours and cursor position that give those bytes meaning. A browser
/// refresh therefore painted garbage until something forced the app to
/// repaint. tmux already holds the authoritative screen, so ask it.
///
/// Returns `Ok(None)` when the session or the tmux server is gone — the
/// caller falls back to the byte replay, which is all a dead session has.
pub fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
let name = session_name(id);
let start = format!("-{CAPTURE_HISTORY_LINES}");
// One invocation, so tmux runs both commands in a single pass of its
// command queue: the cursor position cannot belong to an older screen
// than the captured content. Two invocations raced — a program that
// switched to the alternate screen in between produced a snapshot with
// the new screen and the old cursor.
let out = self
.cmd()
.args([
"display-message",
"-p",
"-t",
&name,
"-F",
SCREEN_GEOMETRY_FORMAT,
";",
"capture-pane",
"-p",
"-e",
// Keep trailing spaces, so a line's background colour reaches
// the edge instead of stopping at the last glyph and letting
// the client's own background show through.
"-N",
"-t",
&name,
"-S",
&start,
])
.output()?;
let Some(stdout) = soft_output(out)? else {
return Ok(None);
};
let Some((head, content)) = stdout.split_once('\n') else {
return Ok(None);
};
let Some(geom) = parse_screen_geometry(head) else {
return Ok(None);
};
let mut lines: Vec<&str> = content
.strip_suffix('\n')
.unwrap_or(content)
.split('\n')
.collect();
// Scrollback belongs to the normal screen. Replaying it under an
// alternate-screen program would paste the shell's history behind it.
if geom.alternate {
lines.drain(..lines.len().saturating_sub(geom.pane_height));
}
Ok(Some(render_screen_snapshot(&lines, geom)))
}
/// `tmux resize-window -t maestro-<id> -x <cols> -y <rows>`.
pub fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
let name = session_name(id);
@@ -446,6 +510,191 @@ fn new_session_args(
args
}
/// How much scrollback a fresh attach inherits from tmux's history, in lines.
/// Enough to land in context after a browser refresh without making the first
/// frame huge.
const CAPTURE_HISTORY_LINES: usize = 300;
/// `display-message -F` request behind [`parse_screen_geometry`]. tmux expands
/// a name it does not know to the empty string rather than failing, so a field
/// missing on an older tmux shifts everything after it — hence the parser keys
/// the optional tail off the field count.
const SCREEN_GEOMETRY_FORMAT: &str = concat!(
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag} ",
"#{mouse_standard_flag} #{mouse_button_flag} #{mouse_all_flag} ",
"#{mouse_utf8_flag} #{mouse_sgr_flag}",
);
/// Field count of [`SCREEN_GEOMETRY_FORMAT`]; fewer means an older tmux did not
/// know one of the names, and the mouse tail cannot be trusted by position.
const SCREEN_GEOMETRY_FIELDS: usize = 10;
/// Mouse-reporting modes a pane has turned on. A full-screen program sets these
/// once, at startup, so a client attaching later never sees the sequences —
/// [`render_screen_snapshot`] replays them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct MouseModes {
/// `?1000` — report button press and release.
standard: bool,
/// `?1002` — also report motion while a button is held.
button: bool,
/// `?1003` — report all motion.
all: bool,
/// `?1005` — UTF-8 coordinates (superseded by SGR, still replayed if on).
utf8: bool,
/// `?1006` — SGR-encoded coordinates.
sgr: bool,
}
impl MouseModes {
/// DECSET parameters to re-enable these modes, private-mode order.
fn decset_params(self) -> impl Iterator<Item = u16> {
[
(self.standard, 1000),
(self.button, 1002),
(self.all, 1003),
(self.utf8, 1005),
(self.sgr, 1006),
]
.into_iter()
.filter_map(|(on, param)| on.then_some(param))
}
}
/// Cursor position and screen geometry of a pane, from `display-message`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ScreenGeometry {
cursor_x: usize,
cursor_y: usize,
pane_height: usize,
alternate: bool,
cursor_visible: bool,
mouse: MouseModes,
}
fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
let line = s.lines().next()?;
let fields: Vec<&str> = line.split_whitespace().collect();
let cursor_x = fields.first()?.parse().ok()?;
let cursor_y = fields.get(1)?.parse().ok()?;
let pane_height: usize = fields.get(2)?.parse().ok()?;
let alternate = *fields.get(3)? == "1";
// Older tmux without `cursor_flag` just means "assume visible".
let cursor_visible = fields.get(4).map(|f| *f == "1").unwrap_or(true);
// Only trust the mouse tail when every field expanded; a short line means
// some name is unknown here and the remaining ones sit at other offsets.
let mouse = if fields.len() >= SCREEN_GEOMETRY_FIELDS {
MouseModes {
standard: fields[5] == "1",
button: fields[6] == "1",
all: fields[7] == "1",
utf8: fields[8] == "1",
sgr: fields[9] == "1",
}
} else {
MouseModes::default()
};
if pane_height == 0 {
return None;
}
Some(ScreenGeometry {
cursor_x,
cursor_y,
pane_height,
alternate,
cursor_visible,
mouse,
})
}
/// Turn captured pane lines into bytes a fresh terminal can replay: clear,
/// print the lines, then put the cursor back where the pane has it.
///
/// The cursor is restored with *relative* moves so the snapshot renders the
/// same whatever height the client's terminal has — it may not match the
/// pane's yet, since the client's first `Resize` arrives after this.
fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec<u8> {
// Screen rows are the last `pane_height` captured lines; anything before
// them is scrollback.
let screen_top = lines.len().saturating_sub(geom.pane_height);
let cursor_row = (screen_top + geom.cursor_y).min(lines.len().saturating_sub(1));
// Trailing blank rows would scroll the real content off a short client
// terminal, so drop them — but never the cursor's own row or anything
// above it, which is where a program that draws below its cursor lives.
let last = lines
.iter()
.rposition(|l| !strip_sgr(l).trim().is_empty())
.unwrap_or(0)
.max(cursor_row);
let mut out = Vec::new();
// Put the client on the same buffer the pane is using, so a program's
// repaints stay out of the client's scrollback while it runs.
if geom.alternate {
out.extend_from_slice(b"\x1b[?1049h");
}
// Mouse reporting is set up once, before this client existed. Without it
// the client cannot tell a mouse-driven app from a plain alternate screen,
// and its wheel and touch gestures have nowhere to go.
for param in geom.mouse.decset_params() {
out.extend_from_slice(format!("\x1b[?{param}h").as_bytes());
}
out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m");
for (i, line) in lines[..=last].iter().enumerate() {
if i > 0 {
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(b"\x1b[m\r");
if last > cursor_row {
out.extend_from_slice(format!("\x1b[{}A", last - cursor_row).as_bytes());
}
if geom.cursor_x > 0 {
out.extend_from_slice(format!("\x1b[{}C", geom.cursor_x).as_bytes());
}
if !geom.cursor_visible {
out.extend_from_slice(b"\x1b[?25l");
}
out
}
/// Text of a line with SGR escapes removed, for emptiness checks.
fn strip_sgr(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
// Skip CSI ... final-byte. Anything else escape-ish is not produced by
// `capture-pane -e`, so treating it the same way is harmless.
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
out
}
/// `Ok(None)` when tmux failed because the server or the target is gone,
/// `Ok(Some(stdout))` on success, `Err` for anything else.
fn soft_output(out: Output) -> Result<Option<String>, TmuxError> {
if out.status.success() {
return Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()));
}
let stderr = String::from_utf8_lossy(&out.stderr);
if is_no_server_error(&stderr) || stderr.contains("can't find") {
return Ok(None);
}
Err(TmuxError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: stderr.into_owned(),
})
}
fn check(out: Output) -> Result<(), TmuxError> {
if out.status.success() {
Ok(())
@@ -461,6 +710,145 @@ fn check(out: Output) -> Result<(), TmuxError> {
mod tests {
use super::*;
#[test]
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
let g = parse_screen_geometry("12 3 40 1 0 0 1 0 0 1\n").unwrap();
assert_eq!(
g,
ScreenGeometry {
cursor_x: 12,
cursor_y: 3,
pane_height: 40,
alternate: true,
cursor_visible: false,
mouse: MouseModes {
button: true,
sgr: true,
..MouseModes::default()
},
}
);
let g = parse_screen_geometry("0 0 24 0").unwrap();
assert!(!g.alternate);
assert!(g.cursor_visible, "missing cursor_flag means visible");
assert!(parse_screen_geometry("0 0 0 0").is_none());
assert!(parse_screen_geometry("garbage").is_none());
}
/// A tmux that does not know one of the mouse names expands it to nothing,
/// so the tail is one field short and every flag in it has slid. Reading it
/// by position would claim modes the pane never set.
#[test]
fn parse_screen_geometry_drops_a_short_mouse_tail() {
let g = parse_screen_geometry("0 0 24 1 1 0 0 1 1").unwrap();
assert_eq!(g.mouse, MouseModes::default());
}
#[test]
fn mouse_modes_map_to_private_mode_parameters() {
let all = MouseModes {
standard: true,
button: true,
all: true,
utf8: true,
sgr: true,
};
assert_eq!(
all.decset_params().collect::<Vec<_>>(),
vec![1000, 1002, 1003, 1005, 1006]
);
assert_eq!(MouseModes::default().decset_params().count(), 0);
}
#[test]
fn snapshot_clears_prints_lines_and_parks_the_cursor() {
// Two scrollback lines, then a 3-row screen with the cursor on row 1.
let lines = ["old", "older", "prompt> hi", "", ""];
let out = render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 10,
cursor_y: 0,
pane_height: 3,
alternate: false,
cursor_visible: true,
mouse: MouseModes::default(),
},
);
let out = String::from_utf8(out).unwrap();
assert_eq!(
out,
"\x1b[H\x1b[2J\x1b[mold\r\nolder\r\nprompt> hi\x1b[m\r\x1b[10C"
);
}
#[test]
fn snapshot_keeps_rows_below_the_cursor_and_moves_back_up() {
let lines = ["prompt> hi", "hint line", ""];
let out = render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 0,
cursor_y: 0,
pane_height: 3,
alternate: true,
cursor_visible: false,
mouse: MouseModes::default(),
},
);
let out = String::from_utf8(out).unwrap();
assert!(out.starts_with("\x1b[?1049h\x1b[H\x1b[2J"), "{out:?}");
assert!(
out.ends_with("prompt> hi\r\nhint line\x1b[m\r\x1b[1A\x1b[?25l"),
"{out:?}"
);
}
#[test]
fn snapshot_ignores_colour_only_rows_when_trimming() {
let lines = ["text", "\x1b[38;5;4m\x1b[m", ""];
let out = String::from_utf8(render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 4,
cursor_y: 0,
pane_height: 3,
alternate: false,
cursor_visible: true,
mouse: MouseModes::default(),
},
))
.unwrap();
assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C");
}
/// A client attaching to a running full-screen app never saw it turn mouse
/// reporting on, so the snapshot has to. Without this the client reads the
/// pane as a plain alternate screen and its scroll gestures go nowhere.
#[test]
fn snapshot_replays_the_panes_mouse_modes() {
let out = String::from_utf8(render_screen_snapshot(
&["app"],
ScreenGeometry {
cursor_x: 0,
cursor_y: 0,
pane_height: 1,
alternate: true,
cursor_visible: true,
mouse: MouseModes {
button: true,
sgr: true,
..MouseModes::default()
},
},
))
.unwrap();
assert!(
out.starts_with("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
"{out:?}"
);
}
/// Every phrasing must be recognised: an unmatched one makes
/// `list_sessions` fail, and a failed pass reaps nothing.
#[test]
+85 -92
View File
@@ -1,14 +1,8 @@
//! The one git probe for what a worktree holds that would be lost, plus
//! guard-free reclaim (#79, #80).
//! The one git probe for what a worktree holds that would be lost (#79).
//!
//! [`in_the_way`] is the single predicate behind all three paths: the kill
//! refusal (#79), the natural-exit classifier (#82), and the retained list
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
//! answer the question differently — #79 and #80 were built in parallel and
//! each grew its own copy, which is exactly the drift this collapses.
//!
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
//! the operator chose it while looking at what was in the way.
//! [`in_the_way`] is the single predicate behind both paths: the kill refusal
//! (#79) and the natural-exit classifier (#82). It lives here rather than in
//! `worktree.rs` so that neither can answer the question differently.
use std::path::Path;
@@ -27,7 +21,7 @@ pub enum ProbeError {
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
/// `target/`, and counting those would put every tree permanently in the way.
/// A directory that no longer exists reports nothing in the way: there is
/// nothing left to lose, and the store's own pruning drops the entry.
/// nothing left to lose.
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
let mut breakdown = InTheWay::default();
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
@@ -51,6 +45,25 @@ pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
breakdown.uncommitted_files += 1;
}
}
// The line delta behind the sidebar's `+N −M` (docs/SIDEBAR.md). Diffing
// the working tree against HEAD covers staged and unstaged changes in one
// pass, so a `git add` never makes the counter drop.
let numstat = run_git(path, ["diff", "--numstat", "HEAD"]);
if let Ok(numstat) = numstat {
for line in String::from_utf8_lossy(&numstat.stdout).lines() {
let mut cols = line.split('\t');
// A binary file reports `-` for both columns; it has no lines to
// add to either total.
let added: usize = cols.next().and_then(|c| c.parse().ok()).unwrap_or(0);
let removed: usize = cols.next().and_then(|c| c.parse().ok()).unwrap_or(0);
breakdown.insertions += added;
breakdown.deletions += removed;
}
}
// A repo with no commits yet has no HEAD to diff against, and the counts
// stay zero rather than failing the whole probe — the file counts above
// already reported the work.
// Resolved inside the worktree so a detached HEAD still has a starting
// point; asking the base repo for `refs/heads/<branch>` fails there.
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
@@ -67,37 +80,6 @@ pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
Ok(breakdown)
}
/// Remove a retained worktree's directory without consulting any guard, per
/// the reclaim contract: the operator chose this while looking at what was in
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
/// the branch survive even when they were never pushed.
///
/// A directory that is already gone only leaves a stale registration behind;
/// `worktree prune` clears it so the branch can be spawned again.
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
if !path.exists() {
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
}
run_git(
base_repo,
[
"worktree",
"remove",
"--force",
path.to_str().expect("worktree path is valid utf-8"),
],
)
.map(|_| ())
}
/// Branch checked out at `path`. Sessions adopted from a sidecar carry no
/// branch on record, so a forced kill (#81) asks the tree itself when it
/// needs one for the retained store. `None` when git cannot answer.
pub fn current_branch(path: &Path) -> Option<String> {
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
where
I: IntoIterator<Item = S>,
@@ -247,6 +229,67 @@ mod tests {
assert_eq!(dirty.untracked_files, 1);
}
/// docs/SIDEBAR.md: the sidebar renders `+N −M`, so the probe carries the
/// line delta of the tracked changes — staged and unstaged together,
/// against HEAD.
#[test]
fn staged_and_unstaged_lines_both_count() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("staged.txt"), b"a\nb\n").unwrap();
git(&r.wt(), &["add", "staged.txt"]);
// README loses its one line and gains three, unstaged.
std::fs::write(r.wt().join("README"), b"x\ny\nz\n").unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.insertions, 5, "2 staged + 3 unstaged");
assert_eq!(breakdown.deletions, 1);
}
/// Untracked files are counted as files and never as lines — reading each
/// one off disk is the cost the sidebar contract declines to pay.
#[test]
fn untracked_files_contribute_no_lines() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("scratch.txt"), b"a\nb\nc\n").unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.untracked_files, 1);
assert_eq!(breakdown.insertions, 0);
assert_eq!(breakdown.deletions, 0);
}
/// A binary file's numstat is `-\t-`. It must not poison the totals, and
/// it must not abort the probe either.
#[test]
fn binary_changes_add_no_lines() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("blob.bin"), [0u8, 159, 0, 12]).unwrap();
git(&r.wt(), &["add", "blob.bin"]);
git(&r.wt(), &["commit", "-qm", "blob"]);
std::fs::write(r.wt().join("blob.bin"), [0u8, 1, 2, 3, 4]).unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.insertions, 0);
assert_eq!(breakdown.deletions, 0);
}
/// A line delta alone never decides `is_clear` — the file counts do — so a
/// mode change with no line delta still blocks a kill.
#[test]
fn a_line_less_change_still_blocks() {
let r = repo_with_worktree();
let script = r.wt().join("run.sh");
std::fs::write(&script, b"echo hi\n").unwrap();
git(&r.wt(), &["add", "run.sh"]);
git(&r.wt(), &["commit", "-qm", "script"]);
git(&r.wt(), &["update-index", "--chmod=+x", "run.sh"]);
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.insertions, 0);
assert_eq!(breakdown.deletions, 0);
assert!(!breakdown.is_clear());
}
#[test]
fn ignored_files_never_count() {
let r = repo_with_worktree();
@@ -272,54 +315,4 @@ mod tests {
let breakdown = in_the_way(&gone).expect("probe");
assert!(breakdown.is_clear());
}
#[test]
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
let r = repo_with_worktree();
r.commit_in_wt("precious");
reclaim(&r.repo, &r.wt()).expect("reclaim");
assert!(!r.wt().exists());
// A second reclaim over the now-missing path just prunes.
reclaim(&r.repo, &r.wt()).expect("reclaim again");
}
#[test]
fn reclaim_keeps_the_branch_and_its_commits() {
let r = repo_with_worktree();
r.commit_in_wt("precious");
let sha = {
let out = Command::new("git")
.arg("-C")
.arg(r.wt())
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
reclaim(&r.repo, &r.wt()).expect("reclaim");
let out = Command::new("git")
.arg("-C")
.arg(&r.repo)
.args(["rev-parse", "--verify", "refs/heads/feature"])
.output()
.unwrap();
assert!(
out.status.success(),
"branch must survive reclaim: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
}
#[test]
fn reclaim_is_guard_free_on_a_dirty_tree() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
assert!(!r.wt().exists());
}
}
-1
View File
@@ -38,7 +38,6 @@ async fn spawn_daemon_ws() -> Harness {
"/home/test",
Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
-1
View File
@@ -27,7 +27,6 @@ async fn spawn() -> Harness {
"/home/test",
presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
-18
View File
@@ -176,11 +176,6 @@ impl DaemonFixture {
manager =
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
}
// Same wiring as production: the manager records retained worktrees
// into the same dir the API reads back from (#78/#81).
let retained =
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
manager = manager.with_retained_store(retained.clone());
Self {
tmux,
manager,
@@ -192,10 +187,6 @@ impl DaemonFixture {
state_dir.path().join("spawn-history.jsonl")
}
fn retained_path(state_dir: &TempDir) -> PathBuf {
state_dir.path().join("retained-worktrees")
}
pub fn spawn_history_path(&self) -> PathBuf {
Self::history_path(&self.state_dir)
}
@@ -204,15 +195,6 @@ impl DaemonFixture {
self.state_dir.path().join("sessions")
}
/// Retained-worktree store rooted at the fixture's own state dir (#78).
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
}
pub fn retained_dir(&self) -> PathBuf {
self.state_dir.path().join("retained-worktrees")
}
pub fn fifo_dir(&self) -> PathBuf {
self.state_dir.path().join("fifos")
}
+51 -263
View File
@@ -25,13 +25,6 @@ struct Harness {
_fx: DaemonFixture,
}
impl Harness {
/// Store over the same dir the manager records into.
fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore {
self._fx.retained_store()
}
}
async fn spawn(hostname: &str) -> Harness {
spawn_with(
hostname,
@@ -55,7 +48,6 @@ async fn spawn_with(
"/home/test",
presets,
projects,
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
@@ -240,17 +232,14 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
assert!(projects.is_empty());
}
/// #53: every returned handle splits into a known adjective and noun.
/// #53: every returned handle is one word from the shared list.
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
for name in &batch.names {
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
panic!("handle {name:?} is not adjective-noun");
});
assert!(!name.contains('-'), "handle {name:?} is not one word");
assert!(
maestro_names::ADJECTIVES.contains(&adj),
"{adj} not in ADJECTIVES"
maestro_names::WORDS.contains(&name.as_str()),
"{name} not in WORDS"
);
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
}
}
@@ -323,6 +312,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;
@@ -495,11 +526,10 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
assert!(h.manager.has(session.id).await, "the session keeps running");
}
/// `?force=true` on the wire (#81): the refusal is overridden, the session
/// ends, and the answer is 200 with the retained worktree named — path,
/// branch, reason — while the directory survives on disk.
/// `?force=true` on the wire (#81): the refusal is overridden and the session
/// ends, while the guarded worktree survives on disk.
#[tokio::test]
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path());
@@ -532,22 +562,11 @@ async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body: maestro_protocol::KillOutcome = resp.json().await.unwrap();
let retained = body.retained.expect("force names what was kept");
assert_eq!(retained.path, wt_path);
assert_eq!(retained.branch, "feat/guarded");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
// The tree would have blocked; force must not have removed it.
assert!(wt_path.exists(), "a forced kill never removes the worktree");
// And the store entry exists, so reclaim can find it (#80).
let all = h.fx_retained().load_all().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, retained.id);
assert!(!h.manager.has(session.id).await, "the session is gone");
}
#[tokio::test]
@@ -704,234 +723,3 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
}
}
}
// ---------------------------------------------------------------------------
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
// ---------------------------------------------------------------------------
/// `git -C dir args`, asserting success — the worktree endpoints are tested
/// against real git, like everything else in this crate.
fn git(dir: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
struct RetainedFixture {
repo: tempfile::TempDir,
wt: std::path::PathBuf,
}
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
/// an uncommitted tracked edit and an unpushed commit.
fn retained_fixture() -> RetainedFixture {
let repo = tempfile::tempdir().unwrap();
common::init_repo(repo.path());
// A pushed main, so "unpushed" means commits made on the feature branch,
// not the shared history.
let remote = repo.path().join("remote.git");
std::fs::create_dir_all(&remote).unwrap();
let remote_s = remote.to_string_lossy().into_owned();
git(&remote, &["init", "-q", "--bare"]);
git(repo.path(), &["remote", "add", "origin", &remote_s]);
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
git(
repo.path(),
&[
"worktree",
"add",
"-q",
"-b",
"feat/login",
"worktrees/feat/login",
],
);
let wt = repo.path().join("worktrees").join("feat").join("login");
RetainedFixture { repo, wt }
}
impl RetainedFixture {
fn commit_in_wt(&self, msg: &str) {
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
git(&self.wt, &["add", "-A"]);
git(&self.wt, &["commit", "-qm", msg]);
}
fn branch_sha(&self, branch: &str) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(self.repo.path())
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.unwrap();
assert!(out.status.success(), "branch {branch} must exist");
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
}
#[tokio::test]
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
// Uncommitted tracked edit + an untracked file; the branch has no commits
// beyond main yet.
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed.len(), 1);
let w = &listed[0];
assert_eq!(w.id, entry.id);
assert_eq!(w.path, fx.wt);
assert_eq!(w.base_repo, fx.repo.path());
assert_eq!(w.branch, "feat/login");
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
assert_eq!(w.in_the_way.uncommitted_files, 1);
assert_eq!(w.in_the_way.untracked_files, 1);
assert_eq!(w.in_the_way.unpushed_commits, 0);
// The operator saves the work themselves. The stored entry is untouched —
// the next read must report the tree as it is *now*, not as it was when
// it was retained.
fx.commit_in_wt("saved");
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed.len(), 1, "same entry, still retained");
assert_eq!(listed[0].id, entry.id);
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
}
#[tokio::test]
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
fx.commit_in_wt("precious");
let sha = fx.branch_sha("feat/login");
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
)
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!fx.wt.exists(), "reclaim removes the directory");
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(listed.is_empty(), "reclaim drops the store entry");
assert_eq!(
fx.branch_sha("feat/login"),
sha,
"the branch and its commits survive"
);
}
#[tokio::test]
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
assert!(store.load_all().unwrap().is_empty());
}
#[tokio::test]
async fn reclaim_unknown_id_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
let store = h._fx.retained_store();
store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
std::fs::remove_dir_all(&fx.wt).unwrap();
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(listed.is_empty(), "a hand-removed directory is not listed");
assert!(
store.load_all().unwrap().is_empty(),
"and its entry is gone"
);
}
+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:?}"),
}
}
+141 -152
View File
@@ -8,9 +8,7 @@ use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use common::{
init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture,
};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
@@ -190,6 +188,117 @@ async fn create_captures_initial_output_in_replay_buffer() {
fx.manager.kill(s.id, false).await.unwrap();
}
/// A fresh attach must land on the pane's *current* screen. Replaying the raw
/// byte tail instead reproduced a browser refresh painting garbage over an
/// agent TUI: the tail starts mid-stream, so the clears, modes and cursor
/// moves that gave those bytes meaning are long gone.
#[tokio::test]
async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
// Print a line, then switch to the alternate screen the way a
// full-screen program does on startup. Only the second marker is
// on the pane; the first belongs to the screen behind it.
command: "sh -c 'printf STALE_MARKER_LINE\\\\n; sleep 0.5; printf \"\\\\033[?1049h\\\\033[2J\\\\033[HLIVE_MARKER\"; exec sleep 30'"
.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let want = b"LIVE_MARKER";
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut replay = Vec::new();
while std::time::Instant::now() < deadline {
let (bytes, _rx) = fx
.manager
.subscribe_pty_with_replay(s.id)
.await
.expect("subscribe");
replay = bytes;
if replay.windows(want.len()).any(|w| w == want) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let text = String::from_utf8_lossy(&replay).into_owned();
assert!(
replay.starts_with(b"\x1b[?1049h\x1b[H\x1b[2J"),
"replay did not start with an alternate-screen snapshot: {text:?}"
);
assert!(
text.contains("LIVE_MARKER"),
"replay lost the live screen: {text:?}"
);
assert!(
!text.contains("STALE_MARKER_LINE"),
"replay carried bytes from behind the alternate screen: {text:?}"
);
fx.manager.kill(s.id, false).await.unwrap();
}
/// A full-screen app turns mouse reporting on once, at startup. A client that
/// attaches later never sees those sequences, so it reads the pane as a plain
/// alternate screen and its wheel and touch gestures have nowhere to go. The
/// snapshot must carry the pane's mouse modes with it.
#[tokio::test]
async fn attach_replay_carries_the_panes_mouse_modes() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
// Alternate screen, then button-event tracking (?1002) with SGR
// coordinates (?1006) — what tmux and agent TUIs actually request.
command: "sh -c 'printf \"\\\\033[?1049h\\\\033[?1002h\\\\033[?1006h\\\\033[2J\\\\033[HMOUSE_MARKER\"; exec sleep 30'"
.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let want = b"MOUSE_MARKER";
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut replay = Vec::new();
while std::time::Instant::now() < deadline {
let (bytes, _rx) = fx
.manager
.subscribe_pty_with_replay(s.id)
.await
.expect("subscribe");
replay = bytes;
if replay.windows(want.len()).any(|w| w == want) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let text = String::from_utf8_lossy(&replay).into_owned();
assert!(
text.contains("MOUSE_MARKER"),
"replay never reached the live screen: {text:?}"
);
assert!(
replay.starts_with(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
"replay dropped the pane's mouse modes: {text:?}"
);
fx.manager.kill(s.id, false).await.unwrap();
}
/// Regression for NAP-228: after `recover`, PTY output must reach new
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
/// kept feeding the old FIFO and the revived manager's subscribers saw
@@ -609,11 +718,9 @@ async fn killing_refuses_until_the_branch_is_pushed() {
}
/// `--force` overrides the refusal and nothing else (#81): the session ends,
/// but the guarded worktree is RETAINED — never removed — and enters the
/// retained store with reason `force-killed`. The outcome names what was
/// kept, so a client can say where the work went.
/// but the guarded worktree is left on disk — never removed.
#[tokio::test]
async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
@@ -639,8 +746,7 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
// Without force the same kill is refused (#79).
assert!(fx.manager.kill(s.id, false).await.is_err());
let outcome = fx
.manager
fx.manager
.kill(s.id, true)
.await
.expect("force overrides the refusal");
@@ -653,30 +759,11 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
// ...but the session itself still ends.
assert!(!fx.manager.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
let retained = outcome.retained.expect("the outcome names what was kept");
assert_eq!(retained.path, wt_path);
assert_eq!(retained.branch, "feat/forced");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert!(
retained.in_the_way.uncommitted_files >= 1,
"the outcome carries what blocked the unforced kill"
);
// The store holds the same entry, so reclaim can find it later (#80).
let all = fx.retained_store().load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, retained.id);
assert_eq!(all[0].path, wt_path);
assert_eq!(all[0].branch, "feat/forced");
}
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
/// guard is clear never needed the override, so a forced kill removes it as
/// usual and retains nothing.
/// usual.
#[tokio::test]
async fn force_on_a_clear_worktree_removes_it_as_usual() {
let fx = DaemonFixture::new();
@@ -704,10 +791,8 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
let outcome = fx.manager.kill(s.id, true).await.expect("kill");
fx.manager.kill(s.id, true).await.expect("kill");
assert!(!wt_path.exists(), "a clear tree needs no override");
assert_eq!(outcome.retained, None);
assert!(fx.retained_store().load_all().unwrap().is_empty());
}
/// Ignored files NEVER block (#79): build output like `target/` exists in
@@ -757,12 +842,11 @@ async fn killing_an_ignored_only_worktree_succeeds() {
/// Natural exit (#82): a session that ends on its own never reaches the kill
/// path, so the watcher applies the kill's rule itself. A worktree holding
/// unsaved work is RETAINED into the store with reason
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead
/// until dismissed — and dismissing it afterwards neither refuses nor
/// retains a second time.
/// unsaved work is LEFT ON DISK and stays attached to the dead record; the
/// session stays in the catalog marked dead until dismissed, and dismissing
/// it goes through `--force`, which leaves the tree alone too.
#[tokio::test]
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
@@ -793,56 +877,35 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
})
.await;
// The tree would have blocked a kill, so the exit kept it...
// The settle step has been applied or refused either way — here refused,
// since the tree holds unsaved work. Only from this point is "the tree
// survived" a settled fact rather than a race (#115).
wait_for_async(Duration::from_secs(3), || async {
fx.manager.is_settled(s.id).await == Some(true)
})
.await;
// The tree would have blocked a kill, so the exit kept it.
assert!(
wt_path.exists(),
"a dirty worktree must be retained on natural exit"
"a dirty worktree must be left alone 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);
assert_eq!(all[0].branch, "feat/exited");
assert_eq!(
all[0].reason,
maestro_protocol::RetainedReason::ExitedWithUnsavedWork
);
// The session itself stays until dismissed; its worktree reference is
// 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 {
let s = &fx.manager.snapshot().await[0];
s.worktree.is_none() && s.blocked_work.is_none()
})
.await;
let snap = fx.manager.snapshot().await;
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].worktree, None);
fx.manager.kill(s.id, false).await.expect("dismiss");
assert!(!fx.manager.has(s.id).await);
assert!(
wt_path.exists(),
"dismissal must not remove what was retained"
);
assert_eq!(
fx.retained_store().load_all().unwrap().len(),
1,
"dismissal must not record a second entry for the same tree"
snap[0].worktree.is_some(),
"the tree stays attached to the dead record"
);
// Dismissing it needs --force, and force never removes what would block.
assert!(fx.manager.kill(s.id, false).await.is_err());
fx.manager.kill(s.id, true).await.expect("forced dismissal");
assert!(!fx.manager.has(s.id).await);
assert!(wt_path.exists(), "dismissal must not remove the work");
}
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
/// nothing to keep — the worktree is removed and no store entry appears.
/// nothing to keep — the worktree is removed.
#[tokio::test]
async fn natural_exit_of_a_pristine_worktree_removes_it() {
let fx = DaemonFixture::new();
@@ -879,92 +942,18 @@ 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;
wait_for_async(Duration::from_secs(3), || async { !wt_path.exists() }).await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
);
assert!(
fx.retained_store().load_all().unwrap().is_empty(),
"nothing was retained"
);
assert!(
fx.manager.has(s.id).await,
"the session stays until dismissed"
);
}
/// When nothing can be recorded — here: no retained store wired — the exit
/// destroys nothing. The tree stays on disk AND stays attached to the dead
/// record, where dismissing it later still goes through #81's forced-kill
/// retention. Losing the store must not lose the work.
#[tokio::test]
async fn natural_exit_without_a_retained_store_destroys_nothing() {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let manager = maestro_daemon::SessionManager::new(
Arc::new(tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")),
maestro_daemon::worktree::WorktreeManager::new(None),
state_dir.path().join("fifos"),
);
// Deliberately NO with_retained_store.
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("nostore");
let s = 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/nostore".into(),
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
tmux.tmux.kill_session(s.id).expect("kill tmux directly");
manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
manager.status(s.id).await == Some(SessionStatus::Dead)
})
.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"
);
let snap = manager.snapshot().await;
assert!(
snap[0].worktree.is_some(),
"the tree must stay attached to the record so a later forced kill can retain it"
);
}
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
+4 -13
View File
@@ -12,8 +12,7 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
RetainedWorktree, Session,
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -65,20 +64,12 @@ 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> {
/// Kill with `force=true`: overrides a kill refusal and leaves the
/// guarded worktree on disk (#81).
pub async fn kill_session_forced(&self, id: Uuid) -> Result<(), 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?)
}
+147 -151
View File
@@ -415,7 +415,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
}
// ---------------------------------------------------------------------------
// Worktree lifecycle: collisions, retention, reclaim (#88)
// Worktree lifecycle: collisions, guards, settling (#88)
// ---------------------------------------------------------------------------
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
@@ -621,10 +621,10 @@ async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
}
/// #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`.
/// session anyway but LEAVES the worktree on disk — force never destroys
/// blocked work.
#[tokio::test(flavor = "multi_thread")]
async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
@@ -655,17 +655,7 @@ async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
}
// 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);
client.kill_session_forced(s.id).await.expect("forced kill");
assert!(
!client
@@ -676,18 +666,18 @@ async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
.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!(wt.is_dir(), "a guarded 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"
"the kept 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.
/// its worktree follows the same rule. Dirty: left on disk. 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;
@@ -734,46 +724,18 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
// 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
!registered.iter().any(|l| l.contains("feature/tidy"))
}
},
)
@@ -797,94 +759,6 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
}
}
/// #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)
// ---------------------------------------------------------------------------
@@ -1280,21 +1154,6 @@ fn commit_all(dir: &std::path::Path, message: &str) {
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
@@ -1391,6 +1250,143 @@ async fn cli_list_reads_from_daemon() {
);
}
/// `maestro send` end to end: the shipped binary, a real daemon, a real tmux
/// pane. The shell only runs the command if the Enter arrived as its own
/// write after the text, so the file appearing is the whole path working.
#[tokio::test(flavor = "multi_thread")]
async fn cli_send_types_a_command_into_a_live_shell() {
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
.await
.expect("spawn bash");
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
&["send", &sess.id.to_string(), "touch", "marker.txt"],
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_DAEMON_URL", &daemon.http_base()),
],
);
assert!(
out.status.success(),
"send failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let marker = cwd.path().join("marker.txt");
wait_for_async(
"the shell runs the sent command",
Duration::from_secs(10),
|| {
let marker = marker.clone();
async move { marker.exists() }
},
)
.await;
client.kill_session(sess.id).await.expect("kill");
}
/// `--no-enter` fills the prompt without submitting: the command must sit
/// there unrun. Same setup as the test above, so the only difference is the
/// flag.
#[tokio::test(flavor = "multi_thread")]
async fn send_without_enter_does_not_run_the_command() {
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
.await
.expect("spawn bash");
let home = TempDir::new().unwrap();
let out = run_cli(
&[
"send",
"--no-enter",
&sess.id.to_string(),
"touch",
"unsubmitted.txt",
],
&[
("HOME", home.path().to_str().unwrap()),
(
"MAESTRO_CONFIG",
home.path().join("c.toml").to_str().unwrap(),
),
("MAESTRO_DAEMON_URL", &daemon.http_base()),
],
);
assert!(
out.status.success(),
"send failed: {}",
String::from_utf8_lossy(&out.stderr)
);
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(
!cwd.path().join("unsubmitted.txt").exists(),
"the command ran despite --no-enter"
);
client.kill_session(sess.id).await.expect("kill");
}
/// A session whose command exited still exists in the catalog, but its pane
/// is gone. Typing into it is refused instead of being swallowed — a script
/// that sends an instruction to a dead agent has to hear about it.
#[tokio::test(flavor = "multi_thread")]
async fn send_to_a_dead_session_is_refused() {
use maestro_protocol::{SendInputRequest, SessionStatus};
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "true"))
.await
.expect("spawn true");
wait_for_async(
"the session to be seen dead",
Duration::from_secs(15),
|| {
let client = client.clone();
let id = sess.id;
async move {
client
.list_sessions()
.await
.map(|ss| {
ss.iter()
.any(|s| s.id == id && s.status == SessionStatus::Dead)
})
.unwrap_or(false)
}
},
)
.await;
let err = client
.daemon()
.send_input(
sess.id,
&SendInputRequest {
text: "hello".into(),
submit: true,
},
)
.await
.expect_err("a dead session cannot be typed into");
assert!(
err.to_string().contains("no longer running"),
"unexpected error: {err}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cli_list_fails_when_daemon_is_down() {
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
+233 -29
View File
@@ -1,10 +1,195 @@
//! The one home of the session-handle wordlists (#53).
//! The one home of the session-handle wordlist (#53).
//!
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
//! from here, served by `GET /api/names`.
/// Musical adjectives — tempo, dynamics, and articulation markings.
pub const ADJECTIVES: &[&str] = &[
/// Every word a handle can be: instruments, forms, and the vocabulary of
/// tempo, dynamics, notation and theory. One word per handle — the list is
/// wide enough that a single word still reads as distinct.
pub const WORDS: &[&str] = &[
// Instruments
"cello",
"violin",
"viola",
"oboe",
"flute",
"piccolo",
"clarinet",
"bassoon",
"trumpet",
"trombone",
"tuba",
"horn",
"cornet",
"bugle",
"flugelhorn",
"euphonium",
"saxophone",
"recorder",
"timpani",
"harp",
"piano",
"organ",
"lyre",
"lute",
"mandolin",
"banjo",
"ukulele",
"guitar",
"sitar",
"koto",
"shamisen",
"balalaika",
"zither",
"dulcimer",
"kantele",
"erhu",
"guzheng",
"pipa",
"duduk",
"ocarina",
"kalimba",
"harmonica",
"accordion",
"bandoneon",
"bagpipe",
"harpsichord",
"clavichord",
"celesta",
"theremin",
"marimba",
"xylophone",
"vibraphone",
"glockenspiel",
"snare",
"cymbal",
"tambourine",
"castanet",
"djembe",
"tabla",
"bodhran",
"gong",
"chimes",
"contrabass",
"shawm",
"crumhorn",
"sackbut",
"didgeridoo",
// Forms and pieces
"sonata",
"fugue",
"canon",
"prelude",
"overture",
"rondo",
"nocturne",
"scherzo",
"etude",
"ballade",
"barcarolle",
"bagatelle",
"caprice",
"chaconne",
"passacaglia",
"toccata",
"intermezzo",
"interlude",
"minuet",
"gavotte",
"sarabande",
"gigue",
"allemande",
"courante",
"mazurka",
"polonaise",
"waltz",
"bolero",
"habanera",
"tarantella",
"fandango",
"serenade",
"aubade",
"requiem",
"cantata",
"oratorio",
"madrigal",
"motet",
"chorale",
"hymn",
"anthem",
"lullaby",
"elegy",
"rhapsody",
"symphony",
"concerto",
"fantasia",
"divertimento",
"quartet",
"quintet",
"sextet",
"septet",
"octet",
"trio",
// Structure, notation and theory
"octave",
"chord",
"cadence",
"cadenza",
"coda",
"motif",
"refrain",
"verse",
"chorus",
"bridge",
"finale",
"arpeggio",
"glissando",
"ostinato",
"counterpoint",
"descant",
"harmony",
"melody",
"rhythm",
"tempo",
"timbre",
"treble",
"clef",
"staff",
"quaver",
"crotchet",
"semibreve",
"fermata",
"trill",
"mordent",
"appoggiatura",
"portamento",
"vibrato",
"triplet",
"tuplet",
"downbeat",
"upbeat",
"anacrusis",
"syncopation",
"modulation",
"transposition",
"unison",
"semitone",
"tritone",
"diapason",
"tonic",
"dominant",
"mediant",
"ionian",
"dorian",
"phrygian",
"lydian",
"mixolydian",
"aeolian",
"locrian",
"pentatonic",
"chromatic",
"diatonic",
// Tempo, dynamics and articulation
"allegro",
"andante",
"adagio",
@@ -15,6 +200,7 @@ pub const ADJECTIVES: &[&str] = &[
"lento",
"moderato",
"crescendo",
"diminuendo",
"staccato",
"legato",
"forte",
@@ -35,60 +221,78 @@ pub const ADJECTIVES: &[&str] = &[
"sotto",
"vivo",
"feroce",
];
/// Musical nouns — instruments and forms from the concert hall.
pub const NOUNS: &[&str] = &[
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
"motif", "overture",
"giocoso",
"affettuoso",
"risoluto",
"leggiero",
"pesante",
"morendo",
"accelerando",
"ritardando",
];
/// Upper bound for a `GET /api/names?count=N` batch.
pub const MAX_BATCH: usize = 100;
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
/// A fresh one-word handle, e.g. `nocturne`.
pub fn generate() -> String {
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
format!(
"{}-{}",
ADJECTIVES.choose(&mut rng).unwrap(),
NOUNS.choose(&mut rng).unwrap()
)
(*WORDS.choose(&mut rng).unwrap()).to_string()
}
/// `count` fresh handles (the caller clamps; this only trusts its input).
/// `count` fresh handles, distinct while the wordlist lasts (the caller
/// clamps; this only trusts its input).
pub fn generate_batch(count: usize) -> Vec<String> {
(0..count).map(|_| generate()).collect()
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
let mut pool: Vec<&str> = WORDS.to_vec();
pool.shuffle(&mut rng);
(0..count)
.map(|i| match pool.get(i) {
// Past the wordlist a batch has to repeat itself.
Some(word) => (*word).to_string(),
None => generate(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn generated_name_is_adjective_dash_noun() {
fn generated_name_is_a_single_known_word() {
let name = generate();
let (adj, noun) = name.split_once('-').expect("name contains a dash");
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
assert!(!name.contains('-'), "{name} is not one word");
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
}
#[test]
fn batch_returns_the_requested_count() {
fn the_wordlist_has_no_duplicates_and_covers_a_full_batch() {
let unique: HashSet<&&str> = WORDS.iter().collect();
assert_eq!(unique.len(), WORDS.len(), "WORDS has duplicates");
assert!(
WORDS.len() >= MAX_BATCH,
"a full batch would have to repeat"
);
}
#[test]
fn batch_returns_the_requested_count_without_repeats() {
assert_eq!(generate_batch(1).len(), 1);
assert_eq!(generate_batch(50).len(), 50);
for name in generate_batch(10) {
let (adj, noun) = name.split_once('-').unwrap();
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
let batch = generate_batch(MAX_BATCH);
assert_eq!(batch.len(), MAX_BATCH);
for name in &batch {
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
}
let unique: HashSet<&String> = batch.iter().collect();
assert_eq!(unique.len(), batch.len(), "batch repeated a handle");
}
#[test]
fn rerolls_can_differ() {
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
let first = generate();
let differs = (0..20).any(|_| generate() != first);
assert!(differs);
+27 -27
View File
@@ -16,7 +16,7 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
KillOutcome, Project, RecentDirs, ReportActivityRequest, RetainedWorktree, Session,
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -115,9 +115,8 @@ impl DaemonClient {
}
/// Kill a session. With `force`, the daemon overrides the kill refusal
/// (#79) and retains the worktree instead of removing it (#81); the
/// outcome then names what was kept and where.
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
/// (#79) and leaves the worktree on disk instead of removing it (#81).
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<(), ClientError> {
let query: &[(&str, &str)] = if force {
&[(routes::FORCE_QUERY, "true")]
} else {
@@ -126,12 +125,19 @@ impl DaemonClient {
let resp = self
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
.await?;
let resp = check(resp).await?;
// An unforced kill keeps its historical `204 No Content`.
if resp.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(KillOutcome { retained: None });
}
json(resp).await
check(resp).await?;
Ok(())
}
/// `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
@@ -149,6 +155,17 @@ impl DaemonClient {
Ok(())
}
/// `POST /api/sessions/:id/input`: type text into a session's pane, with
/// an Enter after it unless `submit` is off. Answers `204 No Content`; a
/// session that has exited is refused rather than silently accepted.
pub async fn send_input(&self, id: Uuid, req: &SendInputRequest) -> Result<(), ClientError> {
let resp = self
.send(Method::POST, &routes::session_input(id), &[], Some(req))
.await?;
check(resp).await?;
Ok(())
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await
}
@@ -175,23 +192,6 @@ impl DaemonClient {
.await
}
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
/// breakdown as of this read.
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
self.get(routes::WORKTREES, &[]).await
}
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
/// daemon removes the directory and drops the store entry; the branch is
/// never deleted.
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
let resp = self
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
.await?;
check(resp).await?;
Ok(())
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
+90
View File
@@ -7,6 +7,15 @@ use std::path::{Path, PathBuf};
use crate::Session;
/// The name a session was given (#125), if any. `None` for one that was never
/// renamed — clients fall back to the command.
pub fn custom_name(s: &Session) -> Option<&str> {
match s.metadata.get("name") {
Some(serde_json::Value::String(name)) => Some(name.as_str()),
_ => None,
}
}
/// 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 {
@@ -140,6 +149,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur
}
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
}
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
}
fn basename(word: &str) -> &str {
match word.rsplit_once('/') {
Some((_, tail)) => tail,
None => word,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -318,4 +384,28 @@ mod tests {
};
assert_eq!(nested.name, "a/b");
}
#[test]
fn command_program_drops_arguments() {
assert_eq!(
command_program("claude --dangerously-skip-permissions"),
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx");
}
#[test]
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
}
+185 -113
View File
@@ -222,23 +222,9 @@ pub struct SessionWorktree {
pub base_repo: PathBuf,
}
/// Why a retained worktree outlived its session (#80). Same values the
/// daemon's retained store persists; new variants are additive but clients
/// should render unknown reasons generically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetainedReason {
/// The session was killed with `--force` while its worktree held unsaved
/// work.
ForceKilled,
/// The session ended by itself and its worktree held unsaved work.
ExitedWithUnsavedWork,
}
/// What a kill refusal would report about a worktree right now (#80) — the
/// same breakdown that blocks an unforced kill, recomputed on every read of
/// [`RetainedWorktree`] so it reflects the tree now rather than when it was
/// retained.
/// What a kill refusal reports about a worktree (#80) — the breakdown that
/// blocks an unforced kill, recomputed on every probe so it reflects the tree
/// now.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InTheWay {
/// Tracked files with uncommitted modifications.
@@ -250,41 +236,72 @@ pub struct InTheWay {
/// Commits on the branch reachable from no remote-tracking ref.
#[serde(default)]
pub unpushed_commits: usize,
/// Lines added across the tracked changes — staged and unstaged alike,
/// measured against `HEAD`. Untracked files contribute nothing: counting
/// their lines means reading every one off disk, and they already have
/// their own count above (docs/SIDEBAR.md).
#[serde(default)]
pub insertions: usize,
/// Lines removed across the same tracked changes.
#[serde(default)]
pub deletions: usize,
}
impl InTheWay {
/// Nothing blocks any more — the work has since been committed and pushed.
///
/// The line counts are not consulted: they describe the same tracked
/// changes `uncommitted_files` counts, and a change with no line delta —
/// a mode change, a rename — still blocks.
pub fn is_clear(&self) -> bool {
*self == Self::default()
self.uncommitted_files == 0 && self.untracked_files == 0 && self.unpushed_commits == 0
}
}
/// One entry of `GET /api/worktrees` (#80): a retained worktree with its
/// in-the-way state as of this read. Reclaiming it via
/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetainedWorktree {
pub id: Uuid,
pub path: PathBuf,
pub base_repo: PathBuf,
pub branch: String,
pub reason: RetainedReason,
pub retained_at: DateTime<Utc>,
#[serde(default)]
pub in_the_way: InTheWay,
/// Which fragment of the compact blocked-work stat a value is, and so which
/// colour the client paints it (docs/SIDEBAR.md).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatKind {
/// Lines added.
Added,
/// Lines removed.
Removed,
/// Changed tracked files, when the change carries no line delta.
Files,
/// Untracked files.
Untracked,
/// Unpushed commits.
Unpushed,
}
/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81).
/// A forced kill ends the session and retains its worktree instead of
/// removing it; this names what was kept, where, and why, so a client can say
/// where the work went rather than leaving the operator to wonder.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KillOutcome {
/// Set when the worktree was retained with reason `force-killed`; `None`
/// when there was nothing to retain — no worktree at all, or one whose
/// guard was clear, which a forced kill removes as usual.
#[serde(default)]
pub retained: Option<RetainedWorktree>,
impl InTheWay {
/// The breakdown as diff notation: `+124`, `-18`, `?3`, `↑2`.
///
/// The line counts cover tracked changes only, so a tree whose only
/// tracked change carries no line delta — a mode change, a rename —
/// reports the file count (`~2`) rather than claiming `+0 -0`. Both
/// sidebars render exactly these fragments; [`Display`](std::fmt::Display)
/// remains the spelled-out phrasing for the kill refusal and the tooltip.
pub fn stats(&self) -> Vec<(StatKind, String)> {
let mut out = Vec::new();
if self.insertions > 0 || self.deletions > 0 {
if self.insertions > 0 {
out.push((StatKind::Added, format!("+{}", self.insertions)));
}
if self.deletions > 0 {
out.push((StatKind::Removed, format!("-{}", self.deletions)));
}
} else if self.uncommitted_files > 0 {
out.push((StatKind::Files, format!("~{}", self.uncommitted_files)));
}
if self.untracked_files > 0 {
out.push((StatKind::Untracked, format!("?{}", self.untracked_files)));
}
if self.unpushed_commits > 0 {
out.push((StatKind::Unpushed, format!("↑{}", self.unpushed_commits)));
}
out
}
}
impl std::fmt::Display for InTheWay {
@@ -489,12 +506,55 @@ pub struct DaemonInfo {
}
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
/// `adjective-noun` session handles generated by the `maestro-names` crate.
/// one-word session handles generated by the `maestro-names` crate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NameBatch {
pub names: Vec<String>,
}
/// HTTP `POST /api/sessions/:id/input` body: text typed into a session's
/// pane, as if the operator had typed it on an attached terminal.
///
/// `submit` presses Enter after the text — what makes `maestro send` a way to
/// give an agent an instruction rather than only to fill its prompt. It goes
/// out as its own write a beat later, because a TUI agent that reads the text
/// as one paste would otherwise swallow the newline into the message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SendInputRequest {
pub text: String,
#[serde(default = "submit_default")]
pub submit: bool,
}
fn submit_default() -> bool {
true
}
/// 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`)
// ---------------------------------------------------------------------------
@@ -561,6 +621,50 @@ mod tests {
assert!(is_compatible("unknown"));
}
/// docs/SIDEBAR.md: the sidebars render the breakdown as diff notation.
#[test]
fn stats_read_as_a_diff() {
let w = InTheWay {
uncommitted_files: 3,
untracked_files: 2,
unpushed_commits: 1,
insertions: 124,
deletions: 18,
};
let text: Vec<String> = w.stats().into_iter().map(|(_, t)| t).collect();
assert_eq!(text, ["+124", "-18", "?2", "↑1"]);
let kinds: Vec<StatKind> = w.stats().into_iter().map(|(k, _)| k).collect();
assert_eq!(
kinds,
[
StatKind::Added,
StatKind::Removed,
StatKind::Untracked,
StatKind::Unpushed
]
);
}
/// A change with no line delta — a mode change — still has to say
/// something, and `+0 -0` would be a lie about the tree.
#[test]
fn stats_fall_back_to_the_file_count_without_a_line_delta() {
let w = InTheWay {
uncommitted_files: 2,
..InTheWay::default()
};
let text: Vec<String> = w.stats().into_iter().map(|(_, t)| t).collect();
assert_eq!(text, ["~2"]);
assert!(!w.is_clear());
}
/// A clear tree says nothing at all — the fragment is omitted, not empty.
#[test]
fn a_clear_tree_has_no_stats() {
assert!(InTheWay::default().stats().is_empty());
assert!(InTheWay::default().is_clear());
}
fn round_trip<T>(value: &T) -> T
where
T: Serialize + for<'de> Deserialize<'de>,
@@ -848,76 +952,6 @@ mod tests {
assert_eq!(spec, round_trip(&spec));
}
fn sample_retained_worktree() -> RetainedWorktree {
RetainedWorktree {
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
path: PathBuf::from("/work/maestro/worktrees/feat/login"),
base_repo: PathBuf::from("/work/maestro"),
branch: "feat/login".into(),
reason: RetainedReason::ForceKilled,
retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z")
.unwrap()
.with_timezone(&Utc),
in_the_way: InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
}
}
#[test]
fn retained_worktree_round_trips() {
let wt = sample_retained_worktree();
assert_eq!(wt, round_trip(&wt));
}
/// A payload from a daemon predating `in_the_way` still decodes; the
/// breakdown defaults to "nothing in the way".
#[test]
fn retained_worktree_defaults_in_the_way_on_legacy_payload() {
let legacy = serde_json::json!({
"id": Uuid::from_u128(1),
"path": "/w/t",
"base_repo": "/w",
"branch": "feat/login",
"reason": "force-killed",
"retained_at": "2026-08-24T10:00:00Z",
});
let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap();
assert_eq!(wt.reason, RetainedReason::ForceKilled);
assert_eq!(wt.in_the_way, InTheWay::default());
assert!(wt.in_the_way.is_clear());
}
#[test]
fn retained_reason_serializes_kebab_case() {
assert_eq!(
serde_json::to_value(RetainedReason::ForceKilled).unwrap(),
serde_json::json!("force-killed")
);
assert_eq!(
serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(),
serde_json::json!("exited-with-unsaved-work")
);
}
#[test]
fn kill_outcome_round_trips_a_retained_worktree() {
let outcome = KillOutcome {
retained: Some(sample_retained_worktree()),
};
assert_eq!(outcome, round_trip(&outcome));
}
/// A kill with nothing to retain — no worktree, or a clean one removed as
/// usual — decodes from an absent field.
#[test]
fn kill_outcome_defaults_to_nothing_retained() {
let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(outcome.retained, None);
}
#[test]
fn catalog_event_round_trips_and_tags() {
let session = sample_session();
@@ -964,4 +998,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"})
);
}
}
+13 -17
View File
@@ -26,9 +26,6 @@ pub const BRANCHES: &str = "/api/branches";
/// `GET` a batch of generated session handles (#53). Takes a `count` query
/// param (default 1, capped at [`maestro_names`]' batch cap).
pub const NAMES: &str = "/api/names";
/// `GET` retained worktrees (#80): what the store holds, with the in-the-way
/// breakdown recomputed on read.
pub const WORKTREES: &str = "/api/worktrees";
/// `WS` catalog snapshot + session deltas.
pub const EVENTS: &str = "/api/events";
@@ -39,7 +36,7 @@ pub const PATH_QUERY: &str = "path";
pub const COUNT_QUERY: &str = "count";
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
/// `force=true` overrides the kill refusal and retains the worktree.
/// `force=true` overrides the kill refusal and leaves the worktree on disk.
pub const FORCE_QUERY: &str = "force";
/// `DELETE /api/sessions/:id`.
@@ -52,9 +49,9 @@ 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}")
/// `POST /api/sessions/:id/input`: type text into a session's pane.
pub fn session_input(id: Uuid) -> String {
format!("{SESSIONS}/{id}/input")
}
/// `WS /api/attach/:id` — raw PTY bytes.
@@ -67,8 +64,8 @@ pub fn attach(id: Uuid) -> String {
pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
pub const ATTACH: &str = "/api/attach/:id";
pub const WORKTREE: &str = "/api/worktrees/:id";
}
/// A daemon base URL that could not be turned into a WebSocket base.
@@ -180,12 +177,12 @@ mod tests {
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
);
assert_eq!(
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
session_input(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
);
assert_eq!(
worktree(id()),
"/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
attach_url("https://laptop.lan:7070", id()).unwrap(),
@@ -206,12 +203,12 @@ mod tests {
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
patterns::SESSION_INPUT.trim_end_matches(":id/input"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::WORKTREE.trim_end_matches(":id"),
worktree(id()).trim_end_matches(&id().to_string())
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
);
}
@@ -226,7 +223,6 @@ mod tests {
GITROOT,
BRANCHES,
NAMES,
WORKTREES,
EVENTS,
] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
+32 -2
View File
@@ -16,13 +16,13 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
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 chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot};
@@ -55,6 +55,8 @@ struct Inner {
info: Option<DaemonInfo>,
/// When set, every route answers with this instead of its normal response.
forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
}
#[derive(Clone)]
@@ -86,6 +88,7 @@ impl MockDaemon {
let app = Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
@@ -167,6 +170,11 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").forced_error = None;
}
/// Every `send` this daemon received, oldest first.
pub fn inputs(&self) -> Vec<(Uuid, SendInputRequest)> {
self.state.inner.lock().expect("poisoned").inputs.clone()
}
pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len()
}
@@ -233,6 +241,28 @@ async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Respon
StatusCode::NO_CONTENT.into_response()
}
/// Records the text instead of typing it — the subject under test is the
/// client's request, not tmux.
async fn send_input(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<SendInputRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut guard = s.inner.lock().expect("poisoned");
if !guard.sessions.iter().any(|x| x.id == id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
guard.inputs.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
+69 -45
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,15 +48,9 @@ pub enum Action {
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Tabs
NewTab,
CloseTab,
NextTab,
PrevTab,
GotoTab(u8),
/// Rename the highlighted session (#125): opens a prompt pre-filled with
/// its current name.
RenameHighlighted,
// Chrome
ToggleSidebar,
@@ -70,8 +69,39 @@ 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::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 +109,17 @@ 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),
"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),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
@@ -111,13 +140,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 +159,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 +175,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 +194,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 +208,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 +224,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
+55 -16
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,18 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("w", "ShowWorktrees"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
("g T", "PrevTab"),
("r", "RenameHighlighted"),
// 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 +77,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 +146,7 @@ impl TuiConfig {
}
Ok(TuiConfig {
palette,
leader,
chord_timeout,
keymap,
@@ -143,6 +159,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 +202,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),
@@ -167,10 +210,6 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "w"),
Some(Action::ShowWorktrees),
);
}
#[test]
+102 -82
View File
@@ -18,8 +18,9 @@
//!
//! The picker itself is two steps (#96), verbatim from the rules in
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
//! 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.
//! 1. Step one is one box per repository (name, then its parent directory).
//! 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
@@ -32,6 +33,8 @@
//! these are separate acts.
//! 6. Neither step probes `/api/gitroot`; the scan already reports
//! repository membership. Only the free-text hatch pays for a probe.
//! 7. Neither step touches the command: the directory and what runs in it
//! are independent choices.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
@@ -39,11 +42,12 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::input::InputKey;
@@ -128,6 +132,15 @@ pub struct DraftWorktreeRow {
pub stale: bool,
}
/// A box's second column: the directory holding the project, `~`-abbreviated.
/// Only the stem — the last segment is the name column beside it.
fn parent_dir_label(path: &Path, home: &str) -> String {
match path.parent() {
Some(parent) => abbreviate_dir(parent, home),
None => String::new(),
}
}
/// The repository key a [`DraftGroup`] was formed under — same key
/// `projects::group_projects` groups by, kept separately because the main
/// box's own path only equals it when the main checkout itself was found.
@@ -699,12 +712,13 @@ impl DraftPane {
order.push(DraftField::DirPicker);
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
} else if self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
order.push(DraftField::Name);
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
order.push(DraftField::WorktreeToggle);
if self.worktree_body_visible() {
order.push(DraftField::BranchMode);
@@ -892,13 +906,6 @@ impl DraftPane {
self.worktree_choice = choice.clone();
self.picked_cwd = Some(target.path.clone());
self.cwd_raw_visible = false;
if let Some(cmd) = target.last_command.as_deref() {
if !cmd.is_empty() {
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
self.selected_preset = idx;
}
}
}
match choice {
WorktreeChoice::New => {
self.worktree_enabled = true;
@@ -1078,20 +1085,17 @@ impl DraftPane {
/// cursor placement the same way a session pane hides its cursor when
/// unfocused.
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
let block = Block::default().title(" draft ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let inner = area;
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
// The worktree toggle and its body stay behind the "Advanced options"
// disclosure (#93): collapsed hides both, regardless of what they'd
// otherwise show. The worktree *choice* step is not part of that —
// it belongs to the directory picker (#95 rule 3), so it stays
// visible once a project is picked.
// The worktree choice step, the worktree toggle and its body all stay
// behind the "Advanced options" disclosure (#93): collapsed hides
// them, regardless of what they'd otherwise show.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice = self.selected_group_key.is_some() && !show_raw;
let show_wt_choice =
!self.advanced_collapsed && self.selected_group_key.is_some() && !show_raw;
let wt_choice_rows_data = if show_wt_choice {
self.worktree_choice_rows()
} else {
@@ -1120,11 +1124,12 @@ impl DraftPane {
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
constraints.push(Constraint::Length(1));
} else if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
if show_wt_toggle {
constraints.push(Constraint::Length(1)); // worktree toggle
}
@@ -1201,9 +1206,9 @@ impl DraftPane {
cursor = Some((col, filter_row.y));
}
// Step one (#96 rule 1): one box per repository, name then the FULL
// path — not the abbreviated form other lists use — so two projects
// sharing a basename stay distinguishable.
// Step one (#96 rule 1): one box per repository, name then the parent
// directory it sits in. The last segment is the name column already;
// the stem is what keeps two projects sharing a basename apart.
let visible = self.visible_groups();
let sel = self.selection();
let count = visible.len() + 1;
@@ -1242,7 +1247,10 @@ impl DraftPane {
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{:<name_width$}", group.main.name), base),
Span::styled(format!(" {}", group.main.path.display()), base),
Span::styled(
format!(" {}", parent_dir_label(&group.main.path, &self.home_dir)),
base,
),
])
} else {
let mut style = Style::default();
@@ -1270,7 +1278,52 @@ impl DraftPane {
if self.focused_field == DraftField::CwdRaw {
cursor = Some((col, row.y));
}
} else if show_wt_choice {
}
// -- name + reroll ---------------------------------------------------------
let name_row = next_row();
draw_gutter(f, name_row, "name:");
let col = draw_text_field(f, &self.name, name_row, field_style(DraftField::Name));
if self.focused_field == DraftField::Name {
cursor = Some((col, name_row.y));
}
// Dim reroll hint, tucked after the typed name without covering it.
let hint_offset = (GUTTER as u16 + self.name.value().chars().count() as u16 + 1)
.min(name_row.width.saturating_sub(10));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"^R reroll",
Style::default().add_modifier(Modifier::DIM),
))),
Rect {
x: name_row.x + hint_offset,
y: name_row.y,
width: name_row.width.saturating_sub(hint_offset),
height: 1,
},
);
// -- advanced disclosure (#93) ------------------------------------------
let advanced_row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "adv:")),
Span::styled(
format!(
"[{}] Advanced options",
if self.advanced_collapsed { "+" } else { "-" }
),
field_style(DraftField::Advanced),
),
Span::styled(
format!(" — {}", self.advanced_summary()),
Style::default().add_modifier(Modifier::DIM),
),
])),
advanced_row,
);
if show_wt_choice {
// Step two (#96 rule 3): "+ new" first, then the repository's
// worktrees newest-first.
let choice_rows = wt_choice_rows_data;
@@ -1319,49 +1372,6 @@ impl DraftPane {
}
}
// -- name + reroll ---------------------------------------------------------
let name_row = next_row();
draw_gutter(f, name_row, "name:");
let col = draw_text_field(f, &self.name, name_row, field_style(DraftField::Name));
if self.focused_field == DraftField::Name {
cursor = Some((col, name_row.y));
}
// Dim reroll hint, tucked after the typed name without covering it.
let hint_offset = (GUTTER as u16 + self.name.value().chars().count() as u16 + 1)
.min(name_row.width.saturating_sub(10));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"^R reroll",
Style::default().add_modifier(Modifier::DIM),
))),
Rect {
x: name_row.x + hint_offset,
y: name_row.y,
width: name_row.width.saturating_sub(hint_offset),
height: 1,
},
);
// -- advanced disclosure (#93) ------------------------------------------
let advanced_row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "adv:")),
Span::styled(
format!(
"[{}] Advanced options",
if self.advanced_collapsed { "+" } else { "-" }
),
field_style(DraftField::Advanced),
),
Span::styled(
format!(" — {}", self.advanced_summary()),
Style::default().add_modifier(Modifier::DIM),
),
])),
advanced_row,
);
// -- worktree (#65) -----------------------------------------------------
if show_wt_toggle {
let wt_row = next_row();
@@ -1740,8 +1750,9 @@ mod tests {
let stale_pos = rendered.find("stale-repo").unwrap();
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
assert!(
rendered.contains("/home/u/new/repo"),
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
rendered.contains("~/new") && !rendered.contains("~/new/repo"),
"the parent directory only — the name column is the last segment \
(#96 rule 1):\n{rendered}"
);
// Staleness dims the row's cells.
@@ -1786,7 +1797,7 @@ mod tests {
assert_eq!(
draft.command(),
"claude",
"pick pre-selects the last command"
"the preset is the operator's own choice — picking a project leaves it alone"
);
}
@@ -2117,7 +2128,7 @@ mod tests {
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (gutter is 5 chars, block border takes column 0).
/// field (gutter is 5 chars and the pane has no border).
#[test]
fn render_places_the_cursor_in_the_focused_text_field() {
let mut draft = DraftPane {
@@ -2129,10 +2140,7 @@ mod tests {
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(
pos,
ratatui::layout::Position::new(1 + GUTTER as u16 + 2, 2)
);
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1));
}
#[test]
@@ -2212,9 +2220,14 @@ mod tests {
assert_eq!(draft.worktree_choice, WorktreeChoice::New);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/main")));
assert!(draft.worktree_enabled, "\"+ new\" means create-worktree on");
assert!(
!draft.field_order().contains(&DraftField::WorktreeChoice),
"step two lives behind the collapsed \"Advanced options\" disclosure"
);
draft.toggle_advanced();
assert!(
draft.field_order().contains(&DraftField::WorktreeChoice),
"step two is now reachable"
"step two is reachable once advanced is open"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
@@ -2627,11 +2640,18 @@ mod tests {
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks the "main" box
let collapsed = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
!collapsed.contains("+ new"),
"step two hides with \"Advanced options\" closed:\n{collapsed}"
);
draft.toggle_advanced();
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("/home/u/repos/main"),
"step one shows the full repository path:\n{rendered}"
rendered.contains("~/repos"),
"step one shows the directory holding the repository:\n{rendered}"
);
assert!(
rendered.contains("+ new"),
+10 -3
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,
@@ -136,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`.
@@ -150,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",
@@ -182,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,
@@ -207,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),
_ => {}
}
@@ -281,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,
})
@@ -617,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);
}
+1 -2
View File
@@ -20,10 +20,8 @@ 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;
use std::path::PathBuf;
use std::time::Duration;
@@ -90,6 +88,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
+68 -6
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,10 +8,13 @@
//! `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,
@@ -29,7 +33,6 @@ impl KillConfirm {
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."));
}
@@ -39,6 +42,49 @@ impl KillConfirm {
}
}
/// Rename prompt for the highlighted session (#125).
///
/// Borrows its text buffer rather than owning it: the buffer lives on `App`,
/// 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(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))
}
}
#[derive(Debug, Clone)]
pub struct HelpOverlay {
pub lines: Vec<String>,
@@ -151,16 +197,32 @@ mod tests {
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"));
}
}
+575 -86
View File
@@ -16,9 +16,12 @@
//! the selected card uses the selection surface's own state colours; the dot
//! keeps carrying the state either way (docs/SIDEBAR.md).
//!
//! One palette ships today, reached through [`active`]. Selecting between
//! presets from config is a separate issue; [`active`] is the seam it
//! replaces.
//! [`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.
@@ -110,6 +113,8 @@ pub struct Palette {
/// 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.
@@ -130,40 +135,489 @@ pub struct Palette {
}
impl Palette {
/// The one palette that ships today.
pub const DEFAULT: Palette = Palette {
/// The dark terminal-native palette and the default preset.
pub const DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x14, 0x16, 0x1b),
fg: Color::Rgb(0xd6, 0xdc, 0xe5),
muted: Color::Rgb(0x9a, 0xa3, 0xb2),
dim: Color::Rgb(0x6b, 0x72, 0x80),
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(0x8b, 0x94, 0x9e),
idle: Color::Rgb(0x91, 0x98, 0xa1),
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
error: Color::Rgb(0xf8, 0x51, 0x49),
},
selection: Surface {
bg: Color::Rgb(0x2b, 0x3f, 0x6e),
fg: Color::Rgb(0xf0, 0xf4, 0xfa),
muted: Color::Rgb(0xc0, 0xc9, 0xda),
dim: Color::Rgb(0x93, 0xa0, 0xbc),
active: Color::Rgb(0x7e, 0xe7, 0x87),
idle: Color::Rgb(0xb6, 0xc0, 0xd4),
awaiting: Color::Rgb(0xf2, 0xcc, 0x60),
error: Color::Rgb(0xff, 0x94, 0x92),
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(0x14, 0x16, 0x1b),
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(0x6b, 0x72, 0x80),
border_unfocused: Color::Rgb(0x58, 0x64, 0x70),
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
group_row: Color::Rgb(0x8f, 0xa3, 0xc8),
group_row: Color::Rgb(0x91, 0x98, 0xa1),
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
mode_normal_fg: Color::Rgb(0x0b, 0x0d, 0x11),
mode_normal_fg: Color::Rgb(0x0d, 0x11, 0x17),
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
mode_insert_fg: Color::Rgb(0x0b, 0x0d, 0x11),
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 {
@@ -218,9 +672,20 @@ impl Default for Palette {
}
}
/// The palette in force. Config-driven preset selection lands here.
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 {
Palette::DEFAULT
*active_slot().read().expect("active palette lock poisoned")
}
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
@@ -260,34 +725,53 @@ mod tests {
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() {
let p = active();
for (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!("{name}.{role} must be an RGB value"));
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{name}.{role} contrast {ratio:.2} is below {floor}"
);
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() {
let p = active();
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
assert!(
ratio >= 1.6,
"card and selection backgrounds differ by only {ratio:.2}"
);
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
@@ -295,13 +779,14 @@ mod tests {
/// selection surface carries its own — distinct — state colours.
#[test]
fn selection_state_colours_are_not_the_card_state_colours() {
let p = active();
for state in STATES {
assert_ne!(
p.card.activity(state),
p.selection.activity(state),
"{state:?} must have a selection-specific colour"
);
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"
);
}
}
}
@@ -310,44 +795,48 @@ mod tests {
/// actually painted on; they must never rely on the terminal default.
#[test]
fn chrome_roles_are_rgb_values() {
let p = active();
for (name, color) in [
("chrome_bg", p.chrome_bg),
("border_focused", p.border_focused),
("border_unfocused", p.border_unfocused),
("focus_accent", p.focus_accent),
("group_row", p.group_row),
("mode_normal_bg", p.mode_normal_bg),
("mode_normal_fg", p.mode_normal_fg),
("mode_insert_bg", p.mode_insert_bg),
("mode_insert_fg", p.mode_insert_fg),
("error_text", p.error_text),
] {
assert!(
relative_luminance(color).is_some(),
"{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, "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,
"{name} contrast {ratio:.2} is below {floor} on chrome_bg"
);
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"
);
}
}
}
+1 -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));
+130 -99
View File
@@ -1,29 +1,27 @@
//! 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::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::worktrees::{ReclaimConfirm, WorktreesView};
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`].
#[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>,
}
@@ -35,11 +33,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;
@@ -52,6 +45,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 {
@@ -63,14 +74,15 @@ 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
@@ -102,36 +114,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);
}
// The pane always owns its full border, so focus changes only recolour
// chrome and never change the rectangles handed to the input router.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(right);
let tab_area = rows[0];
let title_area = rows[0];
let pane_area = rows[1];
inputs.tab_bar.render(f, tab_area);
map.tab_bar = Some(tab_area);
let pane_focused = inputs.state.focus == Focus::Pane;
let pane_block = Block::default()
.borders(Borders::ALL)
.border_style(focus_style(pane_focused));
let pane_inner = pane_block.inner(pane_area);
f.render_widget(pane_block, pane_area);
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
if let Some(p) = inputs.pane {
p.render(f, pane_inner);
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_inner, pane_focused);
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
@@ -157,24 +169,25 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
kc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Help) => {
inputs.help.render(f, 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::Worktrees) => {
if let Some(view) = inputs.worktrees {
view.render(f, area);
map.modal = Some(area);
}
}
Some(OverlayKind::ReclaimConfirm {
branch, blockers, ..
}) => {
let rc = ReclaimConfirm {
branch: branch.clone(),
blockers: blockers.clone(),
};
rc.render(f, area);
Some(OverlayKind::Help) => {
inputs.help.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Toast(text)) => {
@@ -187,10 +200,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
map
}
/// Border style for a panel based on whether it currently has focus. Both
/// forms are palette roles (`border_focused` / `border_unfocused`).
fn focus_style(focused: bool) -> Style {
palette::active().border_style(focused)
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 {
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
@@ -207,7 +225,7 @@ 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",
Mode::Insert => "^a: return to sidebar",
};
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
let line = ratatui::text::Line::from(vec![
@@ -231,22 +249,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);
}
@@ -260,17 +272,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,
@@ -284,6 +291,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]
@@ -303,19 +344,16 @@ 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);
@@ -334,7 +372,7 @@ mod tests {
out.push('\n');
}
for s in [
"draft",
"new session",
"cmd:",
"dir:",
"name:",
@@ -356,22 +394,19 @@ 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 draw_map = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
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();
@@ -383,12 +418,12 @@ mod tests {
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.tab_bar, pane_focus.tab_bar);
assert_eq!(sidebar_focus.pane, pane_focus.pane);
assert_eq!(sidebar_focus.pane, Some(Rect::new(28, 1, 52, 22)));
}
#[test]
fn panel_border_colours_follow_focus_without_restyling_the_pane() {
fn panel_focus_uses_backgrounds_without_borders() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
@@ -398,22 +433,19 @@ 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 render = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
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();
@@ -428,12 +460,11 @@ mod tests {
let sidebar_focused = render(Focus::Sidebar);
let pane_focused = render(Focus::Pane);
let p = palette::active();
assert_eq!(sidebar_focused[(0, 0)].style().fg, Some(p.border_focused));
assert_eq!(pane_focused[(0, 0)].style().fg, Some(p.border_unfocused));
assert_eq!(
sidebar_focused[(28, 1)].style().fg,
Some(p.border_unfocused)
);
assert_eq!(pane_focused[(28, 1)].style().fg, Some(p.border_focused));
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();
+409 -107
View File
@@ -4,18 +4,23 @@
//! Line 2: activity dot + duration in the state's colour, plus the
//! adaptive detail, blocked-work, exited, and command fragments.
use std::collections::HashSet;
use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;
use chrono::Utc;
use maestro_protocol::{ActivityState, Session, SessionStatus};
use maestro_protocol::display::{
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
};
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use uuid::Uuid;
use crate::palette;
use crate::state::SidebarItem;
/// Time-in-state earns a duration fragment once the session has sat in its
/// current activity state this long (docs/SIDEBAR.md).
@@ -28,55 +33,190 @@ const META_LINE_AFTER_SECS: i64 = 30;
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
const INDENT: &str = " ";
const META_INDENT: &str = " ";
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
const ACTIONS: &str = "⧉ ✕";
/// Cards use the selection surface only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
/// keyboard via a filled selection background.
fn card_base(highlighted: bool, focused: bool) -> Style {
palette::active()
.surface(highlighted && focused)
.text_style()
let palette = palette::active();
let mut style = palette.surface(highlighted && focused).text_style();
if focused && !highlighted {
style = style.bg(palette.sidebar_focus_bg);
}
style
}
/// Renderable sidebar state.
#[derive(Debug, Clone)]
pub struct Sidebar {
pub sessions: Vec<Session>,
pub highlight: Option<Uuid>,
pub highlight: Option<SidebarItem>,
pub attached: HashSet<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SidebarGroup {
pub key: PathBuf,
pub sessions: Vec<Session>,
pub collapsed: bool,
}
impl Sidebar {
pub fn render(&self, f: &mut Frame, area: Rect) {
self.render_with_focus(f, area, false);
}
/// Render the panel with a persistent border. Focus changes its colour
/// and whether the highlighted card claims the selection surface, never
/// its geometry.
/// Render the panel without a border. Focus is the panel background, so
/// it remains obvious without spending rows or columns on chrome.
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
let border_style = palette::active().border_style(focused);
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
f.render_widget(block, area);
self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new());
}
let width = inner.width as usize;
pub fn render_with_focus_and_collapsed(
&self,
f: &mut Frame,
area: Rect,
focused: bool,
collapsed: &HashSet<PathBuf>,
) {
self.render_with_focus_and_collapsed_home(f, area, focused, collapsed, &home_dir());
}
pub fn render_with_focus_and_collapsed_home(
&self,
f: &mut Frame,
area: Rect,
focused: bool,
collapsed: &HashSet<PathBuf>,
home: &str,
) {
let palette = palette::active();
let panel_bg = if focused {
palette.sidebar_focus_bg
} else {
palette.chrome_bg
};
f.render_widget(Block::default().style(Style::default().bg(panel_bg)), area);
let width = area.width as usize;
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
"sessions",
Style::default().add_modifier(Modifier::BOLD),
Style::default()
.bg(panel_bg)
.fg(palette.card.fg)
.add_modifier(Modifier::BOLD),
))];
for s in &self.sessions {
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted, focused);
lines.push(Line::from(self.name_row(s, width, focused)));
let meta = self.meta_row(s, width, focused);
lines.push(Line::from(pad_to_width(meta, width, base)));
for group in self.groups(collapsed) {
lines.push(Line::from(self.group_row(&group, width, focused, home)));
if !group.collapsed {
for s in &group.sessions {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
lines.push(Line::from(self.name_row(s, width, focused)));
let meta = self.meta_row(s, width, focused);
lines.push(Line::from(pad_to_width(meta, width, base)));
}
}
}
f.render_widget(Paragraph::new(lines), inner);
f.render_widget(Paragraph::new(lines), area);
}
/// Items in exactly the order and height they occupy on screen.
pub fn rendered_items(&self) -> Vec<SidebarItem> {
self.rendered_items_with_collapsed(&HashSet::new())
}
pub fn rendered_items_with_collapsed(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarItem> {
self.groups(collapsed)
.into_iter()
.flat_map(|group| {
let mut items = vec![SidebarItem::Group(group.key)];
if !group.collapsed {
items.extend(
group
.sessions
.into_iter()
.map(|s| SidebarItem::Session(s.id)),
);
}
items
})
.collect()
}
pub fn groups(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarGroup> {
let mut grouped: BTreeMap<PathBuf, Vec<Session>> = BTreeMap::new();
for session in &self.sessions {
if let Some(key) = session_tree_path(session) {
grouped.entry(key).or_default().push(session.clone());
}
}
grouped
.into_iter()
.map(|(key, mut sessions)| {
sessions
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
SidebarGroup {
collapsed: collapsed.contains(&key),
key,
sessions,
}
})
.collect()
}
fn group_row(
&self,
group: &SidebarGroup,
width: usize,
focused: bool,
home: &str,
) -> Vec<Span<'static>> {
let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone()));
let palette = palette::active();
let surface = palette.surface(highlighted && focused);
let row_bg = if highlighted {
surface.bg
} else if focused {
palette.sidebar_focus_bg
} else {
surface.bg
};
let base = Style::default().bg(row_bg).fg(if highlighted {
surface.fg
} else {
palette.group_row
});
let edge = highlighted && !focused;
let prefix = if edge { "▏ " } else { " " };
let chevron = if group.collapsed { "▸ " } else { "▾ " };
let path = abbreviate_dir(&group.key, home);
let name = abbreviate_folder_name(&path);
let count = format!(" ({})", group.sessions.len());
let prefix = Span::styled(
prefix,
if edge {
base.fg(palette.focus_accent)
} else {
base
},
);
let chevron = Span::styled(chevron, base);
let count = Span::styled(count, surface.muted_style().bg(row_bg));
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
let mut label = Vec::new();
if let Some(stem) = name.stem {
label.push(Span::styled(
format!("{stem}/"),
surface.dim_style().bg(row_bg),
));
}
label.push(Span::styled(name.last, base));
let mut spans = vec![prefix, chevron];
spans.extend(fit_to_width(label, label_budget));
spans.push(count);
pad_to_width(spans, width, base)
}
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
@@ -85,22 +225,10 @@ impl Sidebar {
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let highlighted = self.highlight == Some(s.id);
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
// Line-1 actions are keyboard affordances shown on the highlighted
// row only; they reserve their width so the name truncates with `…`
// before it can ever reach them (docs/SIDEBAR.md).
let actions_width = if highlighted {
ACTIONS.chars().count()
} else {
0
};
let usable = width.saturating_sub(actions_width);
let prefix = INDENT.chars().count() + 2;
// Keep one column of separation between a truncated name and the
// actions so the ellipsis never touches them.
let sep = if highlighted { 1 } else { 0 };
let name_budget = usable.saturating_sub(prefix + sep);
let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused;
let mut spans: Vec<Span<'static>> = vec![
@@ -119,14 +247,7 @@ impl Sidebar {
.into_iter()
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
);
let used: usize = spans.iter().map(|sp| sp.width()).sum();
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
spans.push(Span::styled(" ".repeat(fill), base));
}
if highlighted {
spans.push(Span::styled(ACTIONS, base));
}
spans
pad_to_width(spans, width, base)
}
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
@@ -138,13 +259,14 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(s.id);
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let dot_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let muted = palette::active()
.surface(highlighted && focused)
.muted_style();
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused;
let mut spans = vec![
Span::styled(
@@ -163,12 +285,22 @@ impl Sidebar {
}
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
let palette = palette::active();
let surface = palette.surface(highlighted && focused);
for (kind, text) in b.stats() {
let style = match kind {
StatKind::Added => muted.fg(surface.active),
StatKind::Removed => muted.fg(surface.error),
_ => muted,
};
tail.push(Span::styled(format!(" {text}"), style));
}
}
if dead {
tail.push(Span::styled(" exited", muted));
}
let command = Span::styled(format!(" {}", s.command), muted);
// docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
// Reserve the triggered status fragments before detail and command.
// If the row is exceptionally narrow, keep one column for the
@@ -261,14 +393,23 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
}
/// `metadata.name` if present, else the command line.
fn name_for(s: &Session) -> String {
if let Some(serde_json::Value::String(name)) = s.metadata.get("name") {
name.clone()
} else {
s.command.clone()
pub(crate) fn name_for(s: &Session) -> String {
custom_name_for(s).unwrap_or_else(|| s.command.clone())
}
/// The session's own name, without the command fallback (#125): `None` for a
/// session that was never given one.
pub(crate) fn custom_name_for(s: &Session) -> Option<String> {
match s.metadata.get("name") {
Some(serde_json::Value::String(name)) => Some(name.clone()),
_ => None,
}
}
fn home_dir() -> String {
std::env::var("HOME").unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -314,6 +455,15 @@ mod tests {
terminal.backend().buffer().clone()
}
fn draw_collapsed(sidebar: &Sidebar, w: u16, h: u16, collapsed: &HashSet<PathBuf>) -> Buffer {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| sidebar.render_with_focus_and_collapsed(f, f.area(), true, collapsed))
.unwrap();
terminal.backend().buffer().clone()
}
fn rows(buf: &Buffer) -> Vec<String> {
(0..buf.area.height)
.map(|y| {
@@ -360,6 +510,102 @@ mod tests {
);
}
#[test]
fn groups_worktrees_under_their_base_repository() {
let mut s = session_named(1, "claude");
s.cwd = "/home/u/repos/worktrees/feature".into();
s.worktree = Some(maestro_protocol::SessionWorktree {
path: s.cwd.clone(),
base_repo: "/home/u/repos/maestro".into(),
});
let groups = (Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
})
.groups(&HashSet::new());
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
}
#[test]
fn does_not_render_groups_without_sessions() {
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: Default::default(),
};
assert!(sidebar.groups(&HashSet::new()).is_empty());
assert!(sidebar.rendered_items().is_empty());
}
#[test]
fn collapsing_a_group_hides_cards_but_keeps_the_count_and_item() {
let sidebar = Sidebar {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
};
let key = PathBuf::from("/tmp");
let mut collapsed = HashSet::new();
collapsed.insert(key.clone());
let groups = sidebar.groups(&collapsed);
assert_eq!(groups[0].sessions.len(), 2);
assert!(groups[0].collapsed);
assert_eq!(
sidebar.rendered_items_with_collapsed(&collapsed),
vec![SidebarItem::Group(key)]
);
let rendered = render_to_string(&draw_collapsed(&sidebar, 40, 6, &collapsed));
assert!(rendered.contains("(2)"));
assert!(!rendered.contains("claude"));
}
#[test]
fn long_group_labels_yield_to_the_always_visible_count() {
let mut session = session_named(1, "claude");
session.cwd = "/very/long/project/path/extraordinarily-long-project-name".into();
let sidebar = Sidebar {
sessions: vec![session],
highlight: None,
attached: Default::default(),
};
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
let collapsed = HashSet::from([key]);
let rendered = render_to_string(&draw_collapsed(&sidebar, 28, 6, &collapsed));
assert!(rendered.contains('…'));
assert!(
rendered.contains("(1)"),
"group count was truncated: {rendered:?}"
);
}
#[test]
fn shared_display_helpers_abbreviate_group_labels() {
let path = maestro_protocol::display::abbreviate_dir(
std::path::Path::new("/home/u/git.naps.pt/naps62/maestro"),
"/home/u",
);
let name = maestro_protocol::display::abbreviate_folder_name(&path);
assert_eq!(path, "~/git.naps.pt/naps62/maestro");
assert_eq!(name.stem.as_deref(), Some("~/g/n"));
assert_eq!(name.last, "maestro");
}
#[test]
fn rendered_cards_are_always_two_lines_after_one_group_row() {
let sidebar = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sidebar, 40, 8));
assert!(all[1].contains("/tmp"));
assert!(all[2].contains("claude"));
assert!(all[3].contains("bash"));
assert!(!all[4].contains("bash"));
}
#[test]
fn attached_session_uses_filled_glyph() {
let sid = Uuid::from_u128(1);
@@ -393,14 +639,18 @@ mod tests {
}
/// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not
/// on the name row; a clear or unprobed worktree never warns.
/// on the name row, and reads as diff notation; a clear or unprobed
/// worktree says nothing.
#[test]
fn blocked_session_warns_on_its_meta_line() {
fn blocked_session_states_its_diff_on_the_meta_line() {
let mut blocked = session_named(1, "claude");
blocked.activity_since = Utc::now() - Duration::seconds(5);
blocked.blocked_work = Some(InTheWay {
uncommitted_files: 2,
untracked_files: 3,
unpushed_commits: 4,
..InTheWay::default()
insertions: 124,
deletions: 18,
});
let mut clear = session_named(2, "bash");
clear.blocked_work = Some(InTheWay::default());
@@ -410,31 +660,66 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 10));
let all = rows(&draw(&sb, 44, 10));
let meta = all
.iter()
.find(|l| l.contains("⚠"))
.expect("blocked card must warn somewhere");
assert!(
meta.contains("4 unpushed commits"),
"warning must carry InTheWay's summary: {meta:?}"
);
.find(|l| l.contains("+124"))
.expect("blocked card must state its diff somewhere");
for fragment in ["-18", "?3", "↑4"] {
assert!(
meta.contains(fragment),
"meta line must carry {fragment}: {meta:?}"
);
}
let name_row = row_containing(&all, "claude");
assert!(
!name_row.contains('⚠'),
"warning moved off the name row: {name_row:?}"
!name_row.contains("+124"),
"the diff stays off the name row: {name_row:?}"
);
for name in ["bash", "vim"] {
let idx = all.iter().position(|l| l.contains(name)).unwrap();
assert!(
!all[idx + 1].contains('⚠'),
"{name} must not warn: {:?}",
!all[idx + 1].contains('+') && !all[idx + 1].contains('?'),
"{name} must not state a diff: {:?}",
all[idx + 1]
);
}
}
/// The additions and deletions are the only coloured fragments: they are
/// what a reader scans for, and colouring the rest would make the line
/// compete with the activity dot.
#[test]
fn the_diff_colours_only_its_additions_and_deletions() {
let mut s = session_named(1, "claude");
s.blocked_work = Some(InTheWay {
uncommitted_files: 1,
untracked_files: 0,
unpushed_commits: 2,
insertions: 7,
deletions: 3,
});
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
let surface = palette.surface(false);
let colour_of = |needle: char| {
(0..buf.area.width)
.flat_map(|x| (0..buf.area.height).map(move |y| (x, y)))
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
.map(|(x, y)| buf[(x, y)].fg)
.expect("fragment must be drawn")
};
assert_eq!(colour_of('+'), surface.active);
assert_eq!(colour_of('-'), surface.error);
assert_eq!(colour_of('↑'), surface.muted);
}
/// A fresh session still gets its unconditional second line, but no
/// duration until it has been held in state for 30 seconds.
#[test]
@@ -447,7 +732,7 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[1]), "sessions");
assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
all[3].contains('●'),
@@ -541,7 +826,7 @@ mod tests {
s.activity_since = Utc::now() - Duration::seconds(120);
let sb = Sidebar {
sessions: vec![s],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
@@ -592,12 +877,34 @@ mod tests {
);
}
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test]
fn meta_line_drops_the_commands_arguments() {
let mut s = session_named(1, "handle");
s.command = "claude --dangerously-skip-permissions".into();
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
assert!(
meta.contains("claude"),
"line 2 must name the program: {meta:?}"
);
assert!(
!meta.contains("dangerously"),
"line 2 must not carry arguments: {meta:?}"
);
}
/// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md).
#[test]
fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude");
s.command = "a command that cannot fit".into();
s.command = "a-program-name-that-cannot-fit".into();
s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay {
uncommitted_files: 120,
@@ -695,7 +1002,7 @@ mod tests {
let meta = content(&all[idx + 1]);
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
assert!(
meta.contains("2 unpushed commits"),
meta.contains("↑2"),
"blocked work stays on the line: {meta:?}"
);
assert!(
@@ -801,7 +1108,7 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 10, 6));
let all = rows(&draw(&sb, 9, 6));
assert!(
all[3].contains('●'),
"dot must survive narrow cards: {:?}",
@@ -817,7 +1124,7 @@ mod tests {
#[test]
fn blocked_warning_survives_before_a_truncated_command() {
let mut s = session_named(1, "name");
s.command = "a command that cannot fit".into();
s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay {
uncommitted_files: 1,
..Default::default()
@@ -828,7 +1135,7 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
assert!(all[3].contains("~1"), "{:?}", all[3]);
let meta = content(&all[3]);
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
}
@@ -847,7 +1154,7 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
assert!(all[3].contains("~1"), "{:?}", all[3]);
assert!(
all[3].contains("bash"),
"command must survive: {:?}",
@@ -864,7 +1171,7 @@ mod tests {
highlighted.activity_since = Utc::now() - Duration::seconds(120);
let sb = Sidebar {
sessions: vec![highlighted],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
@@ -881,15 +1188,15 @@ mod tests {
}
}
/// The highlighted card gets the focus background; every other card gets
/// a card background distinct from the panel's default.
/// The highlighted card gets the selection background; the rest of the
/// focused sidebar shares its subtle panel background.
#[test]
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
let one = session_named(1, "claude");
let two = session_named(2, "bash");
let sb = Sidebar {
sessions: vec![one, two],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
@@ -910,13 +1217,13 @@ mod tests {
);
assert_eq!(
buf[(1, bash_y as u16)].style().bg,
Some(p.card.bg),
"unfocused cards must still sit on the card surface"
Some(p.sidebar_focus_bg),
"ordinary cards must share the focused panel surface"
);
// The panel itself (header row) stays on the terminal default.
assert!(
matches!(buf[(1, 1)].style().bg, None | Some(Color::Reset)),
"panel must keep the terminal default background"
assert_eq!(
buf[(1, 0)].style().bg,
Some(p.sidebar_focus_bg),
"the focus background must cover the whole sidebar"
);
}
@@ -924,7 +1231,7 @@ mod tests {
fn highlighted_card_is_hollow_when_sidebar_is_unfocused() {
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let buf = draw_with_focus(&sb, 28, 8, false);
@@ -971,25 +1278,24 @@ mod tests {
for x in 1..27 {
assert_eq!(
buf[(x, y)].style().bg,
Some(palette::active().card.bg),
Some(palette::active().sidebar_focus_bg),
"gap in card background at col {x}"
);
}
}
/// Line 1's actions live on the highlighted row's top-right corner.
#[test]
fn highlighted_row_anchors_the_actions_top_right() {
fn highlighted_rows_do_not_show_action_glyphs() {
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let line = content(row_containing(&all, "claude"));
assert!(
line.ends_with("⧉ ✕"),
"actions must sit at the right edge: {line:?}"
!line.contains('⧉') && !line.contains('✕'),
"keyboard-only actions should not consume card space: {line:?}"
);
}
@@ -999,7 +1305,7 @@ mod tests {
let two = session_named(2, "bash");
let sb = Sidebar {
sessions: vec![one, two],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
@@ -1010,27 +1316,23 @@ mod tests {
);
}
/// The name truncates with `…` before it can ever reach the reserved
/// action width (docs/SIDEBAR.md — overlap is a defect).
#[test]
fn name_truncates_before_reaching_the_actions() {
fn long_name_uses_the_full_card_width() {
let mut s = session_named(1, "claude");
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
*name = "an-unreasonably-long-session-name".into();
}
let sb = Sidebar {
sessions: vec![s],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "an-unreasonably");
let name_end = line.find('…').expect("name must truncate with …");
let actions_start = line.find('⧉').expect("actions must still render");
assert!(name_end < actions_start, "name overlaps actions: {line:?}");
assert!(line.contains('…'), "name must truncate with …: {line:?}");
assert!(
line.split_at(actions_start).0.ends_with(' '),
"no gap before the actions: {line:?}"
!line.contains('⧉') && !line.contains('✕'),
"actions leaked in: {line:?}"
);
}
}
+166 -203
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,19 +60,11 @@ impl Tab {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
KillConfirm(Uuid),
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
/// so this stays comparable.
Rename(Uuid),
Help,
Toast(String),
/// The retained-worktrees view (#85); its entries live on `App` because
/// they are daemon data, not UI state.
Worktrees,
/// Reclaim confirmation for the worktree with this id (#85), guarding
/// the destructive key like [`OverlayKind::KillConfirm`] does.
ReclaimConfirm {
id: Uuid,
branch: String,
/// [`maestro_protocol::InTheWay`] as of when the confirm was opened.
blockers: String,
},
}
#[derive(Debug)]
@@ -70,7 +74,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 +103,7 @@ impl AppState {
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
collapsed_sidebar_groups: HashSet::new(),
overlay: None,
}
}
@@ -126,57 +134,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 +143,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;
@@ -212,20 +165,54 @@ impl AppState {
self.active_tab().session()
}
/// 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) {
if order.is_empty() {
/// Move the sidebar highlight one step through the rendered item
/// sequence, in the direction of `delta` (positive = down), landing only
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
/// No-op when `order` holds no stop.
///
/// Anchoring walks the *full* rendered order, not just the stops, so a
/// highlight parked on a skipped row — an expanded group header, which a
/// mouse click can still select — steps to its neighbour instead of
/// jumping to the far end of the list.
pub fn move_sidebar_highlight(
&mut self,
order: &[SidebarItem],
delta: i32,
is_stop: impl Fn(&SidebarItem) -> bool,
) {
if order.is_empty() || delta == 0 {
return;
}
let len = order.len() as i32;
let step = delta.signum();
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]);
// Nothing highlighted: start one off the end we walk from, so the
// first step lands on the first (or last) item.
.unwrap_or(if step > 0 { -1 } else { 0 });
let mut idx = cur;
for _ in 0..len {
idx = (idx + step).rem_euclid(len);
if is_stop(&order[idx as usize]) {
self.sidebar_highlight = Some(order[idx as usize].clone());
return;
}
}
}
/// 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,97 +239,119 @@ 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));
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(b));
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(c));
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(
state.sidebar_highlight,
Some(SidebarItem::Group("repo".into()))
);
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
// Wraps forward.
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(a));
state.move_sidebar_highlight(&order, 1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
// Wraps backward.
state.move_sidebar_highlight(&order, -1);
assert_eq!(state.sidebar_highlight, Some(c));
state.move_sidebar_highlight(&order, -1, |_| true);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
}
#[test]
fn move_sidebar_highlight_skips_rows_the_filter_rejects() {
let mut state = AppState::new();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let order = vec![
SidebarItem::Group("repo".into()),
SidebarItem::Session(a),
SidebarItem::Group("other".into()),
SidebarItem::Session(b),
];
let sessions_only = |item: &SidebarItem| matches!(item, SidebarItem::Session(_));
state.move_sidebar_highlight(&order, 1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
state.move_sidebar_highlight(&order, 1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
state.move_sidebar_highlight(&order, -1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
// Backwards from nothing lands on the last stop, not the last row.
state.sidebar_highlight = None;
state.move_sidebar_highlight(&order, -1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
}
/// A highlight sitting on a skipped row still moves to its neighbour.
#[test]
fn move_sidebar_highlight_anchors_on_a_skipped_row() {
let mut state = AppState::new();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let order = vec![
SidebarItem::Session(a),
SidebarItem::Group("repo".into()),
SidebarItem::Session(b),
];
let sessions_only = |item: &SidebarItem| matches!(item, SidebarItem::Session(_));
state.sidebar_highlight = Some(SidebarItem::Group("repo".into()));
state.move_sidebar_highlight(&order, 1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
state.sidebar_highlight = Some(SidebarItem::Group("repo".into()));
state.move_sidebar_highlight(&order, -1, sessions_only);
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
}
#[test]
fn move_sidebar_highlight_is_noop_when_nothing_is_a_stop() {
let mut state = AppState::new();
let order = vec![SidebarItem::Group("repo".into())];
state.move_sidebar_highlight(&order, 1, |_| false);
assert_eq!(state.sidebar_highlight, None);
}
#[test]
fn move_sidebar_highlight_is_noop_on_empty_order() {
let mut state = AppState::new();
state.move_sidebar_highlight(&[], 1);
state.move_sidebar_highlight(&[], 1, |_| true);
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 +376,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 +397,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 +418,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"));
}
}
-259
View File
@@ -1,259 +0,0 @@
//! Retained-worktrees view (#85): an overlay listing every retained worktree
//! — branch, abbreviated path, reason, what is in the way — with j/k to move,
//! `r` to reclaim behind a confirmation, esc to close.
//!
//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl,
//! the same source the kill refusal and the web client use, so no two clients
//! can describe the same tree differently.
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use maestro_protocol::{RetainedReason, RetainedWorktree};
use crate::draft::abbreviate_path;
/// The open worktrees overlay: its entries and which one is highlighted.
#[derive(Debug, Clone, Default)]
pub struct WorktreesView {
pub items: Vec<RetainedWorktree>,
pub highlight: usize,
/// Daemon-reported home dir, for `~` abbreviation of paths.
pub home_dir: String,
}
impl WorktreesView {
/// Replace the listing and keep the highlight pointing at something real.
pub fn apply(&mut self, items: Vec<RetainedWorktree>) {
self.items = items;
if !self.items.is_empty() {
self.highlight = self.highlight.min(self.items.len() - 1);
} else {
self.highlight = 0;
}
}
pub fn remove(&mut self, id: uuid::Uuid) {
let idx = self.items.iter().position(|w| w.id == id);
self.items.retain(|w| w.id != id);
if let Some(i) = idx {
self.highlight = self.highlight.min(self.items.len().saturating_sub(1));
let _ = i;
}
}
/// Move the highlight by `delta` (positive = down). Wraps. No-op on empty.
pub fn move_highlight(&mut self, delta: i32) {
if self.items.is_empty() {
return;
}
let len = self.items.len() as i32;
let next = (self.highlight as i32 + delta).rem_euclid(len);
self.highlight = next as usize;
}
pub fn selected(&self) -> Option<&RetainedWorktree> {
self.items.get(self.highlight)
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let h = (self.items.len() as u16 + 6).clamp(4, parent.height);
let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h);
f.render_widget(Clear, area);
let block = Block::default()
.title(" retained worktrees ")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
if self.items.is_empty() {
// A drained store is the normal state — say so plainly, not like
// something went wrong.
lines.push(Line::from("no retained worktrees"));
} else {
lines.push(Line::from(format!(
" {:<26} {:<34} {:<24} IN THE WAY",
"BRANCH", "PATH", "REASON"
)));
for (i, w) in self.items.iter().enumerate() {
let marker = if i == self.highlight { ">" } else { " " };
lines.push(Line::from(format!(
"{marker} {:<25} {:<34} {:<24} {}",
w.branch,
abbreviate_path(&w.path, &self.home_dir),
reason_label(w.reason),
w.in_the_way,
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from("j/k: select r: reclaim esc: close"));
f.render_widget(Paragraph::new(lines), inner);
}
}
/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`].
/// It names the branch and says out loud what reclaim does not take with it —
/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too.
#[derive(Debug, Clone)]
pub struct ReclaimConfirm {
pub branch: String,
/// [`maestro_protocol::InTheWay`] rendered via its `Display` impl.
pub blockers: String,
}
impl ReclaimConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = super::overlays::centered(parent, 70, 7);
f.render_widget(Clear, area);
let block = Block::default().title(" reclaim ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
ratatui::widgets::Paragraph::new(vec![
Line::from(format!("Reclaim worktree {}?", self.branch)),
Line::from(format!("Currently in the way: {}", self.blockers)),
Line::from("The directory is removed; the branch is never deleted."),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
);
}
}
fn reason_label(reason: RetainedReason) -> &'static str {
match reason {
RetainedReason::ForceKilled => "force-killed",
RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use maestro_protocol::{InTheWay, RetainedReason};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
where
F: FnOnce(&mut Frame),
{
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).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
}
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
RetainedWorktree {
id: uuid::Uuid::from_u128(id),
path: path.into(),
base_repo: "/home/naps/tea/maestro".into(),
branch: branch.into(),
reason: RetainedReason::ForceKilled,
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: InTheWay::default(),
}
}
#[test]
fn view_lists_branch_path_reason_and_what_is_in_the_way() {
let mut view = WorktreesView {
home_dir: "/home/naps".into(),
..Default::default()
};
let mut wt = worktree(
"feat/login",
"/home/naps/tea/maestro/worktrees/feat/login",
1,
);
wt.in_the_way = InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
};
view.apply(vec![wt]);
let rendered = render_to_string(160, 20, |f| view.render(f, f.area()));
assert!(rendered.contains("feat/login"), "{rendered}");
// Path abbreviated against the daemon's home dir, same helper as the
// draft pane's project list.
assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}");
assert!(rendered.contains("force-killed"), "{rendered}");
// Phrasing comes from InTheWay's Display impl — shared with the kill
// refusal and the web client.
assert!(
rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"),
"{rendered}"
);
}
#[test]
fn empty_store_looks_like_a_normal_state_not_an_error() {
let view = WorktreesView::default();
let rendered = render_to_string(80, 12, |f| view.render(f, f.area()));
assert!(rendered.contains("no retained worktrees"), "{rendered}");
assert!(!rendered.contains("error"), "{rendered}");
assert!(!rendered.contains("failed"), "{rendered}");
}
#[test]
fn highlight_moves_and_wraps() {
let mut view = WorktreesView::default();
view.apply(vec![
worktree("a", "/r/a", 1),
worktree("b", "/r/b", 2),
worktree("c", "/r/c", 3),
]);
assert_eq!(view.selected().unwrap().branch, "a");
view.move_highlight(1);
assert_eq!(view.selected().unwrap().branch, "b");
view.move_highlight(-1);
assert_eq!(view.selected().unwrap().branch, "a");
view.move_highlight(-1);
// Wraps backward to the last entry.
assert_eq!(view.selected().unwrap().branch, "c");
view.move_highlight(1);
assert_eq!(view.selected().unwrap().branch, "a");
}
#[test]
fn apply_clamps_a_stale_highlight() {
let mut view = WorktreesView::default();
view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]);
view.move_highlight(1);
view.apply(vec![worktree("c", "/r/c", 3)]);
assert_eq!(view.selected().unwrap().branch, "c");
}
#[test]
fn reclaim_confirm_names_the_branch_and_says_it_survives() {
let confirm = ReclaimConfirm {
branch: "feat/login".into(),
blockers: "2 uncommitted files".into(),
};
let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area()));
assert!(
rendered.contains("Reclaim worktree feat/login?"),
"{rendered}"
);
assert!(rendered.contains("2 uncommitted files"), "{rendered}");
assert!(rendered.contains("never deleted"), "{rendered}");
assert!(rendered.contains("yes"), "{rendered}");
assert!(rendered.contains("no"), "{rendered}");
}
}
+14
View File
@@ -57,6 +57,10 @@ enum Command {
/// Kill a session by ID.
#[cfg(feature = "cli")]
Kill(maestro_cli::cli::KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
#[cfg(feature = "cli")]
Send(maestro_cli::cli::SendArgs),
/// Install or update the agent activity hook (#106).
#[cfg(feature = "cli")]
Hooks(maestro_cli::cli::HooksArgs),
@@ -144,6 +148,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
.await?
}
#[cfg(feature = "cli")]
Command::Send(args) => {
maestro_cli::dispatch(
maestro_cli::cli::Command::Send(args),
cli.config,
cli.daemon_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::Hooks(args) => {
maestro_cli::dispatch(
maestro_cli::cli::Command::Hooks(args),
@@ -190,6 +203,7 @@ fn is_cli_subcommand(command: &Option<Command>) -> bool {
| Some(Command::Spawn(_))
| Some(Command::Attach(_))
| Some(Command::Kill(_))
| Some(Command::Send(_))
| Some(Command::Hooks(_))
)
}
+27 -4
View File
@@ -134,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`.
+81 -17
View File
@@ -7,21 +7,26 @@ 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 + actions
## Line 1 — name
- Left: the session name — `metadata.name` if present, else the command line
(same fallback both clients already use).
- Right: two actions anchored to the top-right corner: **spawn similar**
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
the highlighted row; in the web they are buttons. The glyphs are shared so
muscle memory transfers.
- Actions reserve their fixed width; the name truncates with `…` before it can
ever reach them. **Overlap between the name and the actions is a defect** —
that overlap is what started this milestone.
- 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
@@ -32,7 +37,7 @@ itself is not.
Line 2 reads left to right:
```
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
```
Its minimum content is therefore the dot and the command.
@@ -59,11 +64,50 @@ 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.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
while something would block an unforced kill, and reads:
```
+<insertions> -<deletions> ?<untracked files> ↑<unpushed commits>
```
Each fragment is omitted when its count is zero. The line counts cover
**tracked** changes only — staged and unstaged together, measured against
`HEAD` — so a `git add` never makes the counter drop. Untracked files are
counted as files, not lines: counting their lines means reading every one off
disk, and `?3` already says what is there. A tree whose only tracked change
carries no line delta — a mode change, a rename — shows `~<changed files>`
instead of the additions and deletions, since `+0 -0` would describe it
wrongly.
Only `+` and `-` take colour (`--live` / `--danger` in the web client, the
surface's `active` / `error` roles in the TUI); `~`, `?` and `↑` stay muted, so
the fragment reads as a diff rather than as an alarm. The spelled-out phrasing
of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web
card's tooltip and the wording of the kill refusal, so no two clients describe
the same tree differently.
`<command>` is unconditional and muted: it names the **program** the session
runs — never its arguments. `claude --dangerously-skip-permissions` renders as
`claude`. Flags say how a session was invoked, which is not what a card is for,
and a long one crowds out every other fragment on the line. It shows on every
card in both clients — no omission in the TUI, no name-vs-command comparison
gating it. A session named by its program then shows the same string on both
lines; that is accepted, the price of a row that never changes height.
The program is found by looking through a path, leading `FOO=bar` assignments
and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
`/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
is shown as written rather than as an empty fragment.
In the **web client** the program is replaced by that tool's icon when it is
one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
program keeps its name as text, so the card never shows a mark standing for the
wrong tool. The full command line, arguments and all, stays in the card's
tooltip either way. The **TUI** keeps the program as text: a terminal cannot be
assumed to have a font carrying tool glyphs.
## Colour per activity state
@@ -90,9 +134,10 @@ 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.
The duration text inherits its state's colour; the exited and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
take the `active` and `error` roles of the same surface.
## Duration format
@@ -141,3 +186,22 @@ dropping the whole session would be worse than dimming one card. A report
arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
state is a 4xx: an agent posting a state the daemon does not know is a bug in
the agent, and answering 204 would hide it.
## Keyboard walk
`j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients
follow one rule for what the walk stops on:
- **Session cards, always.**
- **Folder headers, only while collapsed.** An expanded folder's cards are one
row away, so its header is nothing to land on; a collapsed folder's header is
the only row it has, and skipping it would leave the folder unreachable.
Enter opens the highlighted session, or expands the highlighted folder and
lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
same chord back): an attached terminal owns every plain keystroke. The TUI uses
`ctrl-w h`.
+32 -54
View File
@@ -12,9 +12,9 @@ have to reconstruct it.
## Nothing is ever adopted
The daemon only ever removes a worktree it created in this session's lifetime or
recorded in its retained store. It never takes ownership of a directory it finds
on disk, whatever that directory looks like.
The daemon only ever removes a worktree it created in this session's lifetime.
It never takes ownership of a directory it finds on disk, whatever that
directory looks like.
There is no reliable way to ask "is this worktree mine?". A git worktree carries
no marker distinguishing one a daemon made from one a human made ten minutes
@@ -69,75 +69,53 @@ its worktree still matters leaves a directory nobody is looking at.
A consequence, accepted deliberately: a feature branch almost always has unpushed
commits, so `--force` is the ordinary way to end a session, not an exception.
That is only safe because force retains.
That is only safe because force never removes what blocked.
### `--force`
`--force` overrides the refusal and nothing else. It ends the session and
**retains** the worktree, which enters the retained store with reason
`force-killed`. Force never removes a tree that would have blocked. A tree
whose guard is clear never needed the override, so a forced kill removes it as
usual — force changes what happens to blocked work, not ordinary cleanup.
**leaves the worktree on disk**, untouched. Force never removes a tree that
would have blocked. A tree whose guard is clear never needed the override, so a
forced kill removes it as usual — force changes what happens to blocked work,
not ordinary cleanup.
Ending a session and destroying its work are two separate destructive acts.
Each is chosen on its own, and the second one — reclaim — is chosen while
looking at exactly what would be lost.
In the web client the kill button sends the unforced `DELETE` straight away —
there is no pre-confirmation, because the daemon is the one that decides. Only
the refusal raises a prompt, and that prompt names what is in the way and offers
"Force kill", which repeats the request with `force=true`.
A forced kill answers with what was retained and why, so a client can say where
the work went rather than leaving the operator to wonder.
Ending a session and destroying its work are two separate acts. Maestro only
performs the first one. Removing a tree it left behind is the operator's own
`git worktree remove`, chosen while looking at exactly what would be lost.
### Exiting on its own
A session that ends by itself — the command finished, the operator typed `exit` —
never reaches the kill path. When the watcher notices the tmux session is gone it
applies the same rule: a pristine worktree is removed, anything else is retained
with reason `exited-with-unsaved-work`.
applies the same rule: a pristine worktree is removed, anything else is left on
disk.
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
only between removing and retaining. It uses the same predicate as the kill, from
only between removing and leaving. It uses the same predicate as the kill, from
the same place, so the two paths cannot drift apart.
The session itself stays in the catalog marked dead until it is dismissed — the
replay buffer of a crashed agent outlives it — but its worktree is settled at
once. Once settled, the session drops its worktree reference, so dismissing it
afterwards neither refuses (there is no guard left to trip) nor records a second
store entry.
once. A removed tree drops off the record, so dismissing the session afterwards
does not refuse: there is no guard left to trip.
If settling is impossible — the probe fails, or the store cannot take the entry —
nothing is destroyed. The tree stays on disk *and* stays attached to the dead
record, where dismissing it later still goes through `--force`'s
retain-before-teardown path. Losing the store must never mean losing the work.
A tree that was left alone stays attached to the dead record, where dismissing it
later goes through `--force`, which leaves it on disk too. The same holds when
the probe itself fails: nothing is destroyed.
## The retained store
## Nothing is removed on the operator's behalf
A `state_dir`-backed record of worktrees the daemon created that no longer have a
session. For each one: the path, the base repository, the branch, why it was
retained, and when.
There is no store of worktrees the daemon is holding, no list endpoint, and no
reclaim command. A tree left behind by a forced kill or a dirty exit is an
ordinary git worktree on disk; `git worktree list` finds it and
`git worktree remove` removes it.
It survives a daemon restart. It is the recovery path for everything above — a
store that is lost on restart is worse than no store, because the worktrees are
still on disk with nothing pointing at them.
Entries leave when the worktree is reclaimed, and when the daemon notices the
directory is gone because somebody removed it by hand.
**It is not an ownership record.** It must never be consulted to decide whether a
path may be adopted — see the first rule. Its only job is to remember what the
daemon is holding so that it can be listed and reclaimed.
## Reclaim
`GET /api/worktrees` lists the store: path, base repository, branch, reason, when,
and what is currently in the way. The in-the-way breakdown is recomputed on read,
not stored, so a tree whose work has since been pushed says so.
`DELETE /api/worktrees/<id>` reclaims one. It removes the directory and drops the
store entry. It does **not** consult the guard — reclaim is the deliberate
destructive act, and the operator chose it while looking at what was in the way.
It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming
a tree must not throw away commits on it. This is worth saying out loud in every
client, because "reclaim" otherwise sounds like it takes the commits too.
Nothing is reclaimed automatically. There is no age-based expiry and no bulk
reclaim.
That is deliberate. A tracked list of trees pending deletion is a second place
the truth can live, and it goes stale the moment somebody removes a directory by
hand. Git already knows which worktrees exist, and removing one is not a
decision Maestro is better placed to make.
+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-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
hash = "sha256-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0=";
};
buildPhase = ''
+1
View File
@@ -13,6 +13,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0"
},
+8
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@fontsource/jetbrains-mono':
specifier: ^5.3.0
version: 5.3.0
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -168,6 +171,9 @@ packages:
cpu: [x64]
os: [win32]
'@fontsource/jetbrains-mono@5.3.0':
resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -631,6 +637,8 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@fontsource/jetbrains-mono@5.3.0': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@rollup/rollup-android-arm-eabi@4.60.4':
+46 -26
View File
@@ -1,6 +1,7 @@
import * as routes from "./routes.js";
import type {
ApiError,
InTheWay,
Branches,
CreateSessionRequest,
DaemonInfo,
@@ -9,24 +10,42 @@ import type {
NameBatch,
Projects,
RecentDirs,
RetainedWorktree,
Session,
Uuid,
} from "./types.js";
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
* render `unsavedWork` rather than re-parsing the message. */
export class ApiRequestError extends Error {
readonly status: number;
readonly code: string;
readonly detail: string;
readonly unsavedWork: InTheWay | null;
constructor(res: Response, info?: ApiError["error"], text?: string) {
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
this.name = "ApiRequestError";
this.status = res.status;
this.code = info?.code ?? "";
this.detail = info?.message ?? text ?? "";
this.unsavedWork = info?.unsaved_work ?? null;
}
}
async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) {
if (res.status === 204) return undefined as unknown as T;
return (await res.json()) as T;
}
let detail = "";
try {
const body = (await res.json()) as ApiError;
detail = `${body.error.code}: ${body.error.message}`;
} catch {
detail = await res.text();
throw new ApiRequestError(res, body.error);
} catch (e) {
if (e instanceof ApiRequestError) throw e;
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
}
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
export async function listSessions(): Promise<Session[]> {
@@ -44,9 +63,28 @@ export async function createSession(req: CreateSessionRequest): Promise<Session>
);
}
export async function killSession(id: Uuid): Promise<void> {
await jsonOrError(
/** 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 }),
}),
);
}
/** Kill a session. With `force` (#81) the daemon overrides its refusal over a
* worktree holding unsaved work and leaves that worktree on disk. */
export async function killSession(id: Uuid, force = false): Promise<void> {
const url = force
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
: routes.session(id);
await jsonOrError(
await fetch(url, {
method: "DELETE",
credentials: "same-origin",
}),
@@ -98,22 +136,4 @@ export async function fetchBranches(path: string): Promise<Branches> {
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** The daemon's retained worktrees (#80): trees kept after a session ended
* with unsaved work, with what is currently in the way. Drives the
* retained-worktrees view (#84). */
export async function listWorktrees(): Promise<RetainedWorktree[]> {
return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" }));
}
/** Reclaim a retained worktree: removes its directory and drops the store
* entry. Never deletes the branch (docs/WORKTREES.md). */
export async function reclaimWorktree(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(routes.worktree(id), {
method: "DELETE",
credentials: "same-origin",
}),
);
}
export { eventsWsUrl } from "./routes.js";
+34 -10
View File
@@ -6,6 +6,8 @@ import { attachWsUrl } from "./routes.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
/**
@@ -27,6 +29,8 @@ export interface AttachConnection {
onMount(): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
focusTerminal(): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
@@ -52,6 +56,16 @@ export interface AttachInfo {
command: string;
}
/** Header echoes the sidebar: the program becomes its tool icon when we have a
* mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
@@ -63,14 +77,12 @@ export function createAttachConnection(
// tooltip for when you really need it.
const titleHtml = info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name
? `<span class="at-cmd">${escapeHtml(info.command)}</span>`
: "")
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
<header class="attach-header">
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a>
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
@@ -90,6 +102,12 @@ export function createAttachConnection(
stateEl.dataset.state = kind;
};
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
// the session list is an off-canvas drawer and this is what opens it.
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
requestToggleDrawer(),
);
disconnectBtn.addEventListener("click", () => {
opts.onRequestClose?.(sessionId);
});
@@ -97,7 +115,7 @@ export function createAttachConnection(
const term = new Terminal({
cursorBlink: true,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
@@ -123,9 +141,12 @@ 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.
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
// later sibling of the viewport, so it paints over it and takes every touch.
{
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
const root = term.element as HTMLElement | undefined;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => {
const core = (term as unknown as {
@@ -133,11 +154,11 @@ export function createAttachConnection(
})._core;
return core?._renderService?.dimensions?.css?.cell?.height || 16;
};
if (vp) {
if (root) {
let lastY = 0;
let lastX = 0;
let remainder = 0;
vp.addEventListener(
root.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
@@ -147,7 +168,7 @@ export function createAttachConnection(
},
{ passive: true },
);
vp.addEventListener(
root.addEventListener(
"touchmove",
(e) => {
if (e.touches.length !== 1) return;
@@ -381,6 +402,9 @@ export function createAttachConnection(
onUnmount() {
mounted = false;
},
focusTerminal() {
term.focus();
},
dispose() {
if (disposed) return;
disposed = true;
+135 -35
View File
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
@@ -16,6 +16,10 @@ beforeAll(() => {
window.fetch = () => Promise.reject(new Error("offline"));
});
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
@@ -46,16 +50,39 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
}
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session with the shared phrasing", () => {
it("marks the row of a blocked session in diff notation", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000001",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
blocked_work: {
uncommitted_files: 3,
unpushed_commits: 2,
insertions: 124,
deletions: 18,
},
});
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");
expect(badge?.textContent).toBe("+124-18↑2");
// The spelled-out breakdown survives in the tooltip.
expect(badge?.getAttribute("title")).toContain(
"3 uncommitted files, 2 unpushed commits",
);
});
it("falls back to the file count when tracked changes carry no lines", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-00000000001a",
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toBe("~2?1");
});
it("does not mark clear or unprobed sessions", () => {
@@ -71,37 +98,77 @@ describe("blocked-work indicator (#83)", () => {
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;
};
it("kills straight away, without a first confirmation", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
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 flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
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");
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
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"?']);
it("closes the override prompt without killing on cancel", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(
new Response(
JSON.stringify({
error: { code: "kill_guarded", message: "worktree has unsaved work" },
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000006",
blocked_work: { unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
});
});
@@ -161,7 +228,7 @@ describe("session cards (#107)", () => {
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");
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
});
it("shows dot + floored duration once the state has been held past 30s", () => {
@@ -177,14 +244,42 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("renders the command when the session name equals it", () => {
it("renders the command as its tool icon when the tool is known", () => {
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");
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.getAttribute("title")).toBe("claude");
// The icon said it all: no text left over.
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("drops the command's arguments, keeping them in the tooltip", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --dangerously-skip-permissions",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
});
it("keeps an unknown command as plain text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001d",
command: "hx src/main.rs",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
});
it("maps each activity state to its contract slug", () => {
@@ -222,15 +317,20 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
});
it("moves blocked work onto line 2 in the shared phrasing", () => {
it("moves blocked work onto line 2 as diff notation", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000017",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
blocked_work: {
uncommitted_files: 3,
unpushed_commits: 2,
insertions: 124,
deletions: 18,
},
});
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");
expect(line2?.textContent).toContain("+124");
expect(line2?.textContent).toContain("-18");
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
});
+284 -122
View File
@@ -1,9 +1,21 @@
import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession } from "./api.js";
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js";
import { isTypingTarget, toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
import type { InTheWay, Session } from "./types.js";
import {
abbreviateFolderName,
blockerStats,
customSessionName,
formatInTheWay,
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
sessionName,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
activityColorSlug,
activityDurationLabel,
@@ -21,8 +33,29 @@ export function requestOpenSpawn(): void {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
}
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
* what Escape in the sidebar list does. Handled in main.ts, which owns the
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to open a draft pane seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
* folder headers are skipped: a directory is not something to focus, and the
* cards under it are one row away anyway. A *collapsed* folder still counts —
* its header is the only row it has, so skipping it would leave the folder
* unreachable. Same rule as the TUI sidebar. */
const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]';
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> = {
@@ -47,15 +80,34 @@ function metaLine(s: Session, now: number): string {
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>`
: "",
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
commandFragment(s.command),
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
* — with the spelled-out breakdown kept in the tooltip. */
function blockedFragment(blockers: InTheWay): string {
const stats = blockerStats(blockers)
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
.join("");
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
}
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
* the card. The program becomes its tool icon when we have a mark for it, and
* stays as its bare name when we do not. The full command line, arguments and
* all, stays in the tooltip either way. */
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -68,6 +120,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
header.style.paddingLeft = `${depth * 16}px`;
const isCollapsed = collapsed.has(node.path);
header.setAttribute("aria-expanded", String(!isCollapsed));
header.dataset.navKey = `folder:${node.path}`;
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
// glyph and the rotation never fight each other.
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
@@ -97,6 +150,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
row.href = `#/attach/${encodeURIComponent(s.id)}`;
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
row.dataset.navKey = `session:${s.id}`;
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
@@ -121,37 +175,106 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", async (ev) => {
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
// #83: the confirm restates the warning at the moment of decision.
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}`);
}
void requestKill(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(SPAWN_SIMILAR_EVENT, { detail: s })),
},
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
]);
});
return row;
}
function renderStatusbar(sessions: Session[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "statusbar";
/**
* Kill on the first click (#81): the daemon is the one that decides whether a
* session may go, and it refuses over a worktree holding unsaved work. Only
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
* a forced kill. Anything else surfaces as an alert.
*/
async function requestKill(s: Session, name: string): Promise<void> {
try {
await killSession(s.id);
} catch (e) {
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
openForceKillDialog({
name,
blockers: e.unsavedWork ?? sessionBlockers(s),
reason: e.detail,
onForce: () => killSession(s.id, true),
});
return;
}
alert(`Failed to kill: ${(e as Error).message}`);
}
}
const live = sessions.filter((s) => s.status === "Running").length;
const tally = document.createElement("span");
tally.className = "session-tally";
tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`;
wrap.appendChild(tally);
return wrap;
/**
* 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 renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
@@ -178,96 +301,11 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
return el;
}
/**
* The global app header (full width, above the sidebar/content split): brand +
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
* stay put and reachable regardless of which pane is showing.
*/
/** Navbar toggle for the debug HUD (#20), next to the design-kit shortcut.
* Reflects the current state and stays in sync with the Ctrl+Shift+D keybind. */
function createDebugToggle(): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-ghost debug-toggle";
btn.textContent = "debug";
btn.title = "Toggle the debug HUD (Ctrl+Shift+D)";
const sync = () => {
const on = isHudEnabled();
btn.setAttribute("aria-pressed", String(on));
btn.classList.toggle("is-active", on);
};
btn.addEventListener("click", () => toggleHud());
window.addEventListener(HUD_TOGGLE_EVENT, sync);
sync();
return btn;
}
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
const header = document.createElement("header");
header.className = "app-header";
header.innerHTML = `
<button type="button" class="app-menu" title="Sessions" aria-label="Toggle session list">☰</button>
<a class="brand" href="#/" aria-label="maestro — clear selection">
<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
</a>
<div class="app-status"></div>
<div class="app-actions"></div>
`;
// Mobile-only ☰ (CSS hides it on desktop) — opens the off-canvas session drawer.
(header.querySelector(".app-menu") as HTMLElement).addEventListener("click", () =>
onToggleSidebar?.(),
);
// Session tally lives centered in the header (not the sidebar).
const statusHost = header.querySelector(".app-status") as HTMLElement;
const renderStatus = () => {
const state = store.snapshot();
statusHost.replaceChildren(renderStatusbar([...state.sessions.values()]));
};
store.subscribe(renderStatus);
renderStatus();
const actions = header.querySelector(".app-actions") as HTMLElement;
// Retained worktrees (#84): a section reachable from the header, not a
// sidebar fixture — PRODUCT.md's scan is the product, so this must not
// compete with the session catalog for attention.
const worktreesLink = document.createElement("a");
worktreesLink.className = "btn btn-ghost";
worktreesLink.href = "#/worktrees";
worktreesLink.textContent = "worktrees";
worktreesLink.title = "Retained worktrees";
actions.appendChild(worktreesLink);
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
const kit = document.createElement("a");
kit.className = "btn btn-ghost";
kit.href = "?kit";
kit.textContent = "kit";
kit.title = "Open the design kit (DEV)";
actions.appendChild(kit);
}
actions.appendChild(createDebugToggle());
actions.appendChild(createThemeToggle());
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
// The keyboard pair sits right here in the tooltip (#67).
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
spawnBtn.addEventListener("click", () => requestOpenSpawn());
actions.appendChild(spawnBtn);
return header;
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
* pane (quiet), which one is focused (loud), and a stable per-session colour so
* a row maps to its pane. */
* pane (quiet), and which one is focused (loud). */
export interface SidebarHighlights {
open: Set<string>;
focused: string | null;
colors: Map<string, string>;
}
/** Live handle to the persistent sidebar (built once, survives navigation). */
@@ -277,8 +315,12 @@ export interface SidebarHandle {
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
* Back-compat shim over {@link setHighlights}. */
setActive(id: string | null): void;
/** Apply the full open/focused/colour highlight state. */
/** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Hand the keyboard to the session list, highlighting a row to walk from. */
focusList(): void;
/** Whether the session list currently holds DOM focus. */
hasListFocus(): boolean;
/** Tear down the store subscription + listeners. */
dispose(): void;
}
@@ -324,6 +366,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.className = "tree";
el.appendChild(treeHost);
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
// rather than the sidebar's own scrolling content, so they stay reachable
// regardless of tree scroll position.
const footer = document.createElement("div");
footer.className = "sidebar-footer";
const helpBtn = document.createElement("button");
helpBtn.type = "button";
helpBtn.className = "btn btn-ghost btn-icon help-btn";
helpBtn.textContent = "?";
helpBtn.title = "Keyboard shortcuts (?)";
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
helpBtn.addEventListener("click", () => toggleHelp());
footer.appendChild(helpBtn);
footer.appendChild(createThemeToggle());
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
const kit = document.createElement("a");
kit.className = "btn btn-ghost";
kit.href = "?kit";
kit.textContent = "kit";
kit.title = "Open the design kit (DEV)";
footer.appendChild(kit);
}
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
// The keyboard pair sits right here in the tooltip (#67).
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
spawnBtn.addEventListener("click", () => requestOpenSpawn());
footer.appendChild(spawnBtn);
el.appendChild(footer);
// Drag a session row into the canvas (issue #32). Delegated so it survives
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
treeHost.addEventListener("pointerdown", (e) => {
@@ -386,7 +462,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
refresh();
});
let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
let highlights: SidebarHighlights = { open: new Set(), focused: null };
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
@@ -395,13 +471,83 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
const color = highlights.colors.get(id);
if (color && (open || focused)) n.style.setProperty("--mv-color", color);
else n.style.removeProperty("--mv-color");
}
};
// ---- keyboard navigation ----
// The walk reads the rendered DOM rather than a parallel model, so it can
// never disagree with what is on screen. Rows carry a `data-nav-key`
// (`session:<id>` / `folder:<path>`) that survives a re-render.
let navKey: string | null = null;
const navRows = (): HTMLElement[] => [
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
];
const rowFor = (key: string | null): HTMLElement | undefined =>
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
const applyNav = () => {
const rows = navRows();
// A row that went away (session killed, folder collapsed) drops the
// highlight rather than leaving it pointing at nothing.
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
};
const moveNav = (delta: number) => {
const rows = navRows();
if (rows.length === 0) return;
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
// Nothing highlighted: start one off the end we are walking from, so the
// first step lands on the first (or last) row.
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
const activateNav = () => {
const row = rowFor(navKey);
if (!row) return;
if (row.classList.contains("tree-leaf")) {
row.click();
return;
}
// Expanding replaces the folder's header in the walk with the rows it was
// hiding, so land on the first of them instead of losing the highlight.
const key = navKey;
row.click();
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
(h) => h.dataset.navKey === key,
);
const revealed = header?.parentElement
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
: undefined;
navKey = revealed?.dataset.navKey ?? null;
applyNav();
};
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
// keyboard stays here while j/k runs.
treeHost.tabIndex = -1;
treeHost.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
// The inline rename field lives inside a row and owns its own keys.
if (isTypingTarget(e.target)) return;
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
else if (e.key === "Enter") activateNav();
else if (e.key === "Escape") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
else return;
e.preventDefault();
});
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);
@@ -416,6 +562,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
}
applyHighlights();
applyNav();
};
void getDaemonInfo()
@@ -448,7 +595,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = {
open: id ? new Set([id]) : new Set(),
focused: id,
colors: new Map(),
};
applyHighlights();
},
@@ -456,8 +602,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = h;
applyHighlights();
},
focusList() {
// Start from the session on screen — that is where the user's attention
// already is — falling back to the standing highlight, then the top row.
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
navKey = start?.dataset.navKey ?? null;
applyNav();
treeHost.focus();
start?.scrollIntoView({ block: "nearest" });
},
hasListFocus() {
return treeHost.contains(document.activeElement);
},
dispose() {
clearInterval(clock);
// An unfinished rename would otherwise leave every later sidebar
// refusing to refresh.
renamingId = null;
document.removeEventListener("catalog-rerender", rerenderHandler);
unsubscribe();
},
+7
View File
@@ -140,6 +140,13 @@ export class ConnectionPool {
this.setFocus(null);
}
/** Put the keyboard back into the focused session's terminal. No-op when
* nothing is focused (welcome pane) or its connection is gone. */
focusTerminal(): void {
if (this.focusedId === null) return;
this.cache.get(this.focusedId)?.focusTerminal();
}
/** Fully disconnect + forget a session. */
close(id: Uuid): void {
const conn = this.cache.get(id);
+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;
}
+47
View File
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { installDrawer, type Drawer } from "./drawer.js";
function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } {
const shell = document.createElement("div");
const sidebar = document.createElement("aside");
const backdrop = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-leaf";
sidebar.appendChild(row);
shell.append(sidebar, backdrop);
document.body.replaceChildren(shell);
const drawer = installDrawer(shell, sidebar, backdrop);
drawer.open();
return { shell, sidebar, drawer };
}
describe("mobile drawer", () => {
beforeEach(() => document.body.replaceChildren());
it("closes when a session row is tapped", () => {
const { shell, sidebar } = mount();
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("leaves the backdrop click closing it", () => {
const { shell } = mount();
(shell.lastElementChild as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is
// inert (CSS gates the drawer styling).
//
// Anything that puts a new view in the content area must close the drawer, or
// it renders behind the sidebar and the tap reads as a dead button. "Spawn
// similar" sits inside a session row but stops propagation, so it never reaches
// the row's own close — both spawn paths arrive here via document events.
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
/** Toggling the drawer from outside the shell (the pane header's ☰ on mobile),
* without threading the controller through every view. */
export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
export function requestToggleDrawer(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
}
export interface Drawer {
open(): void;
close(): void;
toggle(): void;
isOpen(): boolean;
}
export function installDrawer(
shellEl: HTMLElement,
sidebarEl: HTMLElement,
backdropEl: HTMLElement,
): Drawer {
const drawer: Drawer = {
open: () => shellEl.classList.add("drawer-open"),
close: () => shellEl.classList.remove("drawer-open"),
toggle: () => shellEl.classList.toggle("drawer-open"),
isOpen: () => shellEl.classList.contains("drawer-open"),
};
backdropEl.addEventListener("click", () => drawer.close());
sidebarEl.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close();
});
document.addEventListener(TOGGLE_DRAWER_EVENT, () => drawer.toggle());
document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close());
document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close());
return drawer;
}
/** Horizontal swipes drive the drawer (right opens, left closes) rather than
* the browser's back/forward navigation. Only a clearly horizontal gesture past
* a threshold counts, so vertical terminal scrolling (which owns its own touch
* handler) is untouched. */
export function installDrawerSwipe(drawer: Drawer): void {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0 && !drawer.isOpen()) drawer.open();
else if (dx < 0 && drawer.isOpen()) drawer.close();
},
{ passive: true },
);
}
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
SHORTCUTS,
closeHelp,
installHelpKeybind,
isHelpOpen,
isTypingTarget,
openHelp,
} from "./help.js";
beforeEach(() => {
installHelpKeybind();
});
afterEach(() => {
closeHelp();
document.body.replaceChildren();
});
function press(key: string, init: KeyboardEventInit = {}): void {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
}
function rows(): string[] {
return [...document.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
);
}
describe("help overlay", () => {
it("renders every registered shortcut, grouped", () => {
openHelp();
const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
expect(document.querySelectorAll(".help-desc")).toHaveLength(
SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
);
});
it("opens on ? and closes on a second ?", () => {
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(false);
});
it("closes on Escape, on the close button, and on a click outside the panel", () => {
openHelp();
press("Escape");
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-close") as HTMLButtonElement).click();
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
expect(isHelpOpen()).toBe(false);
});
it("stays open for a click inside the panel", () => {
openHelp();
(document.querySelector(".help-panel") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
expect(isHelpOpen()).toBe(true);
});
it("opening twice leaves one overlay", () => {
openHelp();
openHelp();
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
const input = document.createElement("input");
document.body.appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true }));
expect(isHelpOpen()).toBe(false);
press("?", { ctrlKey: true });
expect(isHelpOpen()).toBe(false);
});
it("installing the keybind twice still toggles once", () => {
installHelpKeybind();
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
});
});
describe("isTypingTarget", () => {
it("is true for form fields and editable nodes, false otherwise", () => {
const el = (html: string) => {
const d = document.createElement("div");
d.innerHTML = html;
return d.firstElementChild as HTMLElement;
};
expect(isTypingTarget(el("<input>"))).toBe(true);
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
expect(isTypingTarget(el("<select></select>"))).toBe(true);
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
expect(isTypingTarget(el("<button></button>"))).toBe(false);
expect(isTypingTarget(null)).toBe(false);
});
});
+172
View File
@@ -0,0 +1,172 @@
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
* overlay. One registry drives both the rendering and what we claim is bound,
* so a binding added to `main.ts` without an entry here reads as missing. */
export interface Shortcut {
/** Keycaps, rendered left to right as separate <kbd>. */
keys: string[];
description: string;
}
export interface ShortcutGroup {
title: string;
items: Shortcut[];
}
export const SHORTCUTS: ShortcutGroup[] = [
{
title: "Sessions",
items: [
{ keys: ["n"], description: "Open a spawn draft pane" },
{ keys: ["N"], description: "Spawn a session with every default resolved" },
],
},
{
title: "Session list",
items: [
{
keys: ["Ctrl", "Shift", "H"],
description: "Move the keyboard between the session list and the pane",
},
{ keys: ["j"], description: "Highlight the next session" },
{ keys: ["k"], description: "Highlight the previous session" },
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
{ keys: ["Esc"], description: "Hand the keyboard back to the pane" },
],
},
{
title: "Help",
items: [
{ keys: ["?"], description: "Show or hide this help" },
{ keys: ["Esc"], description: "Close the help overlay or an open menu" },
],
},
{
title: "Debug",
items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
},
];
/** True when the event target swallows plain keystrokes — a form field, or the
* hidden textarea xterm types into. Shared with `main.ts` so every global
* single-key binding is deaf in the same places. */
export function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || typeof el.tagName !== "string") return false;
return (
el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT" ||
el.isContentEditable
);
}
let open: { el: HTMLElement; dispose: () => void } | null = null;
export function isHelpOpen(): boolean {
return open !== null;
}
export function closeHelp(): void {
open?.dispose();
}
export function toggleHelp(): void {
if (open) closeHelp();
else openHelp();
}
/** Mount the overlay. Opening while already open is a no-op re-render, so a
* stray second call can never stack two dialogs. */
export function openHelp(): HTMLElement {
closeHelp();
const el = document.createElement("div");
el.className = "help-overlay";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", "Keyboard shortcuts");
const panel = document.createElement("div");
panel.className = "help-panel";
const head = document.createElement("header");
head.className = "help-head";
const title = document.createElement("h2");
title.className = "help-title";
title.textContent = "Keyboard shortcuts";
const close = document.createElement("button");
close.type = "button";
close.className = "help-close";
close.setAttribute("aria-label", "Close");
close.textContent = "✕";
close.addEventListener("click", closeHelp);
head.append(title, close);
panel.appendChild(head);
for (const group of SHORTCUTS) {
const section = document.createElement("section");
section.className = "help-group";
const heading = document.createElement("h3");
heading.className = "help-group-title";
heading.textContent = group.title;
const list = document.createElement("dl");
list.className = "help-list";
for (const item of group.items) {
const row = document.createElement("div");
row.className = "help-row";
const keys = document.createElement("dt");
keys.className = "help-keys";
for (const key of item.keys) {
const cap = document.createElement("kbd");
cap.textContent = key;
keys.appendChild(cap);
}
const desc = document.createElement("dd");
desc.className = "help-desc";
desc.textContent = item.description;
row.append(keys, desc);
list.appendChild(row);
}
section.append(heading, list);
panel.appendChild(section);
}
// A press on the scrim closes; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeHelp();
});
el.appendChild(panel);
document.body.appendChild(el);
const dispose = () => {
if (open?.el !== el) return;
open = null;
el.remove();
};
open = { el, dispose };
close.focus();
return el;
}
let installed = false;
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
* second call during a hot reload doesn't double-toggle. */
export function installHelpKeybind(): void {
if (installed) return;
installed = true;
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && open) {
e.preventDefault();
closeHelp();
return;
}
// Shift is how `?` is typed on most layouts; the others would be a
// different binding entirely.
if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
if (isTypingTarget(e.target)) return;
e.preventDefault();
toggleHelp();
});
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Forced-kill prompt (#81).
*
* The daemon refuses to kill a session whose worktree still holds uncommitted
* or unpushed work. `confirm()` can only ask the same question twice, so the
* refusal gets its own dialog: it names what is in the way and offers the
* override the daemon's `force=true` already implements. A kill the daemon
* accepts never reaches here — there is nothing to warn about.
*/
import { formatInTheWay, type InTheWay } from "./types.js";
export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */
name: string;
/** What the daemon reported in the way, when it reported anything. */
blockers: InTheWay | null;
/** Message from the daemon's refusal, shown when there is no breakdown. */
reason: string;
/** Run the forced kill. A rejection is rendered in the dialog, which stays
* open so the override can be retried. */
onForce: () => Promise<void>;
}
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
export function closeForceKillDialog(): void {
if (!open) return;
window.removeEventListener("keydown", open.onKeydown, true);
open.el.remove();
open = null;
}
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
* can never stack two dialogs. */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
closeForceKillDialog();
const el = document.createElement("div");
el.className = "kill-dialog";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", `Kill ${p.name}`);
const panel = document.createElement("div");
panel.className = "kill-dialog-panel";
const title = document.createElement("h2");
title.className = "kill-dialog-title";
title.textContent = `Kill "${p.name}"?`;
const body = document.createElement("p");
body.className = "kill-dialog-body";
body.textContent = p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
const error = document.createElement("p");
error.className = "kill-dialog-error";
error.hidden = true;
const actions = document.createElement("div");
actions.className = "kill-dialog-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog);
const force = document.createElement("button");
force.type = "button";
force.className = "btn btn-danger kill-dialog-force";
force.textContent = "Force kill";
force.addEventListener("click", () => {
force.disabled = true;
void p
.onForce()
.then(closeForceKillDialog)
.catch((e: unknown) => {
force.disabled = false;
error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`;
});
});
actions.append(cancel, force);
panel.append(title, body, error, actions);
// A press on the scrim cancels; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
});
el.appendChild(panel);
document.body.appendChild(el);
const onKeydown = (ev: KeyboardEvent): void => {
if (ev.key !== "Escape") return;
ev.preventDefault();
ev.stopPropagation();
closeForceKillDialog();
};
window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown };
cancel.focus();
return el;
}
-1
View File
@@ -205,7 +205,6 @@ export function renderKit(): HTMLElement {
);
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("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")),
+53 -108
View File
@@ -1,15 +1,19 @@
import { renderSidebar, renderTopbar } from "./catalog.js";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css";
import { renderSidebar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { CatalogStore } from "./events.js";
import { shouldRetryClose } from "./reconnect.js";
import {
createSession,
fetchNames,
getDaemonInfo,
listWorktrees,
projects as fetchProjects,
reclaimWorktree,
} from "./api.js";
import {
buildFastForwardRequest,
@@ -17,20 +21,21 @@ import {
resolveFastForward,
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { OPEN_SPAWN_EVENT } from "./catalog.js";
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
initTheme();
installViewportSizing();
installHudKeybind();
installHelpKeybind();
registerServiceWorker();
// Register the PWA service worker so the app is installable and the shell loads
@@ -88,63 +93,15 @@ const content = document.createElement("section");
content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the header; a backdrop covers the content while it's open, and tapping a
// session closes it. On desktop these are inert (CSS gates the drawer styling).
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
// similar, backdrop); on desktop the class it toggles is inert.
const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop";
function closeDrawer(): void {
shellEl.classList.remove("drawer-open");
}
function openDrawer(): void {
shellEl.classList.add("drawer-open");
}
function toggleDrawer(): void {
shellEl.classList.toggle("drawer-open");
}
backdrop.addEventListener("click", closeDrawer);
sidebar.el.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer();
});
// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather
// than the browser's back/forward navigation. We only act on a clearly
// horizontal gesture past a threshold, so vertical terminal scrolling (which
// owns its own touch handler) is untouched.
{
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const open = shellEl.classList.contains("drawer-open");
if (dx > 0 && !open) openDrawer();
else if (dx < 0 && open) closeDrawer();
},
{ passive: true },
);
}
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
installDrawerSwipe(drawer);
shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl);
root.replaceChildren(shellEl);
// The pool owns attach connections independently of the route: navigating away
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
@@ -222,14 +179,24 @@ function renderWelcome(): HTMLElement {
Pick a session from the list, or start a new one. Open sessions keep
streaming in the background — switching back is instant.
</p>
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
<div class="empty-actions"></div>`;
const actions = el.querySelector(".empty-actions") as HTMLElement;
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
// the drawer would be swipe-only. CSS hides this on desktop.
const sessions = document.createElement("button");
sessions.type = "button";
sessions.className = "btn btn-ghost welcome-sessions";
sessions.textContent = "☰ Sessions";
sessions.addEventListener("click", () => requestToggleDrawer());
actions.appendChild(sessions);
const spawn = document.createElement("button");
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.title = "Draft pane: n — fast-forward spawn: N";
spawn.addEventListener("click", () => requestOpenSpawn());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
actions.appendChild(spawn);
return el;
}
@@ -267,9 +234,9 @@ function showMultiView(): void {
updateShellRoute();
}
// Spawn affordances (#62): the header button, the sidebar's empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the
// cloned session's dir + command.
// Spawn affordances (#62): the sidebar's footer button, its empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
// cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void {
showMultiView();
// A new tab, not a split of the pane you were reading (#100). "Spawn
@@ -279,10 +246,10 @@ function requestOpenSpawn(): void {
mv.openDraft(undefined, { newTab: true });
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail;
showMultiView();
mv.openDraft({ cwd: s.cwd, command: s.command });
mv.openDraft(spawnSimilarSeed(s));
});
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
@@ -330,16 +297,9 @@ async function requestFastForward(): Promise<void> {
// textarea, search) so an attached session's keys are never hijacked.
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
if (isTypingTarget(e.target)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) return;
if (e.key === "n") {
e.preventDefault();
requestOpenSpawn();
@@ -349,50 +309,38 @@ document.addEventListener("keydown", (e) => {
}
});
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it.
window.addEventListener("keydown", (e) => {
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
if (e.key !== "H" && e.key !== "h") return;
e.preventDefault();
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
}
});
// Escape in the session list hands the keyboard back to the pane.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
| { kind: "worktrees" }
| { kind: "not-found"; raw: string };
function parseRoute(): Route {
const hash = location.hash.replace(/^#/, "");
if (hash === "" || hash === "/") return { kind: "catalog" };
if (hash === "/worktrees") return { kind: "worktrees" };
const m = hash.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "not-found", raw: hash };
}
// Retained worktrees (#84): built lazily on first visit, then kept so a
// revisit re-enters instantly. The daemon's home dir abbreviates paths once
// `/api/info` answers.
let worktreesView: WorktreesViewHandle | undefined;
let homeDir: string | undefined;
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir;
})
.catch(() => {
/* absolute paths are a fine fallback */
});
function showWorktrees(): void {
pool.setVisible([]);
pool.setFocus(null);
contentShowsMv = false;
if (!worktreesView) {
worktreesView = createWorktreesView({
load: listWorktrees,
reclaim: reclaimWorktree,
homeDir: () => homeDir,
});
}
content.replaceChildren(worktreesView.el);
shellEl.dataset.route = "worktrees";
void worktreesView.refresh();
}
function dispatch(): void {
const route = parseRoute();
switch (route.kind) {
@@ -414,9 +362,6 @@ function dispatch(): void {
// tears panes down.
showMultiView();
return;
case "worktrees":
showWorktrees();
return;
}
}
+15 -3
View File
@@ -14,6 +14,7 @@ function sess(
status: "Running" | "Dead",
cwd: string,
name?: string,
blocked_work?: Session["blocked_work"],
): Session {
const seed = parseInt(id.slice(-1), 16) || 0;
return {
@@ -30,6 +31,7 @@ function sess(
last_activity_at: new Date(Date.now() - seed * 11_000).toISOString(),
// The session tree nests by current_cwd; default it to the spawn dir.
current_cwd: cwd,
blocked_work,
};
}
@@ -109,16 +111,26 @@ function stubSpawnApi(): void {
export function seedMock(store: CatalogStore): void {
const sessions: Session[] = [
// ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node.
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign"),
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign", {
uncommitted_files: 4,
untracked_files: 2,
unpushed_commits: 1,
insertions: 312,
deletions: 47,
}),
sess("a1b2c3d4-0000-0000-0000-000000000002", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
sess("a1b2c3d4-0000-0000-0000-000000000003", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "codex --resume", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes", {
uncommitted_files: 1,
}),
// ~/git/maestro/crates/* — sibling subtree under the same repo.
sess("a1b2c3d4-0000-0000-0000-000000000005", "claude", "Running", "/home/dev/git/maestro/crates/daemon", "daemon-work"),
sess("a1b2c3d4-0000-0000-0000-000000000006", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/daemon", "test-loop"),
sess("a1b2c3d4-0000-0000-0000-000000000007", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
// ~/work/acme/* — a separate top-level dir on the same host.
sess("a1b2c3d4-0000-0000-0000-000000000008", "claude", "Running", "/home/dev/work/acme/api", "billing-api"),
sess("a1b2c3d4-0000-0000-0000-000000000008", "gemini", "Running", "/home/dev/work/acme/api", "billing-api", {
untracked_files: 3,
}),
sess("a1b2c3d4-0000-0000-0000-000000000009", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
// ~/git/dotfiles — single session, fully collapsed chain.
sess("a1b2c3d4-0000-0000-0000-000000000010", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
+227 -2
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { emptyLayout, leaves, type Layout } from "./layout.js";
import { addTab, emptyLayout, leaves, type Layout } from "./layout.js";
import { splitLeaf } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import type { ConnectionPool } from "./connections.js";
@@ -17,7 +17,7 @@ function stubFetch(): void {
: url.includes("/api/projects")
? []
: url.includes("/api/names")
? { names: ["allegro-cello"] }
? { names: ["cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
@@ -36,6 +36,10 @@ function fakeSidebar(): SidebarHandle {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
focusList() {},
hasListFocus() {
return false;
},
dispose() {},
};
}
@@ -333,3 +337,224 @@ describe("openDraft newTab (#100)", () => {
expect(leaves(tab.root)).toHaveLength(2);
});
});
describe("multiview closeSession tab cleanup (#137)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("closes the tab when its last session goes away", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.closeSession("s2");
expect(layout.tabs).toHaveLength(1);
expect(layout.tabs[0].id).toBe(first.id);
expect(layout.activeTabId).toBe(first.id);
});
it("closes a background tab without stealing focus from the active one", () => {
const layout = emptyLayout();
const background = layout.tabs[0];
background.focusedLeafId = splitLeaf(background, "", "s1", "row", true);
const active = addTab(layout);
active.focusedLeafId = splitLeaf(active, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.closeSession("s1");
expect(layout.tabs).toHaveLength(1);
expect(layout.tabs[0].id).toBe(active.id);
expect(layout.activeTabId).toBe(active.id);
});
it("keeps a tab that still holds another pane", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
addTab(layout);
layout.activeTabId = tab.id;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.closeSession("s2");
expect(layout.tabs).toHaveLength(2);
expect(leaves(tab.root)).toHaveLength(1);
});
it("keeps a tab holding a draft pane", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const s1 = splitLeaf(tab, "", "s1", "row", true);
splitLeaf(tab, s1, null, "row", true);
addTab(layout);
layout.activeTabId = tab.id;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.closeSession("s1");
expect(layout.tabs).toHaveLength(2);
expect(leaves(tab.root)).toHaveLength(1);
});
it("never closes the only tab", () => {
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.closeSession("s1");
expect(layout.tabs).toHaveLength(1);
expect(layout.tabs[0].root).toBeNull();
expect(layout.activeTabId).toBe(tab.id);
});
});
describe("multiview mobile single pane", () => {
/** Force the mobile media query on, whatever happy-dom's viewport says. */
function forceMobile(): void {
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: query.includes("859"),
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
);
}
beforeEach(() => {
stubFetch();
forceMobile();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("replaces the focused pane instead of splitting beside it", () => {
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.openSessionBeside("s2");
const ls = leaves(tab.root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBe("s2");
expect(layout.tabs).toHaveLength(1);
});
it("keeps a spawn draft in the current pane rather than a new tab", 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(undefined, { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(1);
const ls = leaves(tab.root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBeNull();
expect(ls[0].draft).not.toBeNull();
});
it("renders one pane and no pane pills for a multi-leaf tab", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(1);
expect(mv.el.querySelector(".mv-panebar")).toBeNull();
expect(mv.el.querySelector(".mv-pill")).toBeNull();
});
it("collapses the tab bar even with several tabs", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
});
});
describe("multiview solo tab bar", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("hides the tab bar while only one tab exists", () => {
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();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
});
it("shows the tab bar again once a second tab appears", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
});
});
+30 -42
View File
@@ -22,7 +22,6 @@ import {
removeLeaf,
splitLeaf,
} from "./layout.js";
import { sessionColor } from "./sessioncolor.js";
import type { AttachInfo } from "./attach.js";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -85,9 +84,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
canvas.className = "mv-canvas";
el.append(tabBar, canvas);
// Mobile (issue #35): below 860px the tree projects to a one-pane-at-a-time
// strip of pills — splits/drag/resize are desktop-only. The tree is untouched,
// so widening restores the splits.
// Mobile (issue #35): below 860px the layout collapses to exactly one visible
// pane and no tab bar — splits/drag/resize are desktop-only, and opening or
// spawning replaces the pane rather than adding a second thing to navigate.
// The sidebar drawer is the only navigation. The tree is untouched, so
// widening restores whatever splits and tabs a desktop session built.
const mq = window.matchMedia("(max-width: 859px)");
let isMobile = mq.matches;
mq.addEventListener("change", (e) => {
@@ -97,12 +98,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- highlight helpers ----
function colorMap(): Map<string, string> {
const m = new Map<string, string>();
for (const id of openSessions(layout)) m.set(id, sessionColor(id));
return m;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
@@ -120,7 +115,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
sidebar.setHighlights({
open: openSessions(layout),
focused: focusedLeafSession(),
colors: colorMap(),
});
}
@@ -142,10 +136,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = document.createElement("div");
pane.className = "mv-pane";
pane.dataset.leafId = leaf.id;
if (leaf.sessionId) {
pane.dataset.sessionId = leaf.sessionId;
pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
}
if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId;
const host = document.createElement("div");
host.className = "mv-pane-host";
@@ -402,7 +393,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId) {
// Mobile shows one pane, so there is no "beside": take over the pane. The
// evicted session keeps running and stays a tap away in the sidebar.
if (!target.sessionId || isMobile) {
target.sessionId = sessionId;
target.draft = null;
} else {
@@ -414,7 +407,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
if (opts?.newTab) {
// Mobile keeps a single tab and a single pane: the spawn form takes over the
// current pane instead of opening a tab or a split.
if (opts?.newTab && !isMobile) {
// 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);
@@ -432,9 +427,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId) {
if (!target.sessionId || isMobile) {
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
// picked defaults into `draft` once ready, so that's never reliably null.
target.sessionId = null;
target.draft = draft();
const stale = draftForms.get(target.id);
if (stale) {
@@ -473,16 +469,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
// Mobile single-tab: the tab bar carries no information (one tab, one pane),
// so CSS collapses it and we float a ☰ instead (rendered in renderMobile).
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
el.classList.toggle("mv-mobile-solo", isMobile);
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
if (!tab.root) {
pruneForms(tab);
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
sidebar.setHighlights({ open: openSessions(layout), focused: null });
persist();
return;
}
@@ -507,7 +507,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
persist();
}
// Mobile: a pill strip of the active tab's leaves + one full-screen pane.
// Mobile: the focused leaf, full screen, alone. Panes a desktop session left
// behind stay in the tree but are reached only by tapping the session in the
// sidebar drawer — mobile shows no pane switcher of its own.
function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void {
const ls = leaves(tab.root);
const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0];
@@ -515,24 +517,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const wrap = document.createElement("div");
wrap.className = "mv-mobile";
if (ls.length > 1) {
const bar = document.createElement("div");
bar.className = "mv-panebar";
for (const l of ls) {
const pill = document.createElement("button");
pill.type = "button";
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId));
pill.addEventListener("click", () => {
tab.focusedLeafId = l.id;
render();
});
bar.appendChild(pill);
}
wrap.appendChild(bar);
}
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
wrap.appendChild(pane.el);
@@ -543,7 +527,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
);
pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() });
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
}
function closeSession(sessionId: string): void {
@@ -553,6 +537,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (loc.tab.focusedLeafId === loc.leaf.id) {
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
}
// A tab that held nothing but the dead session goes with it (#137): a killed
// session should not leave an empty tab behind for the operator to close by
// hand. `closeTab` keeps the last tab (emptied) so there is always one.
if (!loc.tab.root) closeTab(layout, loc.tab.id);
render();
}
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { longestCommonPrefix, splitPath } from "./paths.js";
import { longestCommonPrefix, parentDir, splitPath } from "./paths.js";
describe("splitPath", () => {
it("splits on the last slash, keeping it with the directory", () => {
@@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => {
expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
});
});
describe("parentDir", () => {
it("drops the last segment", () => {
expect(parentDir("/home/user/tea/maestro")).toBe("/home/user/tea");
expect(parentDir("/home/user/tea/maestro/")).toBe("/home/user/tea");
});
it("keeps the root for a top-level path", () => {
expect(parentDir("/maestro")).toBe("/");
expect(parentDir("/")).toBe("/");
});
it("is empty for a bare name", () => {
expect(parentDir("maestro")).toBe("");
});
});
+9
View File
@@ -19,3 +19,12 @@ export function longestCommonPrefix(items: string[]): string {
}
return lcp;
}
/** Everything above the last segment of an absolute path: `/a/b/c` → `/a/b`.
* A top-level path keeps `/`, and a trailing slash is ignored. */
export function parentDir(path: string): string {
const trimmed = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
const slash = trimmed.lastIndexOf("/");
if (slash <= 0) return slash === 0 ? "/" : "";
return trimmed.slice(0, slash);
}
+17 -7
View File
@@ -27,7 +27,7 @@ function stubFetch(info: unknown, projects: Project[]): void {
: url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
? { names: ["cello", "fugue", "lyre"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
@@ -79,14 +79,22 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
it("shows the name as the primary line and the full path unabbreviated", async () => {
it("shows the name as the primary line and its parent directory below", 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");
// Only the stem: repeating the last segment would just echo the name. Not
// `~/t`, since the stem is what distinguishes two projects sharing a
// basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
});
it("keeps the last command out of the box", async () => {
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.textContent).not.toContain("claude");
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
@@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
expect(form.querySelector(".picker-empty")).toBeTruthy();
});
it("picking a project selects its cwd and pre-selects its last command", async () => {
it("picking a project selects its cwd and leaves the command alone", async () => {
const p = project({
name: "maestro",
path: "/home/user/maestro",
last_command: "claude",
last_command: "aider",
});
const onChange = vi.fn();
stubFetch(
@@ -157,6 +165,8 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
// The directory and the command are independent choices: the preset stays
// where it was, whatever the project was last run with.
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// The raw input stays hidden — the picked box is the source of truth.
+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();
});
});
+4 -6
View File
@@ -13,12 +13,15 @@ export const PROJECTS = "/api/projects";
export const GITROOT = "/api/gitroot";
export const NAMES = "/api/names";
export const BRANCHES = "/api/branches";
export const WORKTREES = "/api/worktrees";
export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
export const PATH_QUERY = "path";
/** Name of the query parameter `DELETE /api/sessions/:id` takes (#81):
* `force=true` overrides the kill refusal over a guarded worktree. */
export const FORCE_QUERY = "force";
/** Name of the query parameter `NAMES` takes. */
export const COUNT_QUERY = "count";
@@ -30,11 +33,6 @@ export function attach(id: Uuid): string {
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
}
/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */
export function worktree(id: Uuid): string {
return `${WORKTREES}/${encodeURIComponent(id)}`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
-23
View File
@@ -1,23 +0,0 @@
// Stable per-session accent colour, used to map a sidebar row to the pane
// showing that session (issue #33). Hash the session id into a fixed OKLCH
// palette tuned to the maestro tokens; collisions are tolerated when there are
// more open sessions than hues — the colour is a hint, not an identity.
import type { Uuid } from "./types.js";
const PALETTE = [
"oklch(0.80 0.13 152)", // green (the house accent)
"oklch(0.80 0.12 90)", // amber
"oklch(0.76 0.13 30)", // coral
"oklch(0.78 0.11 210)", // teal
"oklch(0.74 0.13 265)", // blue
"oklch(0.76 0.14 320)", // magenta
"oklch(0.80 0.11 60)", // gold
"oklch(0.78 0.12 185)", // cyan
];
export function sessionColor(id: Uuid): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
+128
View File
@@ -0,0 +1,128 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
function session(id: string, cwd: string): Session {
return {
id,
cwd,
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
} as Session;
}
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;
}
const A = "aaaaaaaa-0000-0000-0000-00000000000a";
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
/** Two sessions in sibling directories: two folder headers, two cards. */
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
// Mounted: `focusList` moves real DOM focus, which needs a live document.
document.body.replaceChildren(handle.el);
return { el: handle.el, handle };
}
function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void {
el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
}
function highlighted(el: HTMLElement): string | null {
return el.querySelector<HTMLElement>(".is-highlighted")?.dataset.navKey ?? null;
}
function tree(el: HTMLElement): HTMLElement {
return el.querySelector<HTMLElement>(".tree")!;
}
describe("sidebar keyboard navigation", () => {
it("walks the cards and never stops on an expanded folder", () => {
const { el } = twoFolders();
// The folders are there — the walk just refuses to land on them.
expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2);
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${B}`);
// Wraps.
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "k");
expect(highlighted(el)).toBe(`session:${B}`);
});
it("accepts the arrow keys as well as j/k", () => {
const { el } = twoFolders();
press(tree(el), "ArrowDown");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "ArrowUp");
// Backwards from the first card wraps to the last.
expect(highlighted(el)).toBe(`session:${B}`);
});
it("stops on a collapsed folder, since its cards are gone", () => {
const { el } = twoFolders();
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
header.click(); // collapse /one
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${B}`);
// Enter expands it again and lands on the card it was hiding.
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "Enter");
expect(highlighted(el)).toBe(`session:${A}`);
});
it("focusList starts from the session on screen", () => {
const { el, handle } = twoFolders();
handle.setHighlights({ open: new Set([B]), focused: B });
handle.focusList();
expect(highlighted(el)).toBe(`session:${B}`);
expect(handle.hasListFocus()).toBe(true);
});
it("Escape asks the shell for the pane back", () => {
const { el } = twoFolders();
let asked = 0;
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
press(tree(el), "Escape");
expect(asked).toBe(1);
});
it("leaves the keyboard alone while a row is being renamed", () => {
const { el } = twoFolders();
press(tree(el), "j");
const input = document.createElement("input");
tree(el).appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true }));
expect(highlighted(el)).toBe(`session:${A}`);
});
});
+21 -16
View File
@@ -8,8 +8,10 @@ import {
gitRoot,
} from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
/** Where a worktree's branch comes from: freshly created (default) or picked
@@ -140,10 +142,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field wt-step" hidden>
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
@@ -158,6 +156,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<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
@@ -440,8 +442,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- 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.
// 1. Step one is one box per repository (name, then its parent directory).
// 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
@@ -454,6 +457,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// 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.
// 7. Neither step touches the command: the directory and what runs in it
// are independent choices.
/** "new" (create a worktree) or the path of an existing worktree to run in. */
type WorktreeChoice = "new" | string;
@@ -544,9 +549,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
wtHint.hidden = true;
}
emit();
// 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);
}
/** Groups with at least one member (itself or a worktree) matching `query`. */
@@ -589,7 +591,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd = seedCwd;
cwdRaw.value = seedCwd;
// Restoring must not touch the worktree toggle — the seed carries it.
if (!restoreSelection(seedCwd)) clearProjectSelection();
// A path no box matches (the project is gone, or the scan never saw
// it) falls back to the raw input showing that path: an empty picker
// with an invisible cwd reads as a form that lost the directory.
if (!restoreSelection(seedCwd)) showRawCwd(seedCwd, false);
} else {
showRawCwd(seedCwd, false);
}
@@ -631,14 +636,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
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(" · ");
// The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(project.path)}</span>
<span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
+2 -2
View File
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
const BATCH_A = ["cello", "fugue", "lyre"];
const BATCH_B = ["oboe", "tuba", "harp"];
/** Stub the three fetches the form's initial load makes. Records every
* /api/names URL so tests can assert how many batches were fetched. */
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import type { Session } from "./types.js";
function session(overrides: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/repos/app",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...overrides,
} as Session;
}
describe("spawnSimilarSeed (#28)", () => {
it("seeds the project picker, not the free-text hatch", () => {
const seed = spawnSimilarSeed(session());
expect(seed.cwd).toBe("/repos/app");
expect(seed.command).toBe("claude");
expect(seed.cwdRawVisible).toBe(false);
});
it("keeps the worktree toggle off for a session that has no worktree", () => {
expect(spawnSimilarSeed(session()).worktreeEnabled).toBe(false);
});
it("clones a worktree session as a new worktree of its base repo", () => {
const seed = spawnSimilarSeed(
session({
cwd: "/repos/app-worktrees/feature",
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
}),
);
expect(seed.cwd).toBe("/repos/app");
expect(seed.worktreeEnabled).toBe(true);
});
it("never carries the cloned session's name", () => {
const seed = spawnSimilarSeed(session({ metadata: { name: "amber-falcon" } }));
expect(seed.name).toBeUndefined();
});
});
+30
View File
@@ -0,0 +1,30 @@
// "Spawn similar" (#28): the seed a cloned session hands to a draft pane.
// Pure — the caller owns opening the pane.
import type { Draft } from "./layout.js";
import type { Session } from "./types.js";
/** Clone a session into draft values: same project, same command, same
* worktree decision, fresh name.
*
* A worktree session seeds its *base repository*, not the checkout the daemon
* made for it: "similar" is another session of that project, and the daemon
* never adopts an existing worktree (docs/WORKTREES.md), so seeding the
* checkout would ask it to build a worktree out of a worktree. The picker
* then lands on that repository's box with "+ new" active.
*
* `cwdRawVisible: false` keeps the two-step project picker on screen with the
* cloned project selected, instead of dropping the path into the free-text
* escape hatch.
*
* The name is left out on purpose: a clone is a new session and takes its own
* generated handle. */
export function spawnSimilarSeed(s: Session): Partial<Draft> {
const wt = s.worktree ?? null;
return {
command: s.command,
cwd: wt ? wt.base_repo : s.cwd,
worktreeEnabled: wt != null,
cwdRawVisible: false,
};
}
+324 -357
View File
@@ -8,7 +8,7 @@
:root {
/* ---- type ---- */
--font-mono:
ui-monospace, "SF Mono", "SFMono-Regular", "Cascadia Code", "JetBrains Mono",
"JetBrains Mono", ui-monospace, "SF Mono", "SFMono-Regular", "Cascadia Code",
"Roboto Mono", Menlo, Consolas, "Liberation Mono", monospace;
--font-sans:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -365,73 +365,6 @@ textarea {
/* ---------- app shell: sidebar + content ---------- */
/* Global app header: brand + app-level actions (spawn, theme toggle), spanning
the full width above the split. */
.app-header {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 0 0 auto;
padding: var(--sp-1) var(--sp-3);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
}
/* Hamburger to the left of the brand — toggles the off-canvas session drawer.
Mobile only (desktop keeps the sidebar permanently in view). */
.app-menu {
display: none;
place-items: center;
flex: none;
width: 32px;
height: 32px;
border: none;
border-radius: var(--r-1);
background: transparent;
color: var(--ink);
font-size: var(--fs-17);
cursor: pointer;
}
.app-menu:hover {
background: var(--surface-2);
}
/* Three-part header: brand (left), status (centered), actions (right). Equal
flex on the outer two keeps the status group truly centered. */
.app-header .brand {
flex: 1 1 0;
min-width: 0;
margin-right: 0;
}
.app-status {
flex: 0 1 auto;
display: flex;
justify-content: center;
min-width: 0;
overflow: hidden;
}
.app-status .statusbar {
border-bottom: 0;
background: transparent;
padding: 0;
flex-wrap: nowrap;
}
.app-status .statusbar .spacer {
display: none;
}
.app-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1 1 0;
justify-content: flex-end;
}
.app-shell {
flex: 1 1 auto;
min-height: 0;
@@ -479,19 +412,29 @@ textarea {
.sidebar-resize {
display: none;
}
.app-status {
display: none;
}
}
/* Sidebar's own slim header: just the session search (app-level actions live
in the global top bar). */
/* Sidebar's own slim header: just the session search. */
/* The search input IS the header — it spans edge to edge, flush, with just a
bottom rule for separation. No wrapper padding, no boxed border/radius. */
.sidebar-search {
flex: 0 0 auto;
}
/* App-level actions (help, theme, spawn), pinned to the sidebar's foot. */
.sidebar-footer {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 0 0 auto;
padding: var(--sp-2) var(--sp-3);
border-top: 1px solid var(--border);
}
.sidebar-footer .spawn-btn {
flex: 1 1 auto;
}
.sidebar-search .search {
width: 100%;
max-width: none;
@@ -625,42 +568,6 @@ textarea {
box-shadow: 0 0 0 3px var(--accent-tint);
}
/* ---------- status bar ---------- */
.statusbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
background: var(--surface);
font-size: var(--fs-12);
color: var(--muted);
}
.statusbar .spacer {
/* zero basis: claim leftover space without inflating the bar's min width */
flex: 1 1 0;
min-width: var(--sp-2);
}
.session-tally {
display: inline-flex;
align-items: center;
gap: 7px;
font-variant-numeric: tabular-nums;
}
.session-tally::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--live);
box-shadow: 0 0 5px var(--live-glow);
}
/* ---------- session tree ---------- */
.tree {
@@ -736,7 +643,7 @@ textarea {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 1px var(--sp-2);
margin: 1px 0;
padding: 4px var(--sp-3);
border-radius: var(--r-1);
color: var(--ink);
@@ -761,7 +668,19 @@ textarea {
.tree-leaf:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px var(--accent-ring);
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
}
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
is a cursor, not a statement about what is mounted, so it reads as a ring
rather than a fill and the two can sit on the same row. */
.tree-leaf.is-highlighted,
.tree-folder-header.is-highlighted {
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
.tree {
outline: none;
}
/* The session currently mounted in the content pane. */
@@ -814,6 +733,175 @@ textarea {
white-space: nowrap;
}
/* 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);
}
/* ---------- help overlay (`?`) ---------- */
.help-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
place-items: center;
padding: var(--sp-4);
background: oklch(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.help-panel {
width: min(440px, 100%);
max-height: min(80vh, 640px);
overflow-y: auto;
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.help-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.help-title {
margin: 0;
font-family: var(--font-mono);
font-size: var(--fs-15);
color: var(--ink);
}
.help-close {
appearance: none;
background: none;
border: 0;
border-radius: var(--r-2);
color: var(--muted);
font: inherit;
line-height: 1;
padding: var(--sp-1) var(--sp-2);
cursor: pointer;
}
.help-close:hover,
.help-close:focus-visible {
background: var(--surface-3);
color: var(--ink);
outline: none;
}
.help-group + .help-group {
margin-top: var(--sp-4);
}
.help-group-title {
margin: 0 0 var(--sp-2);
font-size: var(--fs-11);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.help-list {
margin: 0;
}
/* Keycaps in a fixed column so the descriptions line up down the list. */
.help-row {
display: grid;
grid-template-columns: 14ch 1fr;
align-items: baseline;
gap: var(--sp-3);
padding: var(--sp-1) 0;
}
.help-keys {
display: flex;
flex-wrap: nowrap;
gap: var(--sp-1);
margin: 0;
}
.help-keys kbd {
font-family: var(--font-mono);
font-size: var(--fs-11);
background: var(--surface-2);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: var(--r-1);
padding: 1px 6px;
color: var(--ink);
}
.help-desc {
margin: 0;
font-size: var(--fs-13);
color: var(--muted);
}
/* 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 {
@@ -878,6 +966,46 @@ textarea {
truncation target when even the complete trailing set cannot fit. */
.leaf-cmd {
flex: 0 1 auto;
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
/* The tool's mark stands in for the program the command line starts with
(docs/SIDEBAR.md). It keeps its own colours where the tool has brand
colours, and inherits the muted fragment colour where it does not. */
.tool-icon {
flex: 0 0 auto;
opacity: 0.9;
}
.leaf-cmd-rest {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Blocked work as diff notation: `+124 -18 ?3 ↑2` (docs/SIDEBAR.md). The
spelled-out breakdown is the tooltip; only the additions and deletions take
colour, so the ⚠-less fragment still reads as a diff and not as an alarm. */
.leaf-blocked {
display: inline-flex;
align-items: center;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.blocked-stat[data-stat="add"] {
color: var(--live);
}
.blocked-stat[data-stat="del"] {
color: var(--danger);
}
.blocked-stat[data-stat="files"],
.blocked-stat[data-stat="untracked"],
.blocked-stat[data-stat="unpushed"] {
color: var(--faint);
}
/* Actions live in line 1's flow at its right edge — never overlaid on the
@@ -930,6 +1058,64 @@ textarea {
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}
/* ---------- forced-kill prompt (#81) ---------- */
.kill-dialog {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
place-items: center;
padding: var(--sp-4);
background: oklch(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.kill-dialog-panel {
width: min(400px, 100%);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.kill-dialog-title {
margin: 0 0 var(--sp-2);
font-family: var(--font-mono);
font-size: var(--fs-15);
color: var(--ink);
}
.kill-dialog-body {
margin: 0;
color: var(--muted);
font-size: var(--fs-13);
line-height: 1.5;
}
.kill-dialog-error {
margin: var(--sp-2) 0 0;
color: var(--danger);
font-size: var(--fs-13);
}
.kill-dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.btn-danger {
color: var(--danger);
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}
.btn-danger:hover {
background: var(--danger-tint);
border-color: var(--danger);
}
/* ---------- empty / loading ---------- */
.empty {
@@ -1013,16 +1199,17 @@ textarea {
width: 26px;
}
.attach-header .back {
/* Opens the off-canvas session drawer. Desktop keeps the sidebar permanently in
view, so it is mobile-only — same rule as the welcome state's ☰. */
.attach-header .sessions-btn {
font-size: var(--fs-15);
width: 28px;
height: 26px;
}
/* Desktop keeps the session list permanently in view, so the in-header "back"
arrow is redundant — the sidebar is the nav. Mobile (single-pane) keeps it. */
@media (min-width: 860px) {
.attach-header .back {
.attach-header .sessions-btn,
.welcome-sessions {
display: none;
}
}
@@ -1058,6 +1245,9 @@ textarea {
.attach-title .at-cmd {
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 4px;
}
.attach-title .at-cmd::before {
@@ -1142,9 +1332,13 @@ 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), so the browser must
not claim the gesture. On `.xterm`, not the viewport: `.xterm-screen` paints
over the viewport and is what a finger lands on. */
.attach-body .xterm {
touch-action: none;
}
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
@@ -1848,9 +2042,6 @@ textarea {
max-width: none;
order: -1;
}
.statusbar .spacer {
display: none;
}
.tree-leaf .leaf-actions {
opacity: 1;
}
@@ -1951,12 +2142,6 @@ textarea {
color: var(--muted);
}
/* debug-HUD navbar toggle: lit when the HUD is on. */
.debug-toggle.is-active {
color: var(--accent);
border-color: var(--accent);
}
/* =========================================================================
multi-view (#30 splits, #31 tabs, #33 highlights)
Tabs of nested split trees; each session leaf hosts a live .attach. The
@@ -1969,9 +2154,10 @@ textarea {
display: flex;
flex-direction: column;
}
/* Mobile, single tab: the tab bar is pure chrome — collapse it (a floating ☰
inside the pane keeps the sidebar drawer reachable). */
.mv-mobile-solo > .mv-tabs {
/* Mobile: the tab bar is a second navigation surface — collapse it (a floating
☰ inside the pane keeps the sidebar drawer reachable). */
.mv-mobile-solo > .mv-tabs,
.mv-solo-tab > .mv-tabs {
display: none;
}
@@ -2048,7 +2234,7 @@ textarea {
.mv-canvas > .mv-split,
.mv-canvas > .mv-pane {
position: absolute;
inset: var(--sp-2);
inset: 0;
}
.mv-canvas > .content-welcome {
position: absolute;
@@ -2136,20 +2322,9 @@ textarea {
min-width: 0;
min-height: 0;
}
/* per-session colour strip — maps a pane to its sidebar row */
.mv-pane::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: var(--mv-color, transparent);
z-index: 3;
pointer-events: none;
}
/* focused pane: accent ring + glow, full strength */
.mv-pane.is-focused {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring);
/* focused pane: titlebar tints instead of a ring, full strength */
.mv-pane.is-focused .attach-header {
background: color-mix(in oklab, var(--accent) 14%, var(--surface));
}
/* unfocused panes recede so the eye finds the live one */
.mv-pane:not(.is-focused) {
@@ -2161,11 +2336,6 @@ textarea {
.mv-pane:not(.is-focused) .attach-header {
opacity: 0.7;
}
/* per-pane back button is meaningless in multi-view (use Close pane instead) */
.mv-pane .attach-header .back {
display: none;
}
/* Draft pane (#57): an empty pane renders the shared spawn form inline.
The host is already a flex column; the form fills and scrolls within it. */
.mv-pane-host > .mv-draft {
@@ -2252,35 +2422,16 @@ textarea {
border-radius: var(--r-1);
}
/* sidebar two-tier highlight + colour dot (issue #33).
/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */
elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf.is-open::after,
.tree-leaf.is-active::after {
content: "";
position: absolute;
right: 9px;
top: 50%;
width: 7px;
height: 7px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--mv-color, var(--accent));
pointer-events: none;
}
.tree-leaf.is-open::after {
opacity: 0.7;
}
.tree-leaf.is-active::after {
box-shadow: 0 0 8px var(--mv-color, var(--accent-ring));
}
/* =========================================================================
multi-view mobile (#35): below 860px the split tree projects to a pill strip
with one pane visible; the sidebar becomes an off-canvas drawer (☰).
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
========================================================================= */
.mv-backdrop {
display: none;
@@ -2288,7 +2439,6 @@ textarea {
.mv-mobile {
position: absolute;
/* Flush to the edges — a single full-screen pane needs no canvas inset. */
inset: 0;
display: flex;
flex-direction: column;
@@ -2301,50 +2451,12 @@ textarea {
border-radius: 0;
box-shadow: none;
}
.mv-panebar {
display: flex;
gap: 4px;
padding: var(--sp-1) var(--sp-2);
overflow-x: auto;
scrollbar-width: none;
flex: none;
}
.mv-pill {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 7px var(--sp-3);
border-radius: var(--r-1);
background: var(--surface-2);
color: var(--muted);
font: 600 var(--fs-12) / 1 var(--font-mono);
white-space: nowrap;
cursor: pointer;
border: 1px solid transparent;
}
.mv-pill::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mv-color, var(--faint));
flex: none;
}
.mv-pill.is-active {
background: var(--accent-tint);
color: var(--accent-strong);
border-color: var(--accent-ring);
}
.mv-mobile > .mv-pane {
flex: 1;
min-height: 0;
}
@media (max-width: 859px) {
.app-menu {
display: inline-grid;
}
/* sidebar → off-canvas drawer (override the route-based show/hide) */
.app-shell > .sidebar {
display: flex !important;
@@ -2375,148 +2487,3 @@ textarea {
background: oklch(0 0 0 / 0.5);
}
}
/* ---------- retained worktrees (#84) ---------- */
.worktrees {
padding: var(--sp-4);
max-width: 960px;
margin: 0 auto;
}
.wt-head {
margin-bottom: var(--sp-4);
}
.wt-title {
font-family: var(--font-mono);
font-size: var(--fs-15);
font-weight: 600;
color: var(--ink);
margin: 0 0 var(--sp-1);
}
.wt-sub {
color: var(--muted);
font-size: var(--fs-13);
margin: 0;
}
.wt-row {
display: flex;
align-items: baseline;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-2);
border-bottom: 1px solid var(--surface-2);
font-family: var(--font-mono);
font-size: var(--fs-13);
flex-wrap: wrap;
}
.wt-branch {
color: var(--ink);
font-weight: 600;
}
.wt-path {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wt-reason {
color: var(--faint);
}
/* Status never rides on color alone: the label itself says what blocks. */
.wt-way[data-clear] {
color: var(--faint);
}
.wt-way:not([data-clear]) {
color: var(--warn);
}
.wt-row .wt-reclaim-btn {
margin-left: auto;
}
.wt-row .wt-reclaim-btn::before {
content: "[";
color: var(--danger);
}
.wt-row .wt-reclaim-btn::after {
content: "]";
color: var(--danger);
}
.wt-row .wt-reclaim-btn:hover {
color: var(--danger);
background: var(--danger-tint);
}
.btn-danger {
color: var(--danger);
font-weight: 600;
}
.btn-danger:hover {
background: var(--danger-tint);
}
/* The destructive confirmation, centered over the section. */
.wt-confirm {
position: fixed;
inset: 0;
z-index: var(--z-backdrop);
display: flex;
align-items: center;
justify-content: center;
background: oklch(0 0 0 / 0.5);
padding: var(--sp-4);
}
.wt-confirm[hidden] {
display: none;
}
.wt-confirm-panel {
pointer-events: auto;
background: var(--bg);
border: 1px solid var(--surface-2);
padding: var(--sp-4);
max-width: 480px;
font-family: var(--font-sans);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.wt-confirm-q {
margin: 0;
font-family: var(--font-mono);
font-weight: 600;
color: var(--ink);
}
.wt-confirm-way {
margin: 0;
font-family: var(--font-mono);
font-size: var(--fs-13);
color: var(--warn);
}
.wt-confirm-note {
margin: 0;
font-size: var(--fs-13);
color: var(--muted);
}
.wt-confirm-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-2);
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
it("recognises the agents and shells sessions are spawned with", () => {
const cases: Array<[string, string]> = [
["claude", "claude"],
["codex", "codex"],
["opencode", "opencode"],
["gemini", "gemini"],
["aider", "aider"],
["cursor-agent", "cursor"],
["pi", "pi"],
["zsh", "shell"],
["bash", "shell"],
["fish", "shell"],
];
for (const [command, tool] of cases) {
expect(splitCommand(command).tool, command).toBe(tool);
}
});
it("drops everything after the program", () => {
expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
tool: "claude",
program: "claude",
});
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
});
it("looks through paths, env assignments and wrappers", () => {
expect(splitCommand("/usr/local/bin/claude").tool).toBe("claude");
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
});
it("leaves an unrecognised program as its bare name, mark-less", () => {
expect(splitCommand("hx src/main.rs")).toEqual({
tool: null,
program: "hx",
});
// A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull();
expect(splitCommand("").tool).toBeNull();
});
it("gives every recognised tool a labelled mark", () => {
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
const svg = toolIconSvg(tool);
expect(svg, tool).toContain("<svg");
expect(svg, tool).toContain(`data-tool="${tool}"`);
expect(svg, tool).toContain("aria-label=");
}
expect(toolIconSvg("nonesuch")).toBeNull();
});
});
+140
View File
@@ -0,0 +1,140 @@
/** Tool icons for the sidebar's command fragment (docs/SIDEBAR.md).
*
* A session's command line starts with the program that runs it, and that
* program is nearly always one of a handful of agents or a shell. Rendering it
* as its own mark buys back the whole fragment's width — the arguments are
* dropped, so a card says which tool runs, not how it was invoked.
*
* Recognition is deliberately narrow: an unrecognised program keeps its name
* as text, so the fragment never renders a mark that stands for the wrong tool.
*/
/** Command wrappers that run another program: the tool is what follows. */
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]);
/** Program name (already basenamed and lowercased) → icon id. */
const BY_PROGRAM: Record<string, string> = {
claude: "claude",
"claude-code": "claude",
codex: "codex",
opencode: "opencode",
gemini: "gemini",
aider: "aider",
cursor: "cursor",
"cursor-agent": "cursor",
pi: "pi",
bash: "shell",
zsh: "shell",
sh: "shell",
fish: "shell",
dash: "shell",
ksh: "shell",
nu: "shell",
xonsh: "shell",
};
/** Human name behind an icon id, for the tooltip and the aria label. */
export const TOOL_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "opencode",
gemini: "Gemini CLI",
aider: "Aider",
cursor: "Cursor",
pi: "pi",
shell: "Shell",
};
export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null;
/** The program the command runs, basenamed and stripped of its arguments —
* what a card shows when there is no icon to show instead. */
program: string;
}
/** Split a command line into the tool its icon stands for and the program name.
*
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0;
// Leading `FOO=bar` assignments and wrappers stand in front of the real
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped.
while (i < words.length) {
const word = words[i];
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
i += 1;
continue;
}
const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(wrapper)) {
i += 1;
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue;
}
break;
}
if (i >= words.length) return { tool: null, program: command.trim() };
const program = basename(words[i]);
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
}
function basename(word: string): string {
const cut = word.lastIndexOf("/");
return cut >= 0 ? word.slice(cut + 1) : word;
}
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
function burst(): string {
const rays = Array.from({ length: 8 }, (_, k) => {
const long = k % 2 === 0;
const tip = long ? 1.1 : 3.4;
const waist = long ? 6.3 : 6.6;
const half = long ? 1.05 : 0.85;
return `<path d="M8 ${tip} L${8 + half} ${waist} L8 8 L${8 - half} ${waist} Z" transform="rotate(${k * 45} 8 8)"/>`;
}).join("");
return `<g fill="#d97757">${rays}</g>`;
}
const SVGS: Record<string, string> = {
claude: burst(),
// A hexafoil knot in the shape of OpenAI's mark. The petals stay thin and
// wide apart: three fatter loops merge into a blob at 13px.
codex: `<g fill="none" stroke="currentColor" stroke-width="1.05">
<ellipse cx="8" cy="8" rx="2.5" ry="6.7"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(60 8 8)"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(120 8 8)"/>
</g>`,
gemini: `<path fill="#4285f4" d="M8 0.8C8.6 4.6 11.4 7.4 15.2 8C11.4 8.6 8.6 11.4 8 15.2C7.4 11.4 4.6 8.6 0.8 8C4.6 7.4 7.4 4.6 8 0.8Z"/>`,
// An isometric cube, the shape Cursor's mark is built from.
cursor: `<g fill="currentColor">
<path d="M8 1 L14.5 4.75 L8 8.5 L1.5 4.75 Z" opacity="0.95"/>
<path d="M14.5 4.75 L14.5 12.25 L8 16 L8 8.5 Z" opacity="0.5"/>
<path d="M1.5 4.75 L8 8.5 L8 16 L1.5 12.25 Z" opacity="0.75"/>
</g>`,
shell: `<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.6 4 L6.4 8 L2.6 12"/>
<path d="M8.4 12.6 H13.4"/>
</g>`,
opencode: monogram("oc"),
aider: monogram("ai"),
pi: monogram("π"),
};
/** Tools whose own mark is a wordmark get their initials instead — inventing
* a logo for them would be worse than saying which letters they start with.
* No chip around the letters: a border this small eats the height the letters
* need to stay apart. */
function monogram(text: string): string {
return `<text x="8" y="8.6" fill="currentColor" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="11" font-weight="700" letter-spacing="-0.6" text-anchor="middle" dominant-baseline="central">${text}</text>`;
}
/** The `<svg>` for an icon id, or `null` when the id has no mark. */
export function toolIconSvg(tool: string): string | null {
const body = SVGS[tool];
if (!body) return null;
const label = TOOL_LABELS[tool] ?? tool;
return `<svg class="tool-icon" data-tool="${tool}" viewBox="0 0 16 16" width="13" height="13" role="img" aria-label="${label}"><title>${label}</title>${body}</svg>`;
}
+10
View File
@@ -39,6 +39,16 @@ describe("buildTree", () => {
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
});
it("folds the sole root folder into each of its own children when it branches", () => {
const root = buildTree(
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
"/home/n",
"",
) as TreeFolder;
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
});
it("stops collapsing where a directory branches", () => {
const root = buildTree(
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
+9
View File
@@ -59,6 +59,15 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
for (const c of n.children) if (c.kind === "folder") sortRec(c);
};
sortRec(root);
// The root's sole child (usually `~`) has no sibling to distinguish it
// from, so it folds into every one of its own children, not just a lone
// one — unlike the chain merge below, and only here at the very top.
if (root.children.length === 1 && root.children[0].kind === "folder") {
const only = root.children[0];
root.children = only.children.map((c) =>
c.kind === "folder" ? { ...c, name: `${only.name}/${c.name}` } : c,
);
}
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
// one compact folder rather than four nested ones.
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
+60 -23
View File
@@ -157,10 +157,6 @@ export interface BranchInfo {
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
export type Branches = BranchInfo[];
/** Why a retained worktree outlived its session (#80). New values are
* additive; unknown reasons render generically. */
export type RetainedReason = "force-killed" | "exited-with-unsaved-work";
/** What currently blocks an unforced kill of a worktree's session (#80),
* recomputed by the daemon on every read. All counts default to 0 on the
* wire for older daemons. */
@@ -168,19 +164,42 @@ export interface InTheWay {
uncommitted_files?: number;
untracked_files?: number;
unpushed_commits?: number;
/** Lines added across the tracked changes, staged and unstaged together. */
insertions?: number;
/** Lines removed across the same tracked changes. */
deletions?: number;
}
/** One entry of `GET /api/worktrees` (#80): a retained worktree with its
* in-the-way state as of this read. Reclaiming it removes the directory but
* never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */
export interface RetainedWorktree {
id: Uuid;
path: string;
base_repo: string;
branch: string;
reason: RetainedReason;
retained_at: IsoTimestamp;
in_the_way?: InTheWay;
/** One fragment of the sidebar's compact blocked-work stat (docs/SIDEBAR.md).
* `kind` picks the colour; `text` is what the client prints. */
export interface BlockerStat {
kind: "add" | "del" | "files" | "untracked" | "unpushed";
text: string;
}
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`.
*
* The line counts cover tracked changes only, so a tree whose only tracked
* change carries no line delta — a mode change, a rename — falls back to the
* file count (`~2`) rather than claiming `+0 -0`. Both clients render exactly
* these fragments; the spelled-out {@link formatInTheWay} survives in the
* tooltip and in the kill refusal. */
export function blockerStats(w: InTheWay): BlockerStat[] {
const stats: BlockerStat[] = [];
const added = w.insertions ?? 0;
const removed = w.deletions ?? 0;
const changed = w.uncommitted_files ?? 0;
if (added > 0 || removed > 0) {
if (added > 0) stats.push({ kind: "add", text: `+${added}` });
if (removed > 0) stats.push({ kind: "del", text: `-${removed}` });
} else if (changed > 0) {
stats.push({ kind: "files", text: `~${changed}` });
}
if ((w.untracked_files ?? 0) > 0)
stats.push({ kind: "untracked", text: `?${w.untracked_files}` });
if ((w.unpushed_commits ?? 0) > 0)
stats.push({ kind: "unpushed", text: `\u2191${w.unpushed_commits}` });
return stats;
}
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
@@ -199,12 +218,6 @@ export function formatInTheWay(w: InTheWay): string {
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
}
/** Same reason labels the CLI table and the TUI overlay use; unknown future
* reasons render their raw wire value. */
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. */
@@ -240,14 +253,38 @@ export type ClientControl =
| { type: "Error"; code: string; message: string };
export interface ApiError {
error: { code: string; message: string };
error: {
code: string;
message: string;
/** Set only by the kill guard (#79): what blocked the unforced kill. */
unsaved_work?: InTheWay;
};
}
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
+41
View File
@@ -108,6 +108,16 @@ describe("spawnForm worktree step (#95)", () => {
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();
@@ -230,4 +240,35 @@ describe("spawnForm worktree step (#95)", () => {
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
expect(toggle(form).checked).toBe(false);
});
it("lands a 'spawn similar' seed on the cloned project with '+ new' active", async () => {
urls = stubFetch([MAESTRO, WT_A, WT_B]);
handle = createSpawnForm({
onSuccess() {},
seed: { cwd: MAESTRO.path, cwdRawVisible: false, worktreeEnabled: true },
});
document.body.appendChild(handle.el);
await drain();
const form = handle.el;
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
expect(activeChoice(form)!.dataset.kind).toBe("new");
expect(toggle(form).checked).toBe(true);
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(true);
});
it("falls back to the raw input when the seeded directory matches no project", async () => {
urls = stubFetch([MAESTRO, WT_A]);
handle = createSpawnForm({
onSuccess() {},
seed: { cwd: "/home/user/gone", cwdRawVisible: false, worktreeEnabled: false },
});
document.body.appendChild(handle.el);
await drain();
const form = handle.el;
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(false);
expect(raw.value).toBe("/home/user/gone");
});
});
-114
View File
@@ -1,114 +0,0 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { createWorktreesView } from "./worktreesView.js";
import type { RetainedWorktree } from "./types.js";
function worktree(overrides: Partial<RetainedWorktree>): RetainedWorktree {
return {
id: "11111111-2222-3333-4444-555555555555",
path: "/home/naps/tea/maestro/worktrees/feat/login",
base_repo: "/home/naps/tea/maestro",
branch: "feat/login",
reason: "force-killed",
retained_at: "2026-08-24T10:00:00Z",
in_the_way: {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
...overrides,
};
}
/** Mount a view over a scripted store and return (el, reclaimed-ids). */
function mount(items: RetainedWorktree[], homeDir = "/home/naps") {
const reclaimed: string[] = [];
const view = createWorktreesView({
load: async () => items,
reclaim: async (id) => {
reclaimed.push(id);
items.splice(
items.findIndex((w) => w.id === id),
1,
);
},
homeDir: () => homeDir,
});
document.body.appendChild(view.el);
return { view, reclaimed };
}
describe("retained-worktrees view (#84)", () => {
it("lists branch, abbreviated path, reason and what is in the way", async () => {
const { view } = mount([
worktree({
path: "/home/naps/tea/maestro/worktrees/feat/login",
}),
]);
await view.refresh();
const text = view.el.textContent ?? "";
expect(text).toContain("feat/login");
// Sidebar-style abbreviation of the path against the daemon's home dir,
// full path kept in the tooltip.
expect(text).toContain("t/m/w/f/login");
const pathEl = view.el.querySelector<HTMLElement>(".wt-path")!;
expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login");
// Same reason labels as `maestro worktrees list` and the TUI overlay.
expect(text).toContain("force-killed");
// Same phrasing as InTheWay's Display impl — shared with the kill refusal.
expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits");
});
it("says a tree whose work has since been pushed is clear", async () => {
const { view } = mount([worktree({ in_the_way: {} })]);
await view.refresh();
expect(view.el.textContent).toContain("no unsaved work");
});
it("renders an empty store as a normal state, not an error", async () => {
const { view } = mount([]);
await view.refresh();
const text = view.el.textContent ?? "";
expect(text).toContain("no retained worktrees");
expect(text.toLowerCase()).not.toContain("error");
expect(text.toLowerCase()).not.toContain("failed");
});
it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => {
const wt = worktree({ branch: "feat/login" });
const { view, reclaimed } = mount([wt]);
await view.refresh();
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
const confirmText = view.el.querySelector<HTMLElement>(".wt-confirm")!.textContent ?? "";
expect(confirmText).toContain("Reclaim worktree feat/login?");
// Names what will be lost before it happens…
expect(confirmText).toContain(
"2 uncommitted files, 1 untracked file, 3 unpushed commits",
);
// …and says reclaim does not take the commits too.
expect(confirmText).toContain("never deleted");
view.el.querySelector<HTMLButtonElement>(".wt-confirm-cancel")!.click();
expect(reclaimed).toEqual([]);
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
view.el.querySelector<HTMLButtonElement>(".wt-confirm-go")!.click();
await Promise.resolve();
await Promise.resolve();
expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]);
// The row is gone once the refresh lands.
expect(view.el.textContent).toContain("no retained worktrees");
});
it("sorts oldest-retained first, like every other client", async () => {
const { view } = mount([
worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }),
worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }),
]);
await view.refresh();
const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent);
expect(branches).toEqual(["older", "newer"]);
});
});
-166
View File
@@ -1,166 +0,0 @@
// Retained-worktrees view (#84): every worktree the daemon is holding that no
// session uses, listed from `GET /api/worktrees` with a reclaim action.
//
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
// overlay: same reason labels, same in-the-way phrasing via
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
// as what it is — destructive — naming what will be lost before it happens
// and saying out loud that the branch survives (docs/WORKTREES.md).
import { escapeHtml } from "./html.js";
import {
abbreviateCwd,
abbreviateFolderName,
formatInTheWay,
reasonLabel,
type RetainedWorktree,
type Uuid,
} from "./types.js";
export interface WorktreesDeps {
/** `GET /api/worktrees`. */
load(): Promise<RetainedWorktree[]>;
/** `DELETE /api/worktrees/:id`. */
reclaim(id: Uuid): Promise<void>;
/** The daemon's home dir, for `~` abbreviation of paths. */
homeDir(): string | undefined;
}
export interface WorktreesViewHandle {
el: HTMLElement;
/** Re-fetch the store and re-render. Safe to call repeatedly. */
refresh(): Promise<void>;
dispose(): void;
}
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
return [...items].sort(
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
);
}
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
const el = document.createElement("section");
el.className = "worktrees";
el.innerHTML = `
<header class="wt-head">
<h2 class="wt-title">retained worktrees</h2>
<p class="wt-sub">
Kept when a session ended with unsaved work. Reclaiming removes the
directory only; branches are never deleted.
</p>
</header>
<div class="wt-list"></div>
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
`;
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
let pendingId: string | null = null;
const render = (items: RetainedWorktree[]) => {
listHost.replaceChildren();
if (items.length === 0) {
// A drained store is the normal state — say so plainly, not like
// something went wrong.
const empty = document.createElement("div");
empty.className = "empty";
empty.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌂</div>
<div class="empty-title">no retained worktrees</div>
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
listHost.appendChild(empty);
return;
}
const home = deps.homeDir();
for (const w of sorted(items)) {
listHost.appendChild(renderRow(w, home));
}
};
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
const row = document.createElement("div");
row.className = "wt-row";
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
row.innerHTML = `
<span class="wt-branch">${escapeHtml(w.branch)}</span>
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
`;
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
showConfirm(w);
});
return row;
};
const showConfirm = (w: RetainedWorktree) => {
pendingId = w.id;
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
// in the way right now, and that the branch survives reclaim.
confirmEl.innerHTML = `
<div class="wt-confirm-panel">
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
<div class="wt-confirm-actions">
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
</div>
</div>`;
confirmEl.hidden = false;
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
};
const hideConfirm = () => {
pendingId = null;
confirmEl.hidden = true;
confirmEl.replaceChildren();
};
confirmEl.addEventListener("click", async (e) => {
const target = e.target as HTMLElement;
if (target.closest(".wt-confirm-cancel")) {
hideConfirm();
return;
}
if (!target.closest(".wt-confirm-go") || !pendingId) return;
const id = pendingId;
hideConfirm();
try {
await deps.reclaim(id);
} catch (err) {
alert(`Failed to reclaim: ${(err as Error).message}`);
}
await refresh();
});
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
};
document.addEventListener("keydown", onKey);
const refresh = async () => {
try {
render(await deps.load());
} catch (err) {
listHost.replaceChildren();
const fail = document.createElement("div");
fail.className = "empty";
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
listHost.appendChild(fail);
}
};
return {
el,
refresh,
dispose() {
document.removeEventListener("keydown", onKey);
el.remove();
},
};
}