Compare commits

...
Author SHA1 Message Date
Miguel Palhas 4932cbf3b3 feat(maestro): scaffold Android project (Kotlin, Compose, Material 3)
Greenfield Gradle project under android/ — single-activity, Compose-only
UI, Kotlin DSL + version catalog throughout. MainActivity shows a
placeholder screen; no daemon connection yet (#262).

minSdk 26, targetSdk 34. OkHttp and kotlinx-serialization-json wired in
for the upcoming daemon client. Debug signing config uses a committed
debug.keystore so the APK installs without extra setup.
2026-09-12 23:46:55 +01:00
Miguel PalhasandClaude Opus 4.6 1ea463f6cf fix(maestro): collapse mobile drawer after spawning a session
ci / web (push) Failing after 28s
ci / flake (push) Successful in 4m6s
ci / rust (push) Successful in 7m37s
mountSpawned now fires a CLOSE_DRAWER_EVENT so the sidebar gets out of
the way once the new session lands. Covers fast-forward, modal, and
command-bar spawn paths.

Closes #261

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 23:31:52 +01:00
Miguel PalhasandClaude Opus 4.6 7c27a897ac feat(maestro): optimistic session kill in sidebar
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m13s
ci / rust (push) Successful in 7m25s
Close the kill dialog and remove the session card immediately on
confirm instead of waiting for the DELETE round-trip. Restores the
session if the daemon refuses with kill_guarded and re-opens the
dialog in force mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 21:21:36 +01:00
Miguel Palhas a9717d01c8 Merge remote-tracking branch 'origin/main' into animato-2
ci / web (push) Failing after 27s
ci / flake (push) Successful in 4m15s
ci / rust (push) Successful in 7m30s
2026-09-12 20:50:52 +01:00
Miguel PalhasandClaude Opus 4.6 35a6ff9d0f fix(maestro): force-replace pane sockets on mobile resume
Mobile browsers report readyState === OPEN after background suspension
even though the OS killed the TCP connection. The per-pane visibility
handlers skipped reconnection in that case, leaving the pane frozen
until TCP keepalive fired (30–120 s on cellular).

Remove the readyState guard and unconditionally replace the socket on
visibility return — same pattern the events socket already uses. Also
let pageshow/online fire recovery without the visibilityState gate,
since some mobile browsers fire pageshow before updating that property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 20:50:48 +01:00
Miguel PalhasandClaude Opus 4.6 474000935a fix(maestro): box AcpUpdate::Notification and fix test unwrapping
ci / web (push) Failing after 33s
ci / flake (push) Successful in 4m42s
ci / rust (pull_request) Failing after 8s
ci / web (pull_request) Failing after 32s
ci / flake (pull_request) Successful in 1m13s
ci / rust (push) Successful in 8m25s
Box the Notification variant to satisfy clippy's large_enum_variant
lint, fix paren mismatches from the boxing pass, and update test
helpers to unwrap AcpUpdate before inspecting the notification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:59:03 +01:00
Miguel PalhasandClaude Opus 4.6 5f8cc2a140 fix(maestro): drain notifications before signaling turn-done
The event pump now handles AcpUpdate::TurnDone markers that arrive
in the same FIFO as notifications.  It fires the done oneshot only
after persisting every preceding notification, so finish_queued_acp_turn
can no longer race ahead of the transcript.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:54:00 +01:00
Miguel PalhasandClaude Opus 4.6 432967fe64 feat(maestro): route turn-done through ACP updates channel
The prompt response callback now sends an AcpUpdate::TurnDone marker
through the same unbounded channel as notifications instead of
signaling the done oneshot directly.  Since notifications and the
prompt response both run in the ACP dispatch loop in wire order,
TurnDone lands after every notification chunk for the turn.

The event pump (next commit) will fire the done oneshot only after
persisting all preceding notifications, closing the race where
finish_queued_acp_turn ran before the transcript was complete.

Closes #259

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:53:18 +01:00
Miguel PalhasandClaude Opus 4.6 2512b8be42 fix(maestro): apply tool-arg truncation fix to styles.css duplicate
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m24s
ci / rust (push) Successful in 7m49s
The previous fix (e3556bd) added `flex: 1 1 0` to `.st-tool-arg` in
`structured.css`, but `styles.css` has a duplicate copy of the same
rules that was left stale. Because both load, the unfixed copy in
`styles.css` can override the fix and re-enable horizontal scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:42:44 +01:00
Miguel PalhasandClaude Opus 4.6 e3556bd847 fix(maestro): truncate long commands in tool call header
ci / web (push) Failing after 30s
ci / flake (push) Successful in 4m5s
ci / rust (push) Successful in 7m30s
Same root cause as the tool-group-title fix — .st-tool-arg
defaulted to flex: 0 1 auto, so long commands pushed the row
past the container. Adding flex: 1 1 0 lets the ellipsis
truncation work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:03:06 +01:00
Miguel Palhas ce0e4e6820 Merge remote-tracking branch 'origin/main' into mandolin
ci / web (push) Failing after 45s
ci / flake (push) Successful in 4m17s
ci / rust (push) Successful in 8m4s
# Conflicts:
#	crates/maestro-daemon/src/local_api.rs
#	crates/maestro-daemon/src/session.rs
2026-09-12 17:54:13 +01:00
Miguel Palhas fa3d6c82b2 Merge remote-tracking branch 'origin/main' into chord
ci / web (push) Failing after 26s
ci / flake (push) Successful in 4m6s
ci / rust (push) Successful in 7m29s
2026-09-12 14:10:59 +01:00
Miguel PalhasandClaude Opus 4.6 ebe25a1a55 feat(maestro): server-side prompt queue for structured sessions
Prompts sent while a turn is active are queued on the daemon instead of
in the browser's React state. The queue is persisted in the session
sidecar and drained as the next turn when the current one completes.

Fixes the bug where queued messages were delivered to the wrong session
when switching focus in the UI: the client-side queue captured the new
sessionId in its drain closure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 14:10:55 +01:00
Miguel PalhasandClaude Opus 4.6 923911f631 feat(maestro): auto-continue ACP sessions after restart
After a graceful daemon restart, recovered structured sessions now
receive a synthetic continue prompt so the agent resumes without
manual intervention. The User row carries `"maestro": {"type":
"restart_continue"}` metadata for future UI differentiation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 11:30:36 +01:00
naps62-yolo 8cbe8510ac fix(maestro): truncate long commands in tool group header (#258)
ci / web (push) Failing after 56s
ci / flake (push) Successful in 5m2s
ci / rust (push) Successful in 9m34s
2026-09-12 11:08:04 +01:00
Miguel Palhas cd5bbcfcb1 Merge remote-tracking branch 'origin/main' into timbre-2
ci / web (push) Failing after 1m7s
ci / flake (push) Successful in 3m55s
ci / rust (push) Has been cancelled
2026-09-12 10:59:49 +01:00
Miguel PalhasandClaude Opus 4.6 cbbf526fc4 fix(maestro): persist auto_approve across daemon restarts
Structured sessions spawned with auto_approve lost the flag on daemon
restart because it lived only in the in-memory SessionRecord. Add it to
the Session struct (sidecar JSON) so recovery reads it back instead of
hardcoding false.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:59:44 +01:00
Miguel Palhas 9f5b71d563 Merge remote-tracking branch 'origin/main' into aeolian
ci / web (push) Failing after 29s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 3m9s
2026-09-12 10:42:10 +01:00
Miguel PalhasandClaude Opus 4.6 de603ccd20 feat(maestro): split tool groups on text boundaries
Tool rows interleaved with agent text now produce separate
collapsed groups instead of one per turn. The timeline reads
text → group → text → group, matching chronological order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:42:06 +01:00
Miguel Palhas c6af78e9dd feat(maestro): concatenate queued structured prompts into one turn
ci / web (push) Failing after 6s
ci / rust (push) Failing after 54s
ci / flake (push) Successful in 2m59s
Closes #257
2026-09-12 10:12:36 +01:00
Miguel PalhasandClaude Opus 4.6 7ab503e4f5 fix(maestro): update e2e test for force-kill worktree removal
ci / web (push) Failing after 6s
ci / rust (push) Failing after 53s
ci / flake (push) Successful in 2m47s
1b2e5f5 changed force kill to remove dirty worktrees but left the
test asserting the old keep-on-disk behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 08:38:51 +01:00
Miguel Palhas eaf11a7696 Merge branch 'maestoso'
ci / web (push) Failing after 27s
ci / rust (push) Failing after 1m1s
ci / flake (push) Successful in 3m35s
2026-09-10 23:15:45 +01:00
Miguel Palhas 4738233580 Merge remote-tracking branch 'origin/main' into lento
ci / web (push) Failing after 27s
ci / rust (push) Failing after 50s
ci / flake (push) Has been cancelled
2026-09-10 21:55:19 +01:00
Miguel PalhasandClaude Opus 4.6 cb25320ea0 feat(maestro): keep agent text visible, collapse only tool calls
Agent text and thought rows stay outside the collapsed tool group as
standalone transcript items instead of being absorbed into it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:55:06 +01:00
Miguel Palhas ff52dd9812 Merge branch 'lyre'
ci / web (push) Successful in 51s
ci / rust (push) Failing after 1m2s
ci / flake (push) Successful in 3m24s
2026-09-10 21:53:34 +01:00
Miguel PalhasandClaude Opus 4.6 f3a5cad789 feat(maestro): widen sidebar activity rail from 5px to 7px
Makes the coloured state bars more visible at a glance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:53:18 +01:00
Miguel Palhas fffd2a55f3 Merge remote-tracking branch 'origin/main' into maestoso 2026-09-10 21:52:26 +01:00
Miguel PalhasandClaude Opus 4.6 563a35d2ec fix(maestro): restore sidebar fill, pane title, and focus dimming after React port
ci / web (push) Failing after 6s
ci / rust (push) Failing after 48s
ci / flake (push) Has been cancelled
The React port replaced CSS class names with Tailwind utilities, breaking
three visual contracts:

- SessionCard used inline bg-* classes instead of .is-active/.is-open/
  .is-previewing that the styles.css cascade targets.
- PaneTitle used className "at-title" instead of "attach-title", so font,
  overflow, and child layout rules never applied.
- MultiView's Pane never applied .is-focused or .is-preview, so every
  pane matched :not(.is-focused) and was dimmed to 0.45 opacity.

Closes #256

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:51:53 +01:00
Miguel PalhasandClaude Opus 4.6 9dead5652e fix(maestro): reconnect attach/structured sockets on Android resume
The visibility recovery only reconnected the catalog and daemon-link
sockets. Per-pane attach and structured WebSockets relied on backoff
timers that Android suspends alongside the sockets, leaving the
terminal disconnected after backgrounding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:50:29 +01:00
Miguel Palhas cd2184b128 Merge remote-tracking branch 'origin/main' into modulation-2
ci / web (push) Failing after 7s
ci / rust (push) Failing after 1m3s
ci / flake (push) Successful in 3m30s
2026-09-10 17:29:49 +01:00
Miguel PalhasandClaude Opus 4.6 1b2e5f5f05 feat(maestro): force kill deletes dirty worktrees
Previously --force left the worktree on disk so the operator could
inspect it. Now it removes the worktree unconditionally — the
confirmation prompt already shows the breakdown, so the decision
happens before the delete, not after.

Natural exit still leaves dirty worktrees on disk (no operator
confirmation in that path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:26:04 +01:00
Miguel PalhasandClaude Opus 4.6 2c8956e634 fix(maestro): reset pending state when kill dialog transitions to force mode
ci / web (push) Successful in 38s
ci / flake (push) Successful in 3m51s
ci / rust (push) Has been cancelled
The onConfirm handler in Sidebar resolves (not rejects) when the daemon
refuses an unforced kill — it mutates state to switch to force mode and
returns. That left `pending` stuck at true in the KillDialog, so the
"Force kill" button rendered disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:24:34 +01:00
Miguel Palhas f2c0882c36 Merge remote-tracking branch 'origin/main' into xylophone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m40s
ci / rust (push) Has been cancelled
# Conflicts:
#	.claude/settings.local.json
2026-09-10 17:17:32 +01:00
Miguel PalhasandClaude Opus 4.6 c170cd9d60 fix(maestro): restore sidebar footer, fix spawn modal and session card regressions
Post-React-migration regressions: spawn submit button moved to modal
footer, sidebar footer restored (help/theme/palette/status/spawn),
session card stripped of redundant Tailwind, action buttons use proper
leaf-act/leaf-actions CSS structure for hover-only visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:17:22 +01:00
Miguel PalhasandClaude Opus 4.6 ebf943bf30 chore(maestro): stop tracking settings.local.json
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:50:31 +01:00
Miguel PalhasandClaude Opus 5 645aa2dea0 fix(maestro): drop border and background on composer stop button
ci / web (push) Failing after 6s
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:48:02 +01:00
Miguel Palhas 3a2513c78d Merge branch 'tuba-2'
ci / web (push) Successful in 34s
ci / flake (push) Successful in 5m32s
ci / rust (push) Successful in 8m1s
2026-09-10 16:39:27 +01:00
Miguel Palhas 7cb52927f2 Merge remote-tracking branch 'origin/main' into tuba-2 2026-09-10 16:38:37 +01:00
Miguel PalhasandClaude Opus 4.6 2724447a51 feat(maestro): show last tool and live narration in collapsed group header
The collapsed tool group now reads "N tool calls · Label 'arg'" so the
operator sees the current command at a glance. While a turn is active,
the most recent agent text stays outside the group as visible narration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:30 +01:00
Miguel PalhasandClaude Opus 4.6 142e9a4870 chore(maestro): update local tool permissions
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:24 +01:00
Miguel PalhasandClaude Opus 4.6 e5c17606d0 fix(maestro): update pnpmDeps hash for tailwindcss 4.3.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:35:37 +01:00
Miguel Palhas 979bfe2fbe chore(maestro): update local tool permissions
ci / web (push) Successful in 47s
ci / flake (push) Failing after 1m5s
ci / rust (push) Successful in 6m44s
2026-09-10 16:25:37 +01:00
Miguel PalhasandClaude Opus 4.6 8f96027878 ci(maestro): cancel in-progress builds on all branches
ci / web (push) Failing after 7s
ci / flake (push) Failing after 1m6s
ci / rust (push) Has been cancelled
Closes #255

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:24:38 +01:00
Miguel Palhas b6f60bfbae Merge remote-tracking branch 'origin/main' into grazioso
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-10 16:19:28 +01:00
Miguel PalhasandClaude Opus 4.6 a8a335eadb fix(maestro): remove cancel button border, center attach button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:19:24 +01:00
Miguel Palhas 048e273ff9 ci: retry after runner cache clear
ci / web (push) Successful in 1m0s
ci / flake (push) Failing after 2m19s
ci / rust (push) Has been cancelled
2026-09-10 16:18:05 +01:00
Miguel PalhasandClaude Opus 4.6 beef2049ed fix(maestro): update remaining id="app" references in web tests
ci / rust (push) Failing after 6s
ci / web (push) Successful in 37s
ci / flake (push) Failing after 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:02:02 +01:00
Miguel PalhasandClaude Opus 4.6 c30592e6b6 fix(maestro): update embedded_bundle test for React mount point
ci / web (push) Failing after 6s
ci / flake (push) Failing after 1m4s
ci / rust (push) Failing after 1m51s
The React migration changed the SPA root from id="app" to id="root".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 17:07:21 +01:00
Miguel PalhasandClaude Opus 4.6 abae81a0ce feat(web): React + Tailwind migration
ci / web (push) Successful in 37s
ci / flake (push) Failing after 1m6s
ci / rust (push) Failing after 1m56s
Replaces the 12.7k-line vanilla TypeScript SPA with React + Zustand +
Tailwind. 16 issues across milestone 48.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 17:01:49 +01:00
Miguel PalhasandClaude Opus 4.6 66a568818a fix(maestro): re-read session before sidecar write in mark_session_dead
ci / web (push) Failing after 6s
ci / flake (push) Failing after 55s
ci / rust (push) Successful in 6m7s
mark_session_dead_with_worktree_settlement cloned the session under the
lock, dropped it, did async work, then wrote the stale clone to the
sidecar. A haiku rename (or any other metadata update) that landed
between the clone and the write was silently overwritten, so the sidecar
reverted to name_source: Generated and the next daemon recovery
re-triggered autoname.

Re-read record.session under the lock at write time so the sidecar
always reflects the latest state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:14:34 +01:00
Miguel Palhas f3d6146884 Closes #254: port design kit specimen page to React 2026-09-09 15:13:48 +01:00
Miguel Palhas 35ae01db93 feat(maestro): port design kit specimen page to React
Replaces the deleted kit.ts + kit.css with DesignKit.tsx, gated behind
import.meta.env.DEV and loaded on ?kit. Shows the full tailwind.config.ts
token mapping (colours across all 6 palettes, typography, spacing,
radii, z-index) plus buttons, ActivityRail states, SessionCard states,
and terminal chrome, all switchable live through ThemeContext.

Closes #254
2026-09-09 15:11:48 +01:00
Miguel Palhas 8a05ef2eb7 feat(maestro): wire React app as sole web entry point
Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the
23 views it drove are gone; App.tsx composes the ported components —
sidebar, multi-view, command bar, spawn modal, help and restart
overlays, daemon link, drawer — around the catalog and layout stores.

The pure parts of four deleted modules moved next to the components
that still use them: the spawn-form model and spawnSimilar into
components/spawn, the transcript's markdown/format helpers and the diff
into components/structured, the help overlay's grouping into
components/dialogs. Document-level event names now live in uiEvents.ts.

Deduplicated two pairs the migration left side by side: the sidebar
uses dialogs/KillDialog and common/ContextMenu. It also listens for the
command bar's kill/rename events, which nothing had picked up since
catalog.ts went away.

styles.css stays as the token and base layer, with `#app` renamed to
`#root` and the pane-body rules re-keyed off the classes the React
panes actually carry.

Closes #253
2026-09-09 02:27:42 +01:00
Miguel Palhas ad71147bd9 Closes #252: port all tests to React Testing Library 2026-09-09 02:07:52 +01:00
Miguel Palhas e9c3882cea Closes #242: port daemon link and restart overlay to React 2026-09-09 02:07:49 +01:00
Miguel PalhasandClaude Sonnet 5 c9562ab5eb Closes #252: port vanilla-DOM tests to React Testing Library
Rewrites the 27 DOM-behavior test files (dialogs, sidebar, spawn form,
command bar, structured pane, catalog, multiview, theme, etc.) to use
screen/within/fireEvent/waitFor instead of manual querySelector and
dispatchEvent. Pure-logic test files (matcher, tree, activity, paths,
reconnect, keys, webgl, paneTitle, commands, daemonLink, events, layout,
persistence, previewSchedule, projectList, spawnSimilar, structuredDiff,
toolIcon, fastForward, spawnForm) are untouched, and preview.test.ts
needed no changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 02:06:31 +01:00
Miguel Palhas bc87ddee06 feat(web): port daemonLink and restartOverlay to React (#242)
DaemonLink.tsx holds the link state machine as a Zustand store (same
shape as catalogStore/connectionStore) plus a thin controller component
wiring the catalog store's `connected` field to it. RestartOverlay.tsx
renders the blocking modal for down/updating, reusing the existing CSS.

Closes #242
2026-09-09 01:59:49 +01:00
Miguel Palhas 554c9ebb31 Closes #240: add useHashRouter hook 2026-09-09 01:51:02 +01:00
Miguel PalhasandClaude Sonnet 5 af5ad1a227 feat(web): add useHashRouter hook for React shell (#240)
Ports main.ts's parseRoute/dispatch hash parsing into a React hook:
same #/ and #/attach/:id shape, current route plus a navigate function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 01:43:46 +01:00
Miguel Palhas 8304ef9a49 Closes #251: port preview mode to React 2026-09-09 01:39:37 +01:00
Miguel Palhas cdba6d07e3 Closes #250: port dialogs, context menu, help overlay, mobile drawer 2026-09-09 01:39:37 +01:00
Miguel Palhas ea8c42bfb2 feat(maestro): port preview mode to React (#251)
Extends layoutStore with previewSession/commitPreview, mirroring
multiview.ts's preview controller: a throwaway tab for a session no pane
holds, byte-for-byte restore on end, and every real layout-changing action
drops an active preview first. Never schedules a save while a preview is up.

Adds usePreview, wrapping the store actions with previewSchedule.ts's
120ms leading-edge throttle, plus a touch guard and an immediate `end()`
for real changes. MultiView/TabBar render the preview hue on the pane
titlebar, canvas ring/chip, and the throwaway tab (dashed, unclickable).
2026-09-09 01:39:05 +01:00
Miguel Palhas e0edbc09d2 feat(maestro): port kill dialog, help overlay, context menu, drawer to React
Closes #250: kill confirmation/force-kill (dialogs/KillDialog), the ?
shortcuts overlay (dialogs/HelpOverlay), the general-purpose right-click
menu (common/ContextMenu, closes on scroll/resize unlike the sidebar's
own), the mobile drawer with swipe support (layout/Drawer), and the
visual-viewport hook (hooks/useViewport). Each is a standalone, tested
port of its vanilla-DOM source; wiring into the shell is a later issue.
2026-09-09 01:33:56 +01:00
Miguel Palhas e9e98e0c49 Closes #249: port layout system to React 2026-09-09 01:23:04 +01:00
Miguel Palhas 02f1844650 Closes #247: port command bar (ctrl-k) to React 2026-09-09 01:23:01 +01:00
Miguel Palhas 1b7ec1f54c feat(maestro): port layout system (tabs, splits, multiview) to React
Closes #249: layoutStore.ts wraps the existing layout.ts tree model
in Zustand, persisting to localStorage on a 400ms debounce and adding
boot reconciliation to prune leaves for sessions no longer live.
MultiView/TabBar/PaneContainer/PaneTitle render tabs, nested splits
with draggable dividers, drag-and-drop tab reordering, and dispatch
each pane to TerminalPane or StructuredPane by session view.

Preview mode (#251) and sidebar drag-to-dock stay out of scope here.
2026-09-09 01:21:37 +01:00
Miguel Palhas ee61cea64f feat(maestro): port the command bar to React (#247)
CommandBar, ResultList, ActionPage and useCommandBar over the same
model docs/COMMAND-BAR.md specifies: sigils, empty-query session order,
the drill-in action page, and the keys.

The highlight now walks the rows in the order they render. The legacy
bar indexed the highlight by display order but activated by rank order,
so Enter could act on a different row than the one lit.
2026-09-09 01:14:28 +01:00
Miguel Palhas 7e21371e47 refactor(maestro): lift the matcher into a shared utils module
The match-and-rank algorithm is pure and both the legacy bar and the
React port rank with it (#247).
2026-09-09 01:14:28 +01:00
Miguel Palhas 52ae636dbb Closes #248: port spawn dialog and fast-forward to React 2026-09-09 01:05:53 +01:00
Miguel Palhas 9d93e6e60e Closes #246: port structured pane (ACP transcript) to React 2026-09-09 01:05:50 +01:00
Miguel Palhas babdc678e0 feat(maestro): port spawn dialog to React (#248)
Ports the vanilla spawn form/modal to src/components/spawn/:
SpawnModal, SpawnForm, DirAutocomplete, BranchPicker, and a
useFastForward hook for the one-keystroke spawn. Pure logic modules
(spawnForm.ts's buildSpawnRequest, fastForward.ts, spawnSimilar.ts,
projectList.ts, toolIcon.ts) are reused as-is rather than re-derived.

Exports supportsStructuredView from spawnForm.ts so the React form can
share the structured-view-eligibility check with the vanilla one.
2026-09-09 01:04:05 +01:00
Miguel PalhasandClaude Opus 5 9ba1078386 feat(maestro): port the structured pane to React
Closes #246. The transcript, composer and approval controls move from
structured.ts/structuredRender.ts into components under
web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row
kinds, both projections, the single-active approval nonce, and diff rendering
with its own line counts.

The pure layers stay shared — structuredDiff.ts computes the diff,
structuredRender.ts still sanitizes markdown — and model.ts holds the
client-side decisions (activity grouping, notice classification, offered
decisions, `/` completion) so they are testable without a DOM.

Like TerminalPane, the pane draws the body only; pane chrome reads the
connection through onStatusChange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:41:18 +01:00
Miguel PalhasandClaude Opus 4.6 f0a1774e4d fix(maestro): integration review fixes for wave 3
- Add #root div to index.html so React entry point can mount
- Replace unsafe type cast in Sidebar with typed CatalogStore selector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 00:29:05 +01:00
Miguel Palhas ac720eb266 Closes #245: port terminal pane to React 2026-09-09 00:26:16 +01:00
Miguel Palhas 0ba83c3d8f Closes #245: port terminal pane (xterm attach) to React
TerminalPane is a thin ref-based wrapper: xterm lifecycle lives in a
single mount effect keyed on sessionId, tearing the socket and terminal
down on cleanup. Preserves webglRenderer.ts's page-wide WebGL context
budget (acquire on mount, release on unmount, re-acquire on context
loss while still mounted) and reconnect.ts's exponential 500ms->10s
backoff. keys.ts's mobile modifier encoding moves into KeyBar.tsx.

Pane chrome (title, disconnect, sessions drawer) stays out of scope;
composition happens one level up.
2026-09-09 00:24:08 +01:00
Miguel Palhas e8e98baf4d feat(maestro): port the sidebar to React
Ports catalog.ts's tree renderer to components under
src/components/sidebar/, following docs/SIDEBAR.md: two-line cards
behind a 5px activity rail, adaptive meta line (duration, detail,
program, diff stat), collapsible folders, j/k walk, inline rename and a
right-click menu.

The nav walk reads a pure model (model.ts) rather than the DOM, so the
rule that an expanded folder header is skipped and a collapsed one is
not is testable without a render. Rail motion needs @keyframes, so it
stays in sidebar.css rather than the Tailwind config.

Closes #244
2026-09-09 00:21:13 +01:00
Miguel Palhas b286323c9c Closes #243: port theme system and design tokens to Tailwind config 2026-09-09 00:14:11 +01:00
Miguel Palhas 71ec7e073c Closes #241: port CatalogStore and ConnectionPool to Zustand stores 2026-09-09 00:14:08 +01:00
Miguel Palhas 46bae13212 feat(maestro): map design tokens to Tailwind config, port theme to React
Wires tailwind.config.ts to every custom-property token in styles.css
(spacing, OKLCH colours, radii, z-index, sizing, durations) so Tailwind
utilities resolve to the same variables the vanilla CSS uses. Adds
ThemeContext/useTheme as a React port of theme.ts, keeping the same
localStorage keys and data-theme/data-palette attributes so it can run
alongside the vanilla shell.
2026-09-09 00:12:59 +01:00
Miguel PalhasandClaude Sonnet 5 20d303278d feat(web): port CatalogStore and ConnectionPool to Zustand stores
Ports events.ts's CatalogStore and connections.ts's ConnectionPool to
Zustand stores (src/stores/catalogStore.ts, connectionStore.ts) built
via factory functions so tests can inject fake sockets/schedulers, same
as the originals. Reconnect/backoff schedule, the 6-max-parked LRU, and
the #140 view-reconciliation race handling all carry over unchanged.

The vanilla events.ts/connections.ts stay in place for the current app;
this is prep for the React port (#241).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 00:12:09 +01:00
Miguel Palhas 589e0144e2 Closes #239: scaffold React + Tailwind + Zustand 2026-09-09 00:03:04 +01:00
Miguel Palhas 0061378193 feat(maestro): scaffold React + Tailwind + Zustand
Adds react, react-dom, zustand, tailwindcss, @tailwindcss/vite as a new
skeleton alongside the existing vanilla SPA. New src/index.tsx entry
point (unused until wired into index.html in a later issue); main.ts
stays the active entry.
2026-09-09 00:01:33 +01:00
Miguel Palhas 1cf4825b1d fix(web): collapse agent commentary
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m31s
ci / rust (push) Successful in 6m18s
2026-09-08 17:21:08 +01:00
Miguel PalhasandClaude Opus 4.6 f799aaf564 fix(maestro): restore focus after surgical tool-row patch
ci / web (push) Successful in 41s
ci / flake (push) Successful in 4m36s
ci / rust (push) Successful in 8m42s
The patch path did a DOM replaceWith and returned early, skipping the
focus-restoration logic in rebuildTranscript. Now saves and restores
tool-head focus within the surgical swap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:37:58 +01:00
naps62-yolo 5af5ceb9a8 feat(maestro): trigger haiku naming for ACP sessions (#206)
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-08 15:35:27 +01:00
Miguel PalhasandClaude Opus 4.6 fbdcec6f69 chore(maestro): allow cargo test in local settings
ci / flake (push) Successful in 3m39s
ci / rust (push) Successful in 6m32s
ci / web (push) Failing after 27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:05:41 +01:00
Miguel PalhasandClaude Opus 4.6 6af4cda650 fix(maestro): gitroot tests tolerate /tmp inside a repo
The sandbox has /tmp/.git, so the ancestor walk finds a git root
above the tempdir. Weakened the assertion: the result must not point
inside the .git-free tempdir, but may point above it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:50:50 +01:00
Miguel Palhas ab4beea2d9 Merge branch 'trombone-2'
ci / web (push) Failing after 27s
ci / rust (push) Failing after 1m1s
ci / flake (push) Successful in 3m18s
2026-09-08 14:36:21 +01:00
Miguel Palhas 44ef5619bf Merge branch 'staccato-2'
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-08 14:36:20 +01:00
Miguel Palhas 6adb5dabf0 Merge branch 'harpsichord-2'
ci / web (push) Failing after 31s
ci / rust (push) Failing after 56s
ci / flake (push) Successful in 4m3s
2026-09-08 14:33:49 +01:00
Miguel Palhas e9c9e1c647 Merge remote-tracking branch 'origin/main' into staccato-2 2026-09-08 14:33:26 +01:00
Miguel PalhasandClaude Opus 4.6 4167b21fe9 feat(maestro): render queued messages as blurred chat bubbles
Queued prompts now appear inline in the transcript as faded user bubbles
instead of a pill badge above the composer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:33:22 +01:00
Miguel PalhasandClaude Opus 4.6 b9f8f02e11 fix(maestro): fix diff double-spacing and add +/- markers
Block spans + literal \n text nodes inside <pre> caused empty lines
between every diff line. Drop the text nodes, add unified-diff prefixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:32:51 +01:00
Miguel PalhasandClaude Opus 4.6 f36b7808a7 feat(maestro): show image previews in user message bubbles
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Uploaded images now render as thumbnails in the user bubble instead of
showing only the server file path as text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:32:42 +01:00
Miguel PalhasandClaude Opus 4.6 c2d427691d fix(web): stop tool-group flashing and config-menu closure on updates
Reuse existing tool-group DOM elements in rebuildTranscript instead of
tearing them down — preserves CSS transition state and focus. Defer
config-menu DOM rebuild while the menu is open so <select> dropdowns
survive background state updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:26:14 +01:00
Miguel Palhas 50b862b1fa Merge branch 'vivace'
ci / web (push) Failing after 29s
ci / rust (push) Failing after 50s
ci / flake (push) Successful in 3m30s
2026-09-08 13:47:19 +01:00
Miguel Palhas 4668cf8ece Merge remote-tracking branch 'origin/main' into recorder-4
ci / rust (push) Failing after 42s
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m39s
2026-09-08 13:46:03 +01:00
Miguel PalhasandClaude Opus 4.6 638155de03 feat(maestro): move activity indicator into scroll area
The "Running 1m ..." status now sits at the tail of the chat
history instead of pinned above the input, matching Claude Code's
placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:56 +01:00
Miguel Palhas 1c0fff5507 Merge remote-tracking branch 'origin/main' into vivace 2026-09-08 13:45:48 +01:00
Miguel PalhasandClaude Opus 4.6 10c9db6d87 feat(web): show last agent text outside collapsed group during active turn
Intermediate narration was hidden inside the collapsed tool group
while the agent was still working. The last AgentText/AgentThought
now stays visible as current status, absorbed only when the turn ends
or newer text replaces it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:45 +01:00
Miguel PalhasandClaude Opus 4.6 b339fecad3 feat(maestro): route maestro send through ACP for structured sessions
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Structured sessions were rejected by `send_input` with NotTerminal.
Now `run_send` checks the session view and routes to
`send_structured_prompt` for structured sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:43:01 +01:00
Miguel PalhasandClaude Opus 4.6 3c10a3a08a fix(maestro): skip Starting sessions in liveness watcher
ci / web (push) Failing after 21s
ci / flake (push) Successful in 3m30s
ci / rust (push) Successful in 6m8s
A structured session still booting its adapter has no ACP handle and
no tmux pane, so the liveness pass misclassified it as a vanished
terminal session and reaped it. Claude Code adapters take long enough
to boot that the 2s liveness tick fires before the handshake finishes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 07:25:48 +01:00
Miguel Palhas fd79c8116a Merge remote-tracking branch 'origin/main' into tuba
ci / web (push) Failing after 7s
ci / flake (push) Successful in 3m15s
ci / rust (push) Successful in 5m54s
2026-09-07 21:49:31 +01:00
Miguel PalhasandClaude Opus 4.6 cb8b93f178 test(maestro): update tests for async structured spawn
Tests now assert `Starting` after create and use `wait_for_ready`
before checking post-handshake state. Unsupported command test
expects `BadRequest` instead of a persisted Dead session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:28:22 +01:00
Miguel PalhasandClaude Opus 4.6 60e57fde31 feat(web): collapse resolved approval rows
ci / web (push) Failing after 25s
ci / flake (push) Successful in 3m36s
ci / rust (push) Successful in 6m12s
Resolved permission boxes vanish from the transcript instead of
lingering as "answered" cards. When a turn has 2+ tools the resolved
approval is absorbed into the tool group as a compact italic line;
otherwise it is hidden entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:20:31 +01:00
Miguel PalhasandClaude Opus 4.6 8d35f7f8c8 feat(maestro): async structured session spawn
Register a structured session immediately with `activity: Starting`
and run the ACP handshake in a background task. The HTTP caller gets
an instant response; the user can begin typing a prompt while the
adapter boots.

- Add `ActivityState::Starting` to the protocol
- Open the event log eagerly so the structured WS can connect during
  boot (empty transcript + idle state)
- `queue_acp_prompt` stashes a prompt while `Starting`; the background
  task drains it on successful handshake
- `apply_structured_activity` skips overwriting `Starting`
- `subscribe_acp_projection` allows connecting during `Starting`
- TUI/CLI map `Starting` to idle colour / "starting" label

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:08:08 +01:00
Miguel Palhas bc0fd6ee60 Merge branch 'etude'
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m32s
ci / rust (push) Successful in 6m3s
2026-09-07 20:56:24 +01:00
Miguel PalhasandClaude Opus 4.6 ed5a088299 feat(web): equal-width project cards, drop worktree counters
Project boxes now use a fixed two-column grid so every card is the
same width. The worktree count line is removed — the information
lives in the worktree picker that follows a project selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:48:43 +01:00
Miguel Palhas 9c77965c51 Merge remote-tracking branch 'origin/main' into arpeggio
ci / web (push) Failing after 29s
ci / rust (push) Successful in 6m48s
ci / flake (push) Successful in 4m8s
2026-09-07 20:33:38 +01:00
Miguel PalhasandClaude Opus 4.6 49df4c2e9e fix(web): surgical DOM patch for tool rows to stop flashing
Patch deltas on existing rows now swap just the affected element via
replaceWith() instead of tearing down and rebuilding the entire
transcript. Stops the visible flash and preserves text selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:33:34 +01:00
Miguel PalhasandClaude Opus 4.6 65d7186e1c feat(web): harness cards with tool logos lead the spawn dialog
ci / rust (push) Successful in 8m28s
ci / web (push) Successful in 25s
ci / flake (push) Successful in 3m27s
Move the harness/command choice above the project picker so the
operator decides what runs before where. Replace the flat command
tiles with larger centered cards that render each tool's icon from
toolIcon.ts at 24x24. The Custom escape hatch gets a three-dot
ellipsis icon and a dashed border to read as secondary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:29:35 +01:00
Miguel PalhasandClaude Opus 4.6 47654aa434 feat(web): gate sidebar hover preview on ctrl
ci / rust (push) Failing after 5s
ci / web (push) Successful in 26s
ci / flake (push) Successful in 3m0s
Plain hover no longer previews — ctrl+hover does.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 15:22:58 +01:00
Miguel PalhasandClaude Opus 4.6 6ca2ab1707 merge: resolve conflict with main's prompt queue
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m38s
ci / rust (push) Successful in 6m18s
Keep icon cancel button, always-"Send" label, and queue status span
from main. Drop unused `active` local after label simplification.
Update test expectation to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:35:47 +01:00
Miguel PalhasandClaude Opus 4.6 b0b0160ccb feat(web): align composer and activity to content width, icon cancel button
Move max-width constraint from .structured-rows up to .structured-main
so the activity banner and composer match the transcript width. Replace
text cancel button with a stop-sign icon inside a spinning ring. Drop
the accent border-top on composer focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:32:56 +01:00
naps62 c4cd8a691e Merge pull request 'fix(web): queue structured prompts' (#204) from quaver into main
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m46s
ci / rust (push) Successful in 6m33s
Reviewed-on: #204
2026-09-07 12:28:49 +01:00
Miguel Palhas 0381a571f2 Merge remote-tracking branch 'origin/main' into brillante
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m23s
ci / rust (push) Successful in 7m8s
2026-09-07 11:42:28 +01:00
Miguel PalhasandClaude Opus 4.6 8f3d47c49e feat(web): absorb intermediate agent text into tool groups
Intermediate agent text/thought rows between tool calls in the same
turn now render inside the collapsed tool group instead of after it,
preserving chronological order. The last agent text (no subsequent
tool) stays outside as the prominent final answer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:42:25 +01:00
Miguel Palhas ba0c54759b fix(tui): complete test presets
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 47s
ci / rust (pull_request) Successful in 7m3s
2026-09-07 11:39:51 +01:00
Miguel PalhasandClaude Opus 4.6 78912e7925 fix(tui): add missing auto_approve field in draft test
ci / web (push) Successful in 27s
ci / flake (push) Successful in 3m30s
ci / rust (push) Successful in 6m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:32:32 +01:00
Miguel PalhasandClaude Opus 4.6 fef9be6c01 feat(web): cap structured view width for readability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:30:52 +01:00
Miguel Palhas 5387b29049 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 30s
ci / flake (pull_request) Successful in 49s
ci / rust (pull_request) Failing after 52s
2026-09-07 11:25:33 +01:00
Miguel PalhasandClaude Opus 4.6 88afd0df3f feat(maestro): auto-approve ACP permissions via preset config
ci / web (push) Failing after 6s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 2m41s
Structured sessions ignored the --dangerously-skip-permissions flags in
preset commands because the daemon discards CLI flags when spawning the
ACP adapter binary. This adds an `auto_approve` bool to SpawnPreset and
CreateSessionRequest so the operator can declare it in config.toml:

  { id = "claude", command = "claude", auto_approve = true }

When set, the daemon's permission pump immediately responds AllowAlways
to every ACP permission request without surfacing it to clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:19:24 +01:00
Miguel Palhas df51aa7be9 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 46s
ci / rust (pull_request) Successful in 5m16s
# Conflicts:
#	web/src/structured.ts
2026-09-07 11:06:13 +01:00
naps62-yolo ac8a1b319d feat(web): redesign spawn dialog advanced area (#205)
ci / web (push) Successful in 40s
ci / flake (push) Successful in 3m51s
ci / rust (push) Successful in 6m26s
2026-09-07 10:50:44 +01:00
Miguel PalhasandClaude Opus 4.6 17a57269f7 fix(maestro): satisfy field_reassign_with_default lint
ci / rust (push) Successful in 9m13s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 3m56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 10:47:07 +01:00
Miguel Palhas 43bfda5a60 fix(web): queue structured prompts
ci / web (pull_request) Successful in 47s
ci / flake (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 9m35s
2026-09-07 10:42:49 +01:00
Miguel Palhas 6ffbca3596 Merge remote-tracking branch 'origin/main' into recorder-2 2026-09-07 10:41:59 +01:00
Miguel PalhasandClaude Opus 4.6 2cde58b42f fix(web): compact tool-call groups in structured view
Drop the per-tool name list from group headers (redundant with the rows
below), remove individual borders inside groups, and tighten padding so
20-tool blocks scan as a dense list instead of a wall of cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 10:41:55 +01:00
Miguel Palhas 721c34aa11 Merge remote-tracking branch 'origin/main' into tritone
ci / web (push) Successful in 28s
ci / rust (push) Failing after 1m4s
ci / flake (push) Successful in 3m4s
2026-09-07 10:40:10 +01:00
Miguel PalhasandClaude Opus 5 a7d8b5e22f fix(maestro): keep the draft first when the composer wraps
ci / web (push) Successful in 28s
ci / flake (push) Successful in 3m24s
ci / rust (push) Successful in 6m8s
The attach button precedes the draft in the DOM, so under the ≤520px wrap
rule it took a line of its own above the textarea. An explicit order puts
the draft back on the first line and groups every control below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:27:46 +01:00
Miguel Palhas 2f695e1354 Merge remote-tracking branch 'origin/main' into balalaika 2026-09-07 10:25:40 +01:00
Miguel Palhas 1e30b9ade1 Merge remote-tracking branch 'origin/main' into timbre
ci / rust (push) Successful in 8m43s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 4m21s
2026-09-07 10:24:22 +01:00
Miguel PalhasandClaude Opus 5 b4d4b0712c fix(maestro): keep the composer's one-row layout
Reverts the stacked control strip from f78aa13. The floating card is still
gone — flush band, one hairline against the transcript that doubles as the
focus indicator — but the composer keeps its own `--surface` fill so it
still reads as distinct from the pane, and the draft, gear and Send stay on
one bottom-aligned row.

At phone width that row cannot hold a draft plus three controls, so it
wraps: the draft takes a full line and the controls drop below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:23:15 +01:00
Miguel Palhas 4df8fdb7dc feat(maestro): attach files in structured views 2026-09-07 10:20:08 +01:00
Miguel Palhas b949cffb9e feat(tui): refine structured transcript 2026-09-07 10:18:19 +01:00
Miguel PalhasandClaude Opus 5 25653abcf5 feat(maestro): default structured view on for ACP agents
ci / rust (push) Successful in 9m32s
ci / web (push) Successful in 31s
ci / flake (push) Successful in 4m6s
A Claude Code or Codex spawn now asks for the structured view unless the
operator says otherwise: the two spawn forms open with the box ticked and
`maestro spawn` takes `--terminal` to opt out. Once the operator works the
toggle, a command change no longer re-ticks it under them.

The support predicate moves to `maestro_protocol::display` so all three
clients gate on the same resolution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:18:07 +01:00
Miguel PalhasandClaude Opus 5 f78aa13b36 fix(maestro): flatten structured composer
The composer was a floating bordered card inset from the pane edge, and
its send button was bottom-aligned beside the textarea, so it sank as the
draft grew.

It is now a flush band at the foot of the pane: one hairline rule against
the transcript, which doubles as the focus indicator. The textarea spans
the full width and the controls sit in a fixed strip below it, so the send
button keeps its place however tall the draft grows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:16:56 +01:00
Miguel Palhas 0dc10ffe76 fix(web): repair nix frontend build
ci / web (push) Successful in 27s
ci / flake (push) Successful in 6m50s
ci / rust (push) Successful in 7m15s
Two things broke `nix build .#webFrontend`, and with it `nh home apply`
on any host that installs maestro.

`marked` was added to web/package.json without repinning pnpmDeps.hash,
so pnpm install failed offline with ERR_PNPM_NO_OFFLINE_TARBALL.

`tsc -b` also type-checks web/src/toolIcon.test.ts, which imports the
shared ../../fixtures/command-resolution.json. The derivation's src is
./web, so that path does not exist in the sandbox. The production build
now uses tsconfig.build.json, which excludes tests; `pnpm typecheck`
still covers them from a full checkout.
2026-09-07 09:54:51 +01:00
236 changed files with 19254 additions and 17643 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ on:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
+3
View File
@@ -15,3 +15,6 @@ worktrees/
# `nix run .#dev` scratch state (tmux socket)
/.dev/
# Per-machine Claude Code tool permissions
.claude/settings.local.json
+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
+80
View File
@@ -0,0 +1,80 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "pt.naps.maestro"
compileSdk = 34
defaultConfig {
applicationId = "pt.naps.maestro"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
getByName("debug") {
storeFile = file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
debug {
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@@ -0,0 +1,19 @@
package pt.naps.maestro
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun placeholderScreen_showsAppName() {
composeTestRule.onNodeWithText("Maestro").assertExists()
}
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.Maestro">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Maestro">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,61 @@
package pt.naps.maestro
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import pt.naps.maestro.ui.theme.MaestroTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaestroTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@Composable
fun PlaceholderScreen(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Maestro",
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(id = R.string.placeholder_message),
)
}
}
@Preview(showBackground = true)
@Composable
private fun PlaceholderScreenPreview() {
MaestroTheme {
PlaceholderScreen()
}
}
@@ -0,0 +1,11 @@
package pt.naps.maestro.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,46 @@
package pt.naps.maestro.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
)
@Composable
fun MaestroTheme(
darkTheme: Boolean = true,
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val useDarkTheme = darkTheme || isSystemInDarkTheme()
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
useDarkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,17 @@
package pt.naps.maestro.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
)
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1B1B1B"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Maestro</string>
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,11 @@
package pt.naps.maestro
import org.junit.Assert.assertEquals
import org.junit.Test
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
+6
View File
@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.useAndroidX=true
kotlin.code.style=official
+35
View File
@@ -0,0 +1,35 @@
[versions]
agp = "8.5.2"
kotlin = "2.0.21"
coreKtx = "1.13.1"
lifecycleRuntimeKtx = "2.8.6"
activityCompose = "1.9.2"
composeBom = "2024.09.03"
navigationCompose = "2.8.2"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.7.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
junit = { group = "junit", name = "junit", version = "4.13.2" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+237
View File
@@ -0,0 +1,237 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# issues, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated via the Gradle "wrapper" task
# (see https://docs.gradle.org/current/userguide/gradle_wrapper.html)
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments that
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-A-Za-z0-9_+.]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" -Dorg.gradle.appname=%APP_BASE_NAME% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+19
View File
@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Maestro"
include(":app")
+41 -16
View File
@@ -58,6 +58,24 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema;
/// Item on the updates channel. Notifications and turn-done markers share
/// the same FIFO so the event pump processes every notification for a turn
/// before seeing its done signal.
#[derive(Debug)]
pub enum AcpUpdate {
/// A raw `session/update` notification from the adapter.
Notification(Box<SessionNotification>),
/// The prompt response for `turn` arrived. All notifications for this
/// turn precede this marker on the wire, and therefore in this channel.
/// The event pump must fire `done` after persisting every preceding
/// notification.
TurnDone {
turn: u64,
result: Result<(), String>,
done: oneshot::Sender<Result<(), String>>,
},
}
/// ACP adapter selected from the structured session's resolved command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
@@ -354,7 +372,7 @@ pub struct AcpSession {
pid: u32,
commands: mpsc::UnboundedSender<Command>,
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
exit: watch::Receiver<Option<ExitStatus>>,
}
@@ -393,9 +411,9 @@ impl AcpSession {
wait_exit(&self.exit).await
}
/// The stream of raw `session/update` notifications, handed out once.
/// Notifications that arrived before the take are still queued on it.
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
/// The stream of updates (notifications + turn-done markers), handed out
/// once. Items that arrived before the take are still queued.
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<AcpUpdate>> {
self.updates.lock().expect("updates mutex").take()
}
@@ -772,7 +790,7 @@ fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> S
async fn run_connection<T>(
transport: T,
config: ConnectionConfig,
updates_tx: mpsc::UnboundedSender<SessionNotification>,
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
@@ -800,7 +818,7 @@ async fn run_connection<T>(
return Ok(());
}
// A dropped receiver only means nobody wants updates any more.
let _ = received_updates.send(notification);
let _ = received_updates.send(AcpUpdate::Notification(Box::new(notification)));
Ok(())
},
agent_client_protocol::on_receive_notification!(),
@@ -916,18 +934,18 @@ async fn run_connection<T>(
models.as_ref(),
)));
if let Some(modes) = &modes {
let _ = updates_tx.send(SessionNotification::new(
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
SessionId::new(session_id.as_str()),
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
modes.current_mode_id.clone(),
)),
));
))));
}
if !config_options.lock().expect("config options").is_empty() {
let _ = updates_tx.send(config_notification(
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(config_notification(
&session_id,
config_options.lock().expect("config options").clone(),
));
))));
}
let _ = ready_tx.send(Ok(session_id.clone()));
@@ -955,16 +973,23 @@ async fn run_connection<T>(
let done = Arc::new(Mutex::new(Some(done)));
let callback_done = done.clone();
let callback_turn = permission_turn.clone();
let callback_updates = updates_tx.clone();
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
let callback_done = callback_done.clone();
let callback_turn = callback_turn.clone();
let callback_updates = callback_updates.clone();
async move {
callback_turn
.lock()
.expect("permission turn state")
.finish(turn);
// Route through the same channel as notifications so the
// event pump sees this after every chunk for the turn.
let result = result.map(|_| ()).map_err(|e| e.to_string());
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string()));
if callback_updates.send(AcpUpdate::TurnDone { turn, result, done }).is_err() {
// Channel closed — nowhere left to signal.
}
}
Ok(())
}
@@ -998,7 +1023,7 @@ async fn run_connection<T>(
}).on_receiving_result(move |result| async move {
if result.is_ok() {
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
let _ = callback_updates.send(config_notification(&callback_session, options));
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
@@ -1017,13 +1042,13 @@ async fn run_connection<T>(
)).on_receiving_result(move |result| async move {
if result.is_ok() {
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
let _ = callback_updates.send(config_notification(&callback_session, options));
let _ = callback_updates.send(SessionNotification::new(
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
SessionId::new(callback_session.as_str()),
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
callback_value.as_value_id().expect("checked mode value").0.clone(),
)),
));
))));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
@@ -1041,7 +1066,7 @@ async fn run_connection<T>(
let mut current = callback_options.lock().expect("config options");
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
current.extend(response.config_options.clone());
let _ = callback_updates.send(config_notification(&callback_session, current.clone()));
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, current.clone()))));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
+11 -2
View File
@@ -193,6 +193,9 @@ async fn session_updates_reach_the_taken_receiver() {
.await
.expect("update within 5s")
.expect("channel open");
let maestro_acp::AcpUpdate::Notification(first) = first else {
panic!("expected Notification");
};
assert_eq!(first.session_id.0.as_ref(), session.session_id());
match first.update {
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
@@ -221,7 +224,10 @@ async fn advertised_settings_are_normalized_and_mutable() {
.await
.expect("settings update")
.expect("channel open");
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
let maestro_acp::AcpUpdate::Notification(n) = update else {
continue;
};
if let SessionUpdate::ConfigOptionUpdate(options) = n.update {
break options.config_options;
}
};
@@ -390,7 +396,10 @@ async fn drop_terminates_and_reaps_the_child() {
fake.wait_for_exit().await;
}
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
fn chunk_text(update: &maestro_acp::AcpUpdate) -> String {
let maestro_acp::AcpUpdate::Notification(n) = update else {
panic!("expected Notification, got {update:?}");
};
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => t.text.clone(),
+11 -3
View File
@@ -41,8 +41,8 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
/// Send text to a running session. Terminal sessions receive raw
/// keystrokes; structured sessions receive an ACP prompt.
Send(SendArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
/// there.
#[arg(long)]
pub existing_branch: bool,
/// Run a Claude Code or Codex session in a plain terminal instead of the
/// structured (ACP) view it otherwise gets by default.
#[arg(long, conflicts_with = "structured")]
pub terminal: bool,
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
/// Codex commands have an adapter; the daemon refuses anything else.
#[arg(long)]
pub structured: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
pub attach: bool,
@@ -125,7 +133,7 @@ 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 left on disk — never removed.
/// worktree is removed along with the session.
#[arg(long)]
pub force: bool,
}
+105 -17
View File
@@ -3,7 +3,7 @@
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, SessionView, WorktreeSpec,
@@ -51,16 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
};
let body = CreateSessionRequest {
view: Default::default(),
view: spawn_view(&args),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
metadata,
worktree,
auto_approve: false,
};
client.create_session(&body).await
}
/// The view a `maestro spawn` asks for: structured whenever the command
/// resolves to an agent with an ACP adapter, since that is the richer session
/// and the one both other clients now default to. `--terminal` opts out;
/// `--structured` forces the request through even for a command this side
/// does not recognise, leaving the refusal to the daemon.
fn spawn_view(args: &SpawnArgs) -> SessionView {
if args.terminal {
return SessionView::Terminal;
}
if args.structured || supports_structured_view(&args.command) {
SessionView::Structured
} else {
SessionView::Terminal
}
}
/// `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.
@@ -113,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
/// `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.
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
/// there — ACP prompts are always complete messages).
///
/// 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,
@@ -138,19 +158,28 @@ pub async fn run_send(
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)?
}
};
// We always need the session list: even a bare UUID needs the view field
// to decide which endpoint to hit.
let sessions = client.list_sessions().await?;
let id = resolve_session(&sessions, &args.session)?;
let view = sessions
.iter()
.find(|s| s.id == id)
.map(|s| s.view)
.unwrap_or(SessionView::Terminal);
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
match view {
SessionView::Structured => {
client.send_structured_prompt(id, &text).await?;
}
SessionView::Terminal => {
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
}
}
Ok(id)
}
@@ -199,6 +228,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error",
ActivityState::Starting => "starting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
@@ -239,6 +269,62 @@ mod tests {
use super::DaemonInfo;
use clap::Parser;
use crate::cli::{Cli, Command};
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
argv.extend_from_slice(extra);
let cli = Cli::try_parse_from(argv).expect("parses");
let Command::Spawn(args) = cli.command else {
panic!("expected spawn")
};
args
}
/// The CLI follows the two interactive clients: a command with an ACP
/// adapter spawns structured unless `--terminal` says otherwise, and
/// `--structured` still forces the request for a command this side does
/// not recognise.
#[test]
fn spawn_defaults_to_structured_for_supported_agents() {
use super::spawn_view;
assert_eq!(
spawn_view(&spawn_args(&["claude"])),
SessionView::Structured
);
assert_eq!(
spawn_view(&spawn_args(&["npx codex exec"])),
SessionView::Structured
);
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
assert_eq!(
spawn_view(&spawn_args(&["claude", "--terminal"])),
SessionView::Terminal
);
assert_eq!(
spawn_view(&spawn_args(&["opencode", "--structured"])),
SessionView::Structured
);
}
#[test]
fn spawn_refuses_both_view_flags_at_once() {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"claude",
"--structured",
"--terminal",
])
.unwrap_err();
assert!(err.to_string().contains("--terminal"), "{err}");
}
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
view: Default::default(),
@@ -259,6 +345,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo};
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
use maestro_testing::{session, MockDaemon};
use uuid::Uuid;
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
}],
..Default::default()
});
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -200,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
assert_eq!(sent, id);
}
/// A structured session routes through the ACP prompt endpoint, not the
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
/// complete messages).
#[tokio::test]
async fn send_to_structured_session_uses_acp_prompt() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(13);
let mut s = session(id, "/tmp", "claude");
s.view = SessionView::Structured;
mock.add_session(s);
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["fix".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);
// Terminal input must stay empty — the message went through ACP.
assert!(mock.inputs().is_empty());
let prompts = mock.structured_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].0, id);
assert_eq!(prompts[0].1.prompt, "fix the tests");
}
#[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
+10
View File
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "opencode".into(),
label: "opencode".into(),
command: "opencode".into(),
auto_approve: false,
},
SpawnPreset {
id: "zsh".into(),
label: "Shell".into(),
command: "zsh".into(),
auto_approve: false,
},
]
}
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(),
command: String::new(),
auto_approve: false,
}
}
@@ -515,6 +519,7 @@ auto_install = false
id: "fish".into(),
label: "fish".into(),
command: "fish".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -551,6 +556,7 @@ auto_install = false
id: " ".into(),
label: "x".into(),
command: "x".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -561,6 +567,7 @@ auto_install = false
id: "x".into(),
label: "x".into(),
command: "".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -578,11 +585,13 @@ auto_install = false
id: "a".into(),
label: "a".into(),
command: "a".into(),
auto_approve: false,
},
SpawnPreset {
id: "a".into(),
label: "a2".into(),
command: "a2".into(),
auto_approve: false,
},
]),
..Default::default()
@@ -597,6 +606,7 @@ auto_install = false
id: "custom".into(),
label: "Custom".into(),
command: "whatever".into(),
auto_approve: false,
}]),
..Default::default()
};
+13 -4
View File
@@ -80,13 +80,22 @@ mod tests {
}
#[test]
fn non_repo_dir_is_none() {
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
// the walk can't accidentally hit a real repo above the test's cwd.
fn non_repo_dir_does_not_find_root_inside_fixture() {
// The fixture has no `.git`, so `find_root` must not return a path
// inside it. On hosts where an ancestor of the temp dir *is* a repo
// (e.g. `/tmp` lives inside a checkout), the function legitimately
// returns that ancestor — the test only asserts the fixture itself
// was not mistaken for a repo.
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("plain/dir");
fs::create_dir_all(&sub).unwrap();
assert_eq!(find_root(&sub), None);
match find_root(&sub) {
None => {} // expected on most hosts
Some(root) => assert!(
!root.starts_with(tmp.path()),
"found .git inside the fixture at {root:?}, but none was created"
),
}
}
#[test]
+54 -8
View File
@@ -12,7 +12,8 @@
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post};
@@ -70,6 +71,10 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(
routes::patterns::STRUCTURED_ATTACHMENT,
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
)
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route(
@@ -175,7 +180,7 @@ fn dir_error_status(code: &str) -> StatusCode {
fn session_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"invalid_env" => StatusCode::BAD_REQUEST,
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The session outlived its pane: nothing to type into, and nothing
@@ -432,17 +437,58 @@ async fn structured_replay(
}
}
#[derive(Deserialize)]
struct AttachmentQuery {
name: String,
}
async fn structured_attachment(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<AttachmentQuery>,
bytes: Bytes,
) -> Response {
let name = query.name;
if name.is_empty()
|| name.len() > 180
|| name == "."
|| name == ".."
|| name
.chars()
.any(|c| c.is_control() || c == '/' || c == '\\')
{
return error_response(
StatusCode::BAD_REQUEST,
"invalid_filename",
"use a filename without directory separators, up to 180 bytes",
);
}
match s.manager.store_acp_attachment(id, &name, &bytes).await {
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_prompt(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
Ok(QueueAcpPrompt::InFlight) => error_response(
StatusCode::CONFLICT,
"turn_in_flight",
"the structured session already has an active turn",
match s.manager.queue_acp_prompt(id, body.prompt, None).await {
Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
Ok(QueueAcpPrompt::Full) => error_response(
StatusCode::TOO_MANY_REQUESTS,
"queue_full",
"the prompt queue has reached its capacity",
),
Ok(QueueAcpPrompt::Unavailable) => error_response(
StatusCode::CONFLICT,
File diff suppressed because it is too large Load Diff
+187 -118
View File
@@ -84,15 +84,27 @@ impl Fake {
}
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
panic!("unexpected daemon event")
};
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
match &event.event {
maestro_daemon::sidecar::PersistedEvent::Notification(notification) => {
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
maestro_daemon::sidecar::PersistedEvent::User { row } => row
.data
.pointer("/content/text")
.and_then(serde_json::Value::as_str)
.expect("user row text"),
maestro_daemon::sidecar::PersistedEvent::Turn { row } => row
.data
.pointer("/maestroTurnMeta/outcome")
.and_then(serde_json::Value::as_str)
.unwrap_or("[turn]"),
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
}
}
@@ -112,6 +124,7 @@ fn structured_command(
env,
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
@@ -129,6 +142,25 @@ async fn wait_for_exit(pid: u32) {
assert!(!pid_alive(pid), "pid {pid} still alive");
}
/// Wait for a session's background ACP handshake to complete (activity
/// transitions out of `Starting`). Returns the refreshed session.
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let session = manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == id)
.expect("session exists");
if session.activity != ActivityState::Starting {
return session;
}
assert!(Instant::now() < deadline, "handshake timed out");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new();
@@ -137,7 +169,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let session = fx
let created = fx
.manager
.create(structured_command(
cwd.path(),
@@ -147,8 +179,12 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
.await
.expect("create");
assert_eq!(session.view, SessionView::Structured);
assert_eq!(session.status, SessionStatus::Running);
assert_eq!(created.view, SessionView::Structured);
assert_eq!(created.status, SessionStatus::Running);
assert_eq!(created.activity, ActivityState::Starting);
assert!(created.acp_session_id.is_none());
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.activity, ActivityState::Idle);
let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}");
@@ -163,7 +199,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
"{log}"
);
assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
"{log}"
);
@@ -231,13 +267,14 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
.create(structured_command(cwd.path(), env, "codex"))
.await
.unwrap();
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "fail".into())
.queue_acp_prompt(session.id, "fail".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
assert_eq!(
fx.manager
@@ -261,10 +298,10 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "succeed".into())
.queue_acp_prompt(session.id, "succeed".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
assert_eq!(
fx.manager
@@ -294,11 +331,12 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
let created = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -396,13 +434,14 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "show my prompt".into())
.queue_acp_prompt(session.id, "show my prompt".into(), None)
.await
.expect("queue prompt"),
maestro_daemon::session::QueueAcpPrompt::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
let (snapshot, _, _) = fx
@@ -441,6 +480,7 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
.create(structured(cwd.path(), fake.env()))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
fx.manager.begin_acp_turn(session.id).await.unwrap()
@@ -484,18 +524,19 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "first".into())
.queue_acp_prompt(session.id, "first".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
let second = fx
.manager
.queue_acp_prompt(session.id, "second".into())
.queue_acp_prompt(session.id, "second".into(), None)
.await
.unwrap();
assert!(
@@ -522,21 +563,22 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "first".into())
.queue_acp_prompt(session.id, "first".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "second".into())
.queue_acp_prompt(session.id, "second".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
@@ -581,11 +623,12 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let session = fx
let created = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -624,25 +667,18 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
revived.status(session.id).await,
Some(SessionStatus::Running)
);
assert_eq!(
revived
.snapshot()
.await
.into_iter()
.find(|candidate| candidate.id == session.id)
.unwrap()
.activity,
ActivityState::Idle
);
let log = fake.log();
assert!(
log.contains(&format!("recv session/load sessionId={acp_id}")),
"{log}"
);
// Recovery adds: a continue-prompt User row, the adapter's post-load
// notification, and a turn-completed footer — 5 events total before the
// next manual tick.
wait_for_async(Duration::from_secs(5), || async {
revived
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 3)
.is_ok_and(|events| events.len() == 5)
})
.await;
@@ -651,7 +687,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
.await
.expect("replay read")
.expect("recovered event subscription");
assert_eq!(replay.len(), 3);
assert_eq!(replay.len(), 5);
assert!(live.try_recv().is_err(), "replay duplicated onto live");
let new_pid = fake.pid();
assert_ne!(new_pid, old_pid);
@@ -662,16 +698,23 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
.await
.expect("live event after recovery")
.expect("broadcast open");
assert_eq!(event.seq, 4);
assert_eq!(event.seq, 6);
assert_eq!(event_text(&event), "tick 1");
let stored = revived
.read_acp_events(session.id, EventCursor::Since(0), 10)
.expect("recovered log");
assert_eq!(stored.len(), 4, "session/load replay must not be appended");
assert_eq!(stored.len(), 6, "session/load replay must not be appended");
assert_eq!(
stored.iter().map(event_text).collect::<Vec<_>>(),
["hello from fake", "tick 1", "hello after load", "tick 1"]
[
"hello from fake",
"tick 1",
"The daemon restarted. Continue where you left off.",
"hello after load",
"completed",
"tick 1",
]
);
revived.kill(session.id, false).await.expect("kill");
@@ -705,10 +748,12 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
branch: branch.into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
assert_eq!(session.cwd, worktree);
wait_for_ready(&fx.manager, session.id).await;
push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await;
@@ -757,6 +802,7 @@ async fn failed_recovery_cleans_its_fifo() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
@@ -790,12 +836,15 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
empty.path().to_string_lossy().into_owned(),
)]);
let session = fx
let created = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("a failed session, not an error");
.expect("create registers a Starting session");
assert_eq!(created.activity, ActivityState::Starting);
// The background handshake fails because the adapter binary is missing.
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
@@ -808,13 +857,13 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
// It is a real session: listed, on disk, and dismissable like any other
// dead one.
assert!(fx.manager.has(session.id).await);
assert!(fx.manager.has(created.id).await);
assert!(fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.join(format!("{}.json", created.id))
.exists());
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
fx.manager.kill(created.id, false).await.expect("dismiss");
assert!(!fx.manager.has(created.id).await);
}
#[tokio::test]
@@ -823,20 +872,20 @@ async fn unsupported_structured_command_fails_without_launching_claude() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
let err = fx
.manager
.create(structured_command(cwd.path(), fake.env(), "opencode"))
.await
.expect("failed structured session is persisted");
.expect_err("unsupported command is rejected at request validation");
assert_eq!(session.status, SessionStatus::Dead);
let detail = session.activity_detail.expect("selection error");
assert!(detail.contains("Claude Code or Codex"), "{detail}");
assert!(
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
"{err:?}"
);
assert!(
fake.log().is_empty(),
"unsupported command launched an adapter"
);
fx.manager.kill(session.id, false).await.expect("dismiss");
}
#[tokio::test]
@@ -851,6 +900,7 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
assert!(pid_alive(pid));
@@ -893,6 +943,8 @@ async fn shutdown_reaps_every_adapter_child() {
.create(structured(cwd.path(), b.env()))
.await
.expect("create b");
wait_for_ready(&fx.manager, sa.id).await;
wait_for_ready(&fx.manager, sb.id).await;
let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb));
@@ -927,10 +979,13 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
.create(structured(cwd.path(), env))
.await
.expect("create");
fx.manager
let blocker_session = fx
.manager
.create(structured(cwd.path(), blocker.env()))
.await
.expect("create blocker");
wait_for_ready(&fx.manager, session.id).await;
wait_for_ready(&fx.manager, blocker_session.id).await;
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
@@ -982,6 +1037,7 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_exit(fake.pid()).await;
fx.manager.shutdown().await;
@@ -1015,23 +1071,31 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
let create_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
// create() returns immediately; the handshake runs in the background
// and holds a lifecycle permit.
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
// Wait until the adapter has received session/new (handshake in progress).
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
// Shutdown must wait for the in-flight background handshake.
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!shutdown.is_finished(),
"shutdown returned before create finished"
"shutdown returned before handshake finished"
);
let session = create.await.expect("create task").expect("create");
// The handshake completes in the background (500ms delay).
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
shutdown.await.expect("shutdown task");
@@ -1064,27 +1128,37 @@ async fn structured_handshakes_run_independently() {
gate.to_string_lossy().into_owned(),
);
let first_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let first =
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
wait_for_async(Duration::from_secs(5), || async {
blocked.log().contains("recv session/new")
})
.await;
// Both create() calls return immediately with Starting; the first
// adapter's handshake is gated while the second runs freely.
let first = fx
.manager
.create(structured(cwd.path(), blocked_env))
.await
.expect("first create");
let second = fx
.manager
.create(structured(cwd.path(), ready.env()))
.await
.expect("second create");
let second = tokio::time::timeout(
Duration::from_secs(5),
fx.manager.create(structured(cwd.path(), ready.env())),
)
.await
.expect("second handshake was blocked by the first")
.expect("second create");
assert_eq!(second.status, SessionStatus::Running);
assert!(!first.is_finished(), "the gate must still block the first");
// The second handshake completes independently of the first.
let second_ready = wait_for_ready(&fx.manager, second.id).await;
assert_eq!(second_ready.activity, ActivityState::Idle);
// The first handshake is still blocked.
let first_snap = fx
.manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == first.id)
.unwrap();
assert_eq!(first_snap.activity, ActivityState::Starting);
std::fs::write(&gate, b"release").unwrap();
let first = first.await.expect("first task").expect("first create");
let first_ready = wait_for_ready(&fx.manager, first.id).await;
assert_eq!(first_ready.activity, ActivityState::Idle);
fx.manager.kill(first.id, false).await.expect("kill first");
fx.manager
.kill(second.id, false)
@@ -1112,16 +1186,18 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
gate_b.to_string_lossy().into_owned(),
);
let create_a = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
};
let create_b = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
};
// Both create() calls return immediately; the background handshake
// tasks hold lifecycle permits and are blocked on their gates.
let session_a = fx
.manager
.create(structured(cwd.path(), env_a))
.await
.expect("create a");
let session_b = fx
.manager
.create(structured(cwd.path(), env_b))
.await
.expect("create b");
wait_for_async(Duration::from_secs(5), || async {
a.log().contains("recv session/new") && b.log().contains("recv session/new")
})
@@ -1141,14 +1217,14 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
assert!(!shutdown.is_finished());
std::fs::write(&gate_a, b"release").unwrap();
let session_a = create_a.await.expect("create a task").expect("create a");
wait_for_ready(&fx.manager, session_a.id).await;
assert!(
!shutdown.is_finished(),
"shutdown ignored the second create"
);
std::fs::write(&gate_b, b"release").unwrap();
let session_b = create_b.await.expect("create b task").expect("create b");
wait_for_ready(&fx.manager, session_b.id).await;
shutdown.await.expect("shutdown task");
wait_for_exit(a.pid()).await;
@@ -1164,7 +1240,7 @@ async fn shutdown_waits_for_every_admitted_structured_create() {
}
#[tokio::test]
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
@@ -1187,42 +1263,33 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
branch: "cancelled-create".into(),
create_branch: true,
}),
auto_approve: false,
};
let create = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.create(request).await })
};
// create() returns immediately with Starting; the handshake is gated.
let session = fx.manager.create(request).await.expect("create");
assert_eq!(session.activity, ActivityState::Starting);
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let id = fake
.log()
.lines()
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.expect("session id in adapter log");
let pid = fake.pid();
create.abort();
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
assert!(create.await.unwrap_err().is_cancelled());
tokio::time::timeout(Duration::from_secs(5), shutdown)
// Kill the session while its handshake is still in progress.
// force=true because the worktree has an unpushed branch commit.
fx.manager.kill(session.id, true).await.expect("kill");
// Release the gate so the background task can finish and settle.
std::fs::write(&gate, b"release").unwrap();
// Shutdown must not hang — the lifecycle permit is settled.
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
.await
.expect("cancelled admission kept shutdown waiting")
.expect("shutdown task");
.expect("shutdown hung on a settled permit");
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(5), || async {
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
&& !repo.path().join("cancelled-create").exists()
})
.await;
assert!(fx.manager.snapshot().await.is_empty());
let id = id.to_string();
let id = session.id.to_string();
assert!(
std::fs::read_dir(fx.sidecar_dir())
.into_iter()
@@ -1232,7 +1299,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
.file_name()
.to_string_lossy()
.starts_with(&id)),
"cancelled create left a sidecar artifact"
"killed Starting session left a sidecar artifact"
);
}
@@ -1249,6 +1316,7 @@ async fn shutdown_drains_final_notifications_to_the_log() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
@@ -1279,6 +1347,7 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux,
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
+1
View File
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
+38 -3
View File
@@ -11,14 +11,14 @@ use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
Fixture {
manager,
sidecar,
_tmux: tmux,
tmux,
_state_dir: state_dir,
}
}
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -170,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
let live = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&live), NameSource::Generated);
}
/// A session that dies naturally (liveness watcher marks it dead) must not
/// overwrite a haiku rename that landed before the death. Regression test for
/// the stale-clone sidecar write in `mark_session_dead`.
#[tokio::test]
async fn session_death_preserves_the_haiku_name() {
let fx = fixture();
let id = spawn(&fx.manager, None).await;
fx.manager.claim_autoname(id).await.unwrap();
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
// Verify the sidecar has Haiku before the session dies.
let before = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&before), NameSource::Haiku);
// Kill the tmux session directly so the liveness watcher picks it up.
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
fx.manager
.start_liveness_watcher(std::time::Duration::from_millis(25));
common::wait_for_async(std::time::Duration::from_secs(3), || async {
fx.manager.status(id).await == Some(SessionStatus::Dead)
})
.await;
let after = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(
display::name_source(&after),
NameSource::Haiku,
"mark_session_dead must not overwrite a haiku rename in the sidecar"
);
assert_eq!(display::custom_name(&after), Some("haiku-name"));
}
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
branch: branch.into(),
create_branch: true,
}),
auto_approve: false,
}
}
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(r#"id="app""#),
html.contains(r#"id="root""#),
"served index is missing the SPA mount point; got:\n{html}"
);
assert!(
!html.contains("maestro is running"),
"served index is the placeholder, not the built bundle"
);
// Stronger signal than `id="app"` (which appears in both source and built
// Stronger signal than `id="root"` (which appears in both source and built
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
// bundle, whereas the un-built source references `/src/main.ts`. This catches
// both an empty/placeholder embed and the staleness case.
+97 -19
View File
@@ -154,7 +154,8 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
}
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
h.manager
let id = h
.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: "/tmp".into(),
@@ -162,10 +163,13 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
env,
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
.id
.id;
wait_for_session_activity(h, id, ActivityState::Idle).await;
id
}
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
@@ -235,7 +239,61 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
}
#[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
async fn structured_attachments_preserve_bytes_and_reject_paths() {
let h = spawn("attachments").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
let endpoint = url(
h.addr,
&format!("/api/sessions/{id}/structured/attachments"),
);
let bytes = vec![0, 255, 10, 13];
let response = client
.post(&endpoint)
.query(&[("name", "sample.bin")])
.body(bytes.clone())
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.unwrap();
let path = body["path"].as_str().unwrap();
assert!(Path::new(path).is_absolute());
assert_eq!(std::fs::read(path).unwrap(), bytes);
for name in ["../escape", "a/b", "a\\b", "..", ""] {
let response = client
.post(&endpoint)
.query(&[("name", name)])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 400, "{name}");
}
let response = client
.post(&endpoint)
.query(&[("name", "large.bin")])
.body(vec![0; 20 * 1024 * 1024 + 1])
.send()
.await
.unwrap();
assert_eq!(response.status(), 413);
let response = client
.post(url(
h.addr,
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
))
.query(&[("name", "file.txt")])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test]
async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let mut env = fake.env();
@@ -252,6 +310,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.await
.unwrap();
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
// A second prompt while a turn is active is accepted into the queue.
let second = client
.post(url(
h.addr,
@@ -261,9 +320,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.send()
.await
.unwrap();
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = second.json().await.unwrap();
assert_eq!(error.error.code, "turn_in_flight");
assert_eq!(second.status(), reqwest::StatusCode::ACCEPTED);
let cancel = client
.post(url(
@@ -274,16 +331,8 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.await
.unwrap();
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
let next = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "next"}))
.send()
.await
.unwrap();
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
// After cancel + settlement, the queued "second" prompt drains. Wait for
// both the first and the drained prompt to reach ACP.
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if fake.log().matches("recv session/prompt").count() == 2 {
@@ -293,7 +342,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
}
})
.await
.expect("both prompt commands reach ACP");
.expect("first and drained queued prompt both reach ACP");
let log = fake.log();
let first_prompt = log.find("recv session/prompt").unwrap();
let cancel = log.find("recv session/cancel").unwrap();
@@ -493,6 +542,7 @@ async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -626,6 +676,7 @@ async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -768,6 +819,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
};
let created: Session = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
@@ -942,7 +994,9 @@ async fn names_honors_count_and_caps_it() {
#[tokio::test]
async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
// `.git`. The tempdir itself has no `.git`, so the result must either be
// null (no repo anywhere above) or — in sandboxed environments where /tmp
// itself sits inside a repo — a path strictly above the tempdir.
let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
@@ -957,7 +1011,16 @@ async fn gitroot_probes_path() {
.json()
.await
.unwrap();
assert!(resp.get("root").is_none() || resp["root"].is_null());
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
// inside a checkout), the root legitimately points there. The test only
// asserts no root was found *inside* the temp dir.
match resp.get("root").and_then(|v| v.as_str()) {
None => {}
Some(root) => assert!(
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
"found a repo root inside the fixture at {root:?}"
),
}
}
#[tokio::test]
@@ -985,6 +1048,7 @@ async fn patch_session_renames_it() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1028,6 +1092,7 @@ async fn post_focus_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1062,6 +1127,7 @@ async fn delete_owned_session_kills_it() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1084,6 +1150,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -1198,6 +1265,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.unwrap();
@@ -1251,6 +1319,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.unwrap();
@@ -1281,6 +1350,7 @@ async fn attach_ws_streams_pty_bytes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1383,6 +1453,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1428,6 +1499,12 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
panic!("expected structured SessionAdded")
};
assert_eq!(session.activity, ActivityState::Starting);
// The background handshake completes and sends SessionUpdated → Idle.
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
else {
panic!("expected SessionUpdated after handshake")
};
assert_eq!(session.activity, ActivityState::Idle);
let client = reqwest::Client::new();
@@ -1495,6 +1572,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+1
View File
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -70,6 +70,7 @@ impl Fx {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
}
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
env: HashMap::new(),
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
branch: "feat/indicator".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
branch: "feat/keep".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
branch: "feat/pushed".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
branch: "feat/forced".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
branch: "feat/clear".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
branch: "feat/rusty".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
branch: "feat/exited".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
branch: "feat/clean".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
branch: "feat/one".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create first");
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
branch: "feat/two".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create second");
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+2
View File
@@ -34,6 +34,8 @@ fn sample(id: Uuid) -> Session {
last_focused_at: None,
acp_session_id: None,
view: SessionView::Terminal,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+2 -2
View File
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
.map(|v| v.starts_with("text/html")),
Some(true)
);
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
}
#[tokio::test]
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
}
/// An unmatched API path must 404 rather than fall through to the SPA shell:
+12 -4
View File
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
@@ -111,6 +112,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base, branch, create_branch)),
auto_approve: false,
}
}
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
auto_approve: false,
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -260,6 +263,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
auto_approve: false,
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -319,6 +323,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
auto_approve: false,
};
let s = client
@@ -772,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
// Force overrides the refusal and nothing else: session gone, tree kept.
// Force overrides the refusal: session gone, tree removed.
client.kill_session_forced(s.id).await.expect("forced kill");
assert!(
@@ -784,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
.any(|x| x.id == s.id),
"the forced kill ends the session"
);
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
assert!(!wt.is_dir(), "a forced kill removes the worktree");
assert!(
git_worktree_list(base.path())
!git_worktree_list(base.path())
.iter()
.any(|l| l.contains("feature/local")),
"the kept tree stays registered in git"
"the removed tree is unregistered from git"
);
}
@@ -827,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
auto_approve: false,
})
.await
.expect("spawn dirty");
@@ -876,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(cwd, branch, true)),
auto_approve: false,
}
}
@@ -1068,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
env: fake.env.clone(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("spawn structured session");
+21
View File
@@ -226,6 +226,15 @@ pub fn command_tool(command: &str) -> Option<&'static str> {
.map(|(_, tool)| *tool)
}
/// Whether a command line resolves to an agent Maestro can run as a
/// structured (ACP) session — Claude Code or Codex, the two adapters
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
/// control on this, and the daemon rejects a `Structured` request for
/// anything else, so the answer has to come from one place.
pub fn supports_structured_view(command: &str) -> bool {
matches!(command_tool(command), Some("claude" | "codex"))
}
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
/// of the word that is the actual program (past every assignment/wrapper it
/// resolved through).
@@ -735,6 +744,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -1064,4 +1075,14 @@ mod tests {
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
}
#[test]
fn structured_view_support_follows_the_resolved_program() {
assert!(supports_structured_view("claude --resume"));
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
assert!(supports_structured_view("npx claude-code"));
assert!(!supports_structured_view("opencode"));
assert!(!supports_structured_view("bash -l"));
assert!(!supports_structured_view("claude-code-acp"));
}
}
+33 -1
View File
@@ -132,6 +132,16 @@ pub struct Session {
/// the two are minted by different processes with different lifetimes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>,
/// When `true` the daemon auto-approves every ACP permission request for
/// this session instead of surfacing them to the client. Set at spawn time
/// from the preset or the `CreateSessionRequest`; persisted so it survives
/// a daemon restart.
#[serde(default)]
pub auto_approve: bool,
/// Prompts queued on the daemon while a turn was in flight or the adapter
/// was still booting. Persisted so the queue survives a daemon restart.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queued_prompts: Vec<String>,
}
/// A session's process shape (#133): a plain terminal, or a structured agent
@@ -175,6 +185,10 @@ pub enum ActivityState {
AwaitingInput,
/// An agent reported a failure, or a structured session/turn failed.
Error,
/// The ACP adapter is booting; the session is registered but cannot accept
/// prompts yet. Older clients that do not know this variant degrade it to
/// `Idle` via the lenient deserializer — acceptable.
Starting,
}
impl<'de> Deserialize<'de> for ActivityState {
@@ -187,6 +201,7 @@ impl<'de> Deserialize<'de> for ActivityState {
"Active" => Self::Active,
"AwaitingInput" => Self::AwaitingInput,
"Error" => Self::Error,
"Starting" => Self::Starting,
// Known or not, any other spelling degrades to the neutral default
_ => Self::Idle,
})
@@ -225,9 +240,10 @@ where
"Idle" => Ok(ActivityState::Idle),
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
"Error" => Ok(ActivityState::Error),
"Starting" => Ok(ActivityState::Starting),
other => Err(D::Error::unknown_variant(
other,
&["Active", "Idle", "AwaitingInput", "Error"],
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
)),
}
}
@@ -383,6 +399,10 @@ pub struct CreateSessionRequest {
/// untouched.
#[serde(default)]
pub view: SessionView,
/// When `true`, the daemon auto-approves every ACP permission request
/// for this structured session.
#[serde(default)]
pub auto_approve: bool,
}
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
@@ -522,6 +542,11 @@ pub struct SpawnPreset {
pub id: String,
pub label: String,
pub command: String,
/// When `true`, the daemon auto-approves every ACP permission request
/// for structured sessions spawned from this preset instead of surfacing
/// them to the client.
#[serde(default)]
pub auto_approve: bool,
}
/// Build provenance of the running daemon: the commit it was compiled
@@ -857,6 +882,10 @@ pub struct ReducedState {
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_options: Option<Value>,
/// Prompts queued on the daemon while a turn is in flight. Drained as
/// a single concatenated prompt when the current turn completes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queued_prompts: Vec<String>,
}
/// Server messages sent over a structured-session WebSocket.
@@ -1039,6 +1068,8 @@ mod tests {
last_focused_at: None,
view: SessionView::Terminal,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -1251,6 +1282,7 @@ mod tests {
create_branch: true,
}),
view: SessionView::Terminal,
auto_approve: false,
};
assert_eq!(req, round_trip(&req));
}
+6
View File
@@ -86,6 +86,11 @@ pub fn structured_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/prompt")
}
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
pub fn structured_attachment(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/attachments")
}
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel")
@@ -113,6 +118,7 @@ pub mod patterns {
pub const STRUCTURED: &str = "/api/structured/:id";
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
+37 -1
View File
@@ -22,7 +22,8 @@ use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
PROTOCOL_VERSION,
};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot};
@@ -49,6 +50,8 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -60,6 +63,8 @@ struct Inner {
forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
/// Every ACP prompt sent to a structured session, in order.
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
}
#[derive(Clone)]
@@ -92,6 +97,7 @@ impl MockDaemon {
.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::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
@@ -178,6 +184,16 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").inputs.clone()
}
/// Every ACP prompt this daemon received, oldest first.
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
self.state
.inner
.lock()
.expect("poisoned")
.structured_prompts
.clone()
}
pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len()
}
@@ -266,6 +282,26 @@ async fn send_input(
StatusCode::NO_CONTENT.into_response()
}
async fn structured_prompt(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<StructuredPromptRequest>,
) -> 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.structured_prompts.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;
+110
View File
@@ -11,6 +11,7 @@
//! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
@@ -54,6 +55,36 @@ enum OverlayDecision {
Pass,
}
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
let encoded = base64_encode(text.as_bytes());
let mut output = std::io::stdout();
write!(output, "\x1b]52;c;{encoded}\x07")?;
output.flush()
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[((bits >> 6) & 0x3f) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(bits & 0x3f) as usize] as char
} else {
'='
});
}
output
}
pub struct App {
cfg: TuiConfig,
home_dir: String,
@@ -340,6 +371,10 @@ impl App {
return Ok(false);
}
if self.handle_structured_mouse(&key) {
return Ok(false);
}
if let Some(effects) = self.handle_structured_key(&key) {
self.perform(effects).await;
return Ok(false);
@@ -423,6 +458,61 @@ impl App {
true
}
/// Transcript blocks are pointer targets: click selects the whole block,
/// gives it the selection surface, and copies its complete text.
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
let InputKey::Mouse {
kind,
region: Region::Pane,
row,
..
} = key
else {
return false;
};
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
return false;
}
let Some(sid) = self.state.active_session() else {
return false;
};
let Some(handle) = self.structured.get(&sid) else {
return false;
};
let Some(pane) = self.region.pane else {
return false;
};
let offset = usize::from(row.saturating_sub(pane.y));
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
if offset >= usize::from(transcript_height) {
return true;
}
let mut state = handle.state.lock().expect("structured state mutex");
let rendered_line = state.scroll.saturating_add(offset);
if !state.select_rendered_line(rendered_line) {
return true;
}
let Some(id) = state.selected.as_deref() else {
return true;
};
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
return true;
};
if *kind == MouseKind::Hover {
return true;
}
let text = crate::structured_pane::copy_text(transcript);
drop(state);
if text.is_empty() {
return true;
}
match copy_to_terminal_clipboard(&text) {
Ok(()) => self.state.toast("copied transcript block"),
Err(error) => self.state.toast(format!("copy failed: {error}")),
}
true
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_payload_uses_standard_base64() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
}
use crate::action::Action;
use crate::draft::DraftField;
use crate::keymap::Mode;
@@ -2120,6 +2216,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
})
.collect(),
});
@@ -2303,6 +2401,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}],
});
app.dispatch(Action::SidebarNext);
@@ -2332,6 +2432,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -3009,6 +3111,7 @@ mod tests {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
};
app.submit_spawn(req).await;
@@ -3057,6 +3160,7 @@ mod tests {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
};
let structured_id = Uuid::from_u128(188);
@@ -3492,6 +3596,8 @@ mod tests {
blocked_work: None,
last_focused_at: focused,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
};
let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3772,6 +3878,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}],
});
app.dispatch(Action::KillHighlighted);
@@ -3809,6 +3917,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}],
});
}
+2
View File
@@ -225,6 +225,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+2
View File
@@ -811,6 +811,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+92 -19
View File
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::{abbreviate_dir, command_tool};
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::{
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
};
@@ -255,9 +255,14 @@ pub struct DraftPane {
/// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target.
pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Off by default and supported only
/// when the resolved command is Claude Code or Codex.
/// ACP-backed structured transcript. Supported only when the resolved
/// command is Claude Code or Codex, and on by default whenever it is —
/// until the operator works the toggle themselves.
pub structured_view: bool,
/// Set once the operator toggles structured view by hand. From then on a
/// command change never re-enables what they turned off, the same guard
/// `name_touched` gives the name field.
pub structured_view_touched: bool,
/// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
pub base_repo: Option<PathBuf>,
@@ -298,7 +303,7 @@ impl Default for DraftPane {
// while its data loads, and stay usable when the daemon reports none.
let mut presets = builtin_presets();
presets.push(custom_preset());
Self {
let mut draft = Self {
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
presets,
selected_preset: 0,
@@ -319,6 +324,7 @@ impl Default for DraftPane {
worktree_enabled: true,
advanced_collapsed: true,
structured_view: false,
structured_view_touched: false,
base_repo: None,
probed_cwd: None,
probe_seq: 0,
@@ -337,7 +343,11 @@ impl Default for DraftPane {
// straight away, and Tab from here reaches the command tiles.
focused_field: DraftField::DirPicker,
home_dir: String::new(),
}
};
// The built-in tile the form opens on decides whether structured view
// starts ticked, exactly as a later preset list would.
draft.sync_structured_view();
draft
}
}
@@ -356,7 +366,7 @@ impl DraftPane {
.position(|p| !p.command.is_empty())
.unwrap_or(0);
self.apply_seed_command();
self.clear_unsupported_structured_view();
self.sync_structured_view();
}
/// Land a seeded command on the tile that runs it: its own preset when the
@@ -673,18 +683,24 @@ impl DraftPane {
}
fn supports_structured_view(&self) -> bool {
matches!(command_tool(self.command()), Some("claude" | "codex"))
supports_structured_view(self.command())
}
fn clear_unsupported_structured_view(&mut self) {
/// Re-resolve the toggle after the command changed: an unsupported
/// command forces it off, a supported one turns it back on unless the
/// operator has already made the choice themselves.
fn sync_structured_view(&mut self) {
if !self.supports_structured_view() {
self.structured_view = false;
} else if !self.structured_view_touched {
self.structured_view = true;
}
}
pub fn toggle_structured_view(&mut self) {
if self.supports_structured_view() {
self.structured_view = !self.structured_view;
self.structured_view_touched = true;
}
}
@@ -718,6 +734,10 @@ impl DraftPane {
);
}
let worktree = self.build_worktree()?;
let auto_approve = self
.presets
.get(self.selected_preset)
.is_some_and(|p| p.auto_approve);
Ok(CreateSessionRequest {
view: if self.structured_view && self.supports_structured_view() {
SessionView::Structured
@@ -729,6 +749,7 @@ impl DraftPane {
env: Default::default(),
metadata,
worktree,
auto_approve,
})
}
@@ -827,7 +848,7 @@ impl DraftPane {
}
self.selected_preset =
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
self.clear_unsupported_structured_view();
self.sync_structured_view();
}
/// Step-one boxes surviving the type-to-filter, in display order. A
@@ -1130,7 +1151,7 @@ impl DraftPane {
DraftField::Preset | DraftField::Submit => {}
DraftField::CustomCommand => {
self.custom_command.handle_key(other);
self.clear_unsupported_structured_view();
self.sync_structured_view();
}
// Typing filters the list; the highlight resets to the top
// so a narrowed search always starts at its best match.
@@ -1776,6 +1797,7 @@ mod tests {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
}]);
assert_eq!(
draft.command(),
@@ -1798,11 +1820,13 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
},
]);
assert_eq!(draft.command(), "aider");
@@ -1822,6 +1846,7 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
}]);
assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l");
@@ -1888,7 +1913,7 @@ mod tests {
}
#[test]
fn structured_view_is_default_off_and_visible_for_supported_agents() {
fn structured_view_is_default_on_and_visible_for_supported_agents() {
let mut draft = DraftPane {
advanced_collapsed: false,
..DraftPane::default()
@@ -1898,25 +1923,26 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
SpawnPreset {
id: "opencode".into(),
label: "OpenCode".into(),
command: "opencode".into(),
auto_approve: false,
},
]);
assert!(!draft.structured_view);
assert!(draft.structured_view, "on by default for a supported agent");
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(rendered.contains("[ ] structured view"), "{rendered}");
assert!(rendered.contains("[x] structured view"), "{rendered}");
draft.toggle_structured_view();
assert!(draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "codex");
assert!(
@@ -2006,16 +2032,16 @@ mod tests {
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value(command);
draft.sync_structured_view();
assert_eq!(
draft.field_order().contains(&DraftField::StructuredToggle),
supported,
"toggle visibility for {command:?}"
);
draft.toggle_structured_view();
assert_eq!(
draft.structured_view, supported,
"toggle takes effect for {command:?}"
"default selection for {command:?}"
);
let view = draft.build_request().unwrap().view;
assert_eq!(
@@ -2027,6 +2053,12 @@ mod tests {
},
"request view for {command:?}"
);
draft.toggle_structured_view();
assert!(
!draft.structured_view,
"the toggle turns the default off for {command:?}"
);
}
}
@@ -2046,22 +2078,24 @@ mod tests {
id: "claude-wrapped".into(),
label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex-wrapped".into(),
label: "Codex (npx)".into(),
command: "npx codex exec".into(),
auto_approve: false,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
},
]);
assert_eq!(draft.command(), "npx claude-code --resume");
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
draft.toggle_structured_view();
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
@@ -2080,6 +2114,45 @@ mod tests {
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// The default only applies until the operator has an opinion: once they
/// untick the box, moving between supported commands leaves it unticked
/// instead of turning it back on under them.
#[test]
fn an_unticked_structured_view_survives_a_command_change() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
]);
assert!(draft.structured_view);
draft.toggle_structured_view();
assert!(!draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "codex");
assert!(
!draft.structured_view,
"the operator's choice outranks the default"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from a supported agent clears an armed
/// selection immediately, matching the preset-switch case above — an
/// invalid `Structured` request can never reach `build_request`.
@@ -2094,7 +2167,7 @@ mod tests {
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("codex");
draft.toggle_structured_view();
draft.sync_structured_view();
assert!(draft.structured_view);
typed(&mut draft, " -p");
+2
View File
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
branch: handle.to_string(),
create_branch: true,
}),
auto_approve: false,
})
}
@@ -184,6 +185,7 @@ mod tests {
id: id.into(),
label: id.into(),
command: command.into(),
auto_approve: false,
}
}
+7
View File
@@ -55,6 +55,7 @@ pub enum MouseKind {
Click,
DblClick,
Drag,
Hover,
ScrollUp,
ScrollDown,
}
@@ -148,6 +149,7 @@ impl InputKey {
MouseKind::Click => "click",
MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag",
MouseKind::Hover => "hover",
MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown",
};
@@ -178,6 +180,7 @@ impl InputKey {
"click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag,
"hover" => MouseKind::Hover,
"scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())),
@@ -297,6 +300,7 @@ where
let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::Moved => MouseKind::Hover,
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None,
@@ -681,6 +685,9 @@ mod tests {
let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
let s = "mouse:hover(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
}
#[test]
+3 -2
View File
@@ -82,7 +82,7 @@ impl Surface {
pub fn activity(&self, state: ActivityState) -> Color {
match state {
ActivityState::Active => self.active,
ActivityState::Idle => self.idle,
ActivityState::Idle | ActivityState::Starting => self.idle,
ActivityState::AwaitingInput => self.awaiting,
ActivityState::Error => self.error,
}
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
mod tests {
use super::*;
const STATES: [ActivityState; 4] = [
const STATES: [ActivityState; 5] = [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
];
+6
View File
@@ -502,6 +502,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -979,6 +981,7 @@ mod tests {
for state in [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
] {
@@ -1009,6 +1012,7 @@ mod tests {
for state in [
ActivityState::Active,
ActivityState::Idle,
ActivityState::Starting,
ActivityState::AwaitingInput,
ActivityState::Error,
] {
@@ -1270,6 +1274,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
};
let sb = Sidebar {
sessions: vec![s],
+83 -6
View File
@@ -308,11 +308,12 @@ fn fallback_after_removal(
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
let mut out = Vec::new();
for row in &state.rows {
for (index, row) in state.rows.iter().enumerate() {
if row.kind == TranscriptRowKind::Plan {
continue;
}
let row_width = width.saturating_sub(1).max(1);
let start = out.len();
match row.kind {
TranscriptRowKind::User => push_wrapped(
&mut out,
@@ -356,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
),
TranscriptRowKind::Plan => {}
}
if out.len() > start
&& state.rows[index + 1..]
.iter()
.any(|next| next.kind != TranscriptRowKind::Plan)
{
// A blank surface line is the vertical padding that keeps adjacent
// transcript blocks readable at a glance.
out.push(VisualLine {
text: String::new(),
row_id: None,
rail: false,
tone: Tone::Normal,
});
}
}
out
}
/// Text supplied to the terminal clipboard when a transcript block is
/// clicked. It preserves the full tool detail rather than its compact preview.
pub fn copy_text(row: &TranscriptRow) -> String {
match row.kind {
TranscriptRowKind::User
| TranscriptRowKind::AgentText
| TranscriptRowKind::AgentThought => content_text(row),
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
let (name, argument) = tool_title(row);
let mut lines = vec![if argument.is_empty() {
name
} else {
format!("{name} {argument}")
}];
lines.extend(tool_content_lines(row, None));
lines.join("\n")
}
TranscriptRowKind::Approval => row
.data
.get("toolCall")
.and_then(|tool| tool.get("title"))
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
}
fn push_wrapped(
out: &mut Vec<VisualLine>,
row: &TranscriptRow,
@@ -1000,6 +1044,39 @@ mod tests {
);
}
#[test]
fn transcript_blocks_have_a_padding_line_between_them() {
let mut state = StructuredState::default();
state.rows = vec![
row(
"one",
TranscriptRowKind::User,
json!({"content":{"type":"text","text":"first"}}),
),
row(
"two",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"second"}}),
),
];
let lines = build_lines(&state, 80);
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].text, "");
assert!(lines[1].row_id.is_none());
}
#[test]
fn copying_a_tool_keeps_its_full_detail() {
let tool = row(
"t",
TranscriptRowKind::ToolCall,
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
);
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
}
#[test]
fn tool_is_compact_until_selected_row_expands() {
let mut state = StructuredState::default();
@@ -1420,7 +1497,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1439,7 +1516,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1459,7 +1536,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1478,7 +1555,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1497,7 +1574,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
render_transcript_lines(&pane, &mut state, 30, 5);
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
+31
View File
@@ -199,6 +199,18 @@ impl StructuredState {
self.follow_selection = true;
}
/// Select the transcript block under a rendered transcript line. Pointer
/// input uses the same row identity map as scroll reconciliation, so a
/// wrapped block behaves as one target.
pub fn select_rendered_line(&mut self, line: usize) -> bool {
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
return false;
};
self.selected = Some(id);
self.follow_selection = false;
true
}
pub fn toggle_selected(&mut self) {
let Some(id) = self.selected.clone() else {
return;
@@ -657,6 +669,25 @@ mod tests {
assert_eq!(state.composer.value(), "hi");
}
#[test]
fn selecting_a_rendered_line_selects_its_whole_block() {
let mut state = StructuredState {
line_rows: vec![
Some("one".into()),
Some("one".into()),
None,
Some("two".into()),
],
..Default::default()
};
assert!(state.select_rendered_line(1));
assert_eq!(state.selected.as_deref(), Some("one"));
assert!(!state.follow_selection);
assert!(!state.select_rendered_line(2));
assert_eq!(state.selected.as_deref(), Some("one"));
}
#[test]
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
let mut state = StructuredState::default();
+2 -1
View File
@@ -70,7 +70,7 @@ command. `<duration>` follows in the rail's colour when shown. There is no
state word and no second badge: the rail exists so the state is still legible
when a row is dimmed or the terminal renders the duration in the default fg.
In the **web client** the rail is a 5px bar pinned to the row's own left
In the **web client** the rail is a 7px bar pinned to the row's own left
edge, square corners, spanning the full card height edge to edge — it
shifts outward with nesting depth along with the row's own padding, since
it is positioned relative to the row. It carries motion only for states that
@@ -160,6 +160,7 @@ The state word is never spelled out — the colour carries it.
| ---------------- | --------- | ---------------- |
| `Active` | `--live` | `active` |
| `Idle` | `--muted` | `idle` |
| `Starting` | `--muted` | `idle` |
| `AwaitingInput` | `--warn` | `awaiting` |
| `Error` | `--danger`| `error` |
+65 -13
View File
@@ -35,6 +35,22 @@ session's worktree holds unsaved work — applies to a structured session
exactly as it does to a terminal one; only what gets terminated differs (an
ACP handle instead of a tmux pane).
## Choosing the view
`view` is fixed at spawn and never changes. Every client picks it the same
way, from the resolved command alone
(`maestro_protocol::display::supports_structured_view`): a command that
resolves to Claude Code or Codex spawns **Structured** by default, anything
else spawns **Terminal**. The operator can override the default — the web and
TUI spawn forms show the checkbox ticked and let it be unticked, and
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
even for a command the CLI does not recognise and lets the daemon refuse it).
Once the operator works the toggle, a later command change never re-ticks it
under them; it can still be forced off when the new command has no adapter.
The daemon is the last word: a `Structured` request for a command with no
adapter is refused there, whichever client sent it.
## The adapter
The resolved structured command selects a provider adapter. Its binary is
@@ -173,10 +189,17 @@ choices, not daemon ones.
presentation for `Notice`. `Plan` is the exception: both clients omit it
from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
- The web client groups each contiguous run of two or more tool rows into a
collapsed activity block. An `AgentText` or `AgentThought` row between
tools splits them into separate groups, so the timeline reads
text → group → text → group rather than lumping every tool in a turn into
one box. Text and thought rows stay outside groups as standalone transcript
items — the operator sees all reasoning without expanding. Resolved
approval rows between tools within a run are absorbed into the group. The
collapsed header reads "N tool calls · Label 'arg'" where the label and
argument come from the last tool row's `splitToolTitle` — the operator
sees which command is current at a glance. Opening the group reveals tool
rows in chronological order.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
@@ -302,6 +325,26 @@ pending decision, entirely on the daemon side:
## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
be removed before sending; failed sends retain the draft and attachments.
Pastes also stay inline when all ten attachment slots are occupied or the text
exceeds the 20 MiB file limit.
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
Only live structured sessions accept uploads. Filenames are limited to 180
bytes and cannot contain directory separators or control characters. Files get unique names and private
file permissions in `<id>.attachments` beside the session sidecar. They persist
across daemon restarts; removing a composer chip does not delete uploaded data.
There is currently no automatic attachment cleanup.
The prompt includes each filename and host path so the adapter can read it
through its own file tools, including images when the provider supports them.
These references are persisted in the ordinary user transcript row. Attachment
bytes are not embedded in ACP messages or transcript history.
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
against each other through one per-session `operation` lock
(`AcpEventStream::operation`), because ACP permission callbacks carry no
@@ -310,17 +353,26 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn."
- A prompt is appended as a durable `User` row before it claims the turn and
- Prompts are accepted at any time: if the session is idle the daemon dispatches
immediately (`204`); if a turn is active the prompt is appended to a
server-side FIFO (`202 Accepted`, capped at 20 entries, `429` when full).
When the active turn completes, all queued prompts are concatenated with
`\n\n` and dispatched as the next turn. The queue is persisted in the session
sidecar and survives daemon restarts — clients never queue locally.
`ReducedState.queued_prompts` contains the current queue so every connected
client can render the pending messages.
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing — one active turn per session, never a hidden queue of prompts.
operator's input. If that append fails, the prompt is not dispatched.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
prompt claiming "the active turn" while the old one is still outstanding
would make the next permission callback ambiguous between the two.
and ends the turn's client-visible lifecycle immediately. The prompt queue
is preserved: after the cancelled turn settles, the queued prompts drain as
the next turn. If the session dies instead (settlement timeout), the queue
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
its response), the daemon cannot simply forget it: ACP has not confirmed the
cancel yet, and a new prompt claiming "the active turn" while the old one is
still outstanding would make the next permission callback ambiguous between
the two.
- So a cancelled-but-still-dispatched turn must **settle within
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
resolves) or that grace period runs out. If it settles in time, the next
+10 -25
View File
@@ -75,27 +75,17 @@ The point of refusing rather than cleaning up is that the session stays as the
operator's handle on their own unfinished work. A session that is killed while
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 never removes what blocked.
### `--force`
`--force` overrides the refusal and nothing else. It ends the session and
**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.
`--force` overrides the refusal and **removes the worktree**, even when it holds
unsaved work. The operator sees the breakdown in the confirmation prompt and
decides; once confirmed, the tree is gone.
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`.
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` —
@@ -113,17 +103,12 @@ once. A removed tree drops off the record, so dismissing the session afterwards
does not refuse: there is no guard left to trip.
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.
later goes through `--force`, which removes it. The same holds when the probe
itself fails: nothing is destroyed.
## Nothing is removed on the operator's behalf
## Trees left behind by natural exit
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.
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.
A forced kill always removes the worktree. A natural exit with unsaved work
leaves the tree on disk — there is no operator confirmation in that path. Those
trees are ordinary git worktrees; `git worktree list` finds them and
`git worktree remove` removes them.
+1 -1
View File
@@ -148,7 +148,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
hash = "sha256-FEWOAtSFvOWHeCNR0RK8JQ/ssW31qmShu1YD2GuqioQ=";
};
buildPhase = ''
+2 -2
View File
@@ -23,7 +23,7 @@
<meta name="apple-mobile-web-app-title" content="maestro" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+13 -3
View File
@@ -6,7 +6,7 @@
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -b tsconfig.build.json && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
@@ -17,12 +17,22 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
"marked": "^18.0.11",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^4.7.0",
"happy-dom": "^20.11.6",
"tailwindcss": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.9"
"vitest": "^2.1.9",
"zustand": "^5.0.15"
}
}
+984 -11
View File
File diff suppressed because it is too large Load Diff
+459
View File
@@ -0,0 +1,459 @@
// The app shell (#253): everything main.ts used to wire by hand, as one React
// tree. The stores are the state — a catalog store fed by the events socket, a
// layout store holding the tab/split tree — and the hash route is a deep link
// into the latter.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "zustand";
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 { createSession, fetchNames, getDaemonInfo, gitRoot, projects as fetchProjects } from "./api.js";
import {
CommandSet,
DEFAULT_LEADER,
controlByte,
defineCommands,
isBarTypingTarget,
} from "./commands.js";
import { ThemeProvider } from "./contexts/ThemeContext.js";
import { CommandBar, SPAWN_DIRECTORY_EVENT, useCommandBar } from "./components/commandBar/index.js";
import { DaemonLink, createDaemonLinkStore } from "./components/daemon/DaemonLink.js";
import { RestartOverlay } from "./components/daemon/RestartOverlay.js";
import { HelpOverlay } from "./components/dialogs/index.js";
import { useViewport } from "./components/hooks/useViewport.js";
import { Drawer } from "./components/layout/Drawer.js";
import { MultiView, type MultiViewHandle } from "./components/layout/MultiView.js";
import { Sidebar } from "./components/sidebar/index.js";
import {
SpawnModal,
spawnSimilarProjectSeed,
spawnSimilarSeed,
useFastForward,
type SpawnFormProps,
} from "./components/spawn/index.js";
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "./fastForward.js";
import { useHashRouter } from "./hooks/useHashRouter.js";
import { usePreview } from "./hooks/usePreview.js";
import { activeTab, findLeaf, openSessions } from "./layout.js";
import { createCatalogStore, type CatalogStore } from "./stores/catalogStore.js";
import { createLayoutStore, type LayoutStore } from "./stores/layoutStore.js";
import type { Project, Session, SpawnPreset, Uuid } from "./types.js";
import {
FOCUS_PANE_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
requestCloseDrawer,
requestOpenSpawn,
requestToggleDrawer,
} from "./uiEvents.js";
type SpawnSeed = SpawnFormProps["seed"];
/** Open modal state: `seed` is what the form starts from (undefined for a
* plain spawn), `newTab` where the created session lands. */
interface SpawnState {
seed: SpawnSeed;
newTab: boolean;
}
export function App() {
return (
<ThemeProvider>
<Shell />
</ThemeProvider>
);
}
function Shell() {
useViewport();
// One instance each, for the life of the page.
const catalogStore = useMemo(() => createCatalogStore(), []);
const layoutStore = useMemo(() => createLayoutStore(), []);
const linkStore = useMemo(() => createDaemonLinkStore(), []);
const sessions = useStore(catalogStore, (s: CatalogStore) => s.sessions);
const connected = useStore(catalogStore, (s: CatalogStore) => s.connected);
const loaded = useStore(catalogStore, (s: CatalogStore) => s.loaded);
const layout = useStore(layoutStore, (s: LayoutStore) => s.layout);
const [homeDir, setHomeDir] = useState<string | undefined>(undefined);
const [leader, setLeader] = useState(DEFAULT_LEADER);
const [spawn, setSpawn] = useState<SpawnState | null>(null);
const { route, navigate } = useHashRouter();
const { preview, previewedSessionId, commit: commitPreview, end: endPreview } = usePreview({
store: layoutStore,
});
useEffect(() => {
catalogStore.getState().start();
return () => catalogStore.getState().stop();
}, [catalogStore]);
// The daemon's home dir abbreviates cwds to `~/…`; its leader rebinds the
// chords hanging off it (#159), so the `?` overlay and the command bar show
// the configured chord rather than the default. Absolute paths and the
// default leader are fine fallbacks.
useEffect(() => {
void getDaemonInfo()
.then((info) => {
setHomeDir(info.home_dir);
if (info.leader) setLeader(info.leader);
})
.catch(() => {});
}, []);
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after
// a transient lost connection has already recovered.
useEffect(() => {
const recover = () => {
void linkStore.getState().probe();
catalogStore.getState().reconnect();
};
// visibilitychange fires reliably on tab/app switch; guard it so we don't
// probe while the page is being hidden.
const onVisibility = () => {
if (document.visibilityState !== "visible") return;
recover();
};
// pageshow (bfcache restore) and online (network back) indicate the page
// is active — fire unconditionally since visibilityState may lag behind on
// some mobile browsers.
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("pageshow", recover);
window.addEventListener("online", recover);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pageshow", recover);
window.removeEventListener("online", recover);
};
}, [catalogStore, linkStore]);
// The catalog is the authority on which sessions exist: a pane for a session
// that is no longer listed gets closed here, rather than waiting on its own
// socket to notice. Only once a list has actually landed — before that,
// "not listed" means "not known yet" and would close every restored pane.
useEffect(() => {
if (!loaded) return;
for (const id of openSessions(layout)) {
if (!sessions.has(id)) layoutStore.getState().closeSession(id);
}
}, [loaded, sessions, layout, layoutStore]);
const tab = activeTab(layout);
const focusedSessionId =
(tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)?.sessionId) || null;
const focusedSession = focusedSessionId ? sessions.get(focusedSessionId) : undefined;
/** Open/focus a session, settling any preview of it in place. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that row silently ignored. */
const focusSession = useCallback(
(sessionId: Uuid) => {
// `commit` cancels a pending preview and opens the session; the layout
// store settles a matching preview in place rather than moving it twice.
commitPreview(sessionId);
navigate({ kind: "attach", sessionId });
},
[commitPreview, navigate],
);
// A deep link (`#/attach/:id`) opens the session it names. Only the route
// *changing* does this: focusing a pane rewrites the hash, and re-opening
// from that would fight the layout the operator just arranged.
const lastRouteRef = useRef<string | null>(null);
useEffect(() => {
const key = route.kind === "attach" ? route.sessionId : "catalog";
if (lastRouteRef.current === key) return;
lastRouteRef.current = key;
if (route.kind === "attach") layoutStore.getState().openSession(route.sessionId);
}, [route, layoutStore]);
/** Show a freshly spawned session: it gets its own pane rather than evicting
* whatever the operator was reading. */
const mountSpawned = useCallback(
(session: Session, opts?: { newTab?: boolean }) => {
layoutStore.getState().openSessionBeside(session.id, opts);
navigate({ kind: "attach", sessionId: session.id });
requestCloseDrawer();
},
[layoutStore, navigate],
);
const openSpawn = useCallback(
(seed: SpawnSeed, newTab: boolean) => {
endPreview();
setSpawn({ seed, newTab });
},
[endPreview],
);
/** The plain spawn: the session you were last on preselects its project, so
* `n` then Enter spawns another session there without touching the picker.
* It lands in a new tab, not a split of the pane you were reading (#100). */
const openPlainSpawn = useCallback(() => {
openSpawn(focusedSession ? spawnSimilarProjectSeed(focusedSession) : undefined, true);
}, [focusedSession, openSpawn]);
/** The focused session's project — its base repo for a worktree session, its
* cwd otherwise (the rule spawnSimilarProjectSeed uses). */
const focusedProjectCwd = focusedSession
? (focusedSession.worktree?.base_repo ?? focusedSession.cwd)
: undefined;
const fastForward = useFastForward({
focusedCwd: focusedProjectCwd,
onSpawned: (session) => mountSpawned(session),
onFallback: (seed) => openSpawn(seed, false),
});
const bar = useCommandBar({ onOpen: endPreview });
useEffect(() => {
const onOpenSpawnEvent = () => openPlainSpawn();
const onSpawnSimilar = (e: Event) => {
const s = (e as CustomEvent<Session>).detail;
if (s) openSpawn(spawnSimilarSeed(s), false);
};
const onSessionSelect = (e: Event) => focusSession((e as CustomEvent<Uuid>).detail);
document.addEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
document.addEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
document.addEventListener(SESSION_SELECT_EVENT, onSessionSelect);
return () => {
document.removeEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
document.removeEventListener(SESSION_SELECT_EVENT, onSessionSelect);
};
}, [openPlainSpawn, openSpawn, focusSession]);
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
// fast-forward spawn, aimed at the chosen directory instead of the most
// recent project.
useEffect(() => {
const onSpawnDirectory = (e: Event) => {
void spawnInDirectory((e as CustomEvent<string>).detail, mountSpawned, (seed) =>
openSpawn(seed, false),
);
};
document.addEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
return () => document.removeEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
}, [mountSpawned, openSpawn]);
// Tab in the session list hands the keyboard to the pane; leader-h hands it
// back the other way. Handing it to a previewed pane is choosing it: the
// preview settles rather than snapping back when the list blurs.
const sidebarRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MultiViewHandle>(null);
const focusPane = useCallback(() => {
if (previewedSessionId) commitPreview(previewedSessionId);
viewRef.current?.focusPane();
}, [previewedSessionId, commitPreview]);
useEffect(() => {
document.addEventListener(FOCUS_PANE_EVENT, focusPane);
return () => document.removeEventListener(FOCUS_PANE_EVENT, focusPane);
}, [focusPane]);
// The global half of the command registry (#148). `n` opens the spawn modal
// and `N` fast-forwards a spawn (#67); both are plain keys, so the set's own
// `isTypingTarget` keeps them out of form fields and the terminal's hidden
// textarea. Leader-h moves the keyboard between the list and the pane — a
// chord, because an attached terminal owns every plain keystroke
// (docs/COMMAND-BAR.md).
useEffect(() => {
if (bar.open || spawn) return;
const spawnKeys = new CommandSet(
defineCommands({
"spawn.open": openPlainSpawn,
"spawn.fastForward": () => void fastForward(),
}),
);
const focusKeys = new CommandSet(
defineCommands(
{
"focus.toggle": () => {
if (sidebarRef.current?.contains(document.activeElement)) {
focusPane();
} else {
// No-op on desktop; on mobile the list is off-canvas until the
// drawer opens, so the focus has somewhere to land.
requestToggleDrawer();
sidebarRef.current?.querySelector<HTMLElement>("[tabindex]")?.focus();
}
},
// The escape hatch: binding ctrl-k takes readline's
// kill-to-end-of-line away from every pane, and leader ctrl-k gives
// it back as the literal 0x0B byte (docs/COMMAND-BAR.md).
"pane.literalCtrlK": () => viewRef.current?.sendInput("\x0b"),
// The leader pressed twice, tmux-style: one literal leader byte,
// built from the leader itself so a rebound one sends its own (#158).
"pane.literalLeader": () => viewRef.current?.sendInput(controlByte(leader)),
},
leader,
),
{ leader },
);
const onKeydown = (e: KeyboardEvent) => {
if (focusKeys.handle(e)) return;
if (isBarTypingTarget(e.target)) return;
spawnKeys.handle(e);
};
window.addEventListener("keydown", onKeydown, true);
return () => window.removeEventListener("keydown", onKeydown, true);
}, [bar.open, spawn, openPlainSpawn, fastForward, focusPane, leader]);
const openIds = useMemo(() => openSessions(layout), [layout]);
const barSessions = useMemo(() => [...sessions.values()], [sessions]);
return (
<>
<Drawer
sidebar={
<div ref={sidebarRef} className="contents">
<Sidebar
store={catalogStore}
homeDir={homeDir}
activeId={focusedSessionId}
openIds={openIds}
previewId={previewedSessionId}
linkStore={linkStore}
onSelect={(id) => focusSession(id)}
onPreview={preview}
onSpawn={openPlainSpawn}
onSpawnDefaults={() => void fastForward()}
onSpawnSimilar={(s) => openSpawn(spawnSimilarSeed(s), false)}
onFocusPane={focusPane}
/>
</div>
}
>
<MultiView
ref={viewRef}
store={layoutStore}
// Undefined until the first list lands: MultiView's boot
// reconciliation prunes against it, and an empty map would take
// every restored pane with it.
sessions={loaded ? sessions : undefined}
onDisconnect={(id) => layoutStore.getState().closeSession(id)}
emptyState={<Welcome onSpawn={requestOpenSpawn} />}
/>
</Drawer>
<DaemonLink store={linkStore} connected={connected} />
<RestartOverlay store={linkStore} />
<HelpOverlay />
{bar.open && (
<CommandBar
sessions={barSessions}
homeDir={homeDir}
onOpenSession={(id) => {
bar.closeBar();
focusSession(id);
}}
onClose={bar.closeBar}
/>
)}
{spawn && (
<SpawnModal
seed={spawn.seed}
onSuccess={(session) => mountSpawned(session, { newTab: spawn.newTab })}
onClose={() => setSpawn(null)}
/>
)}
</>
);
}
async function spawnPresets(): Promise<SpawnPreset[]> {
try {
const info = await getDaemonInfo();
if (info.presets?.length) return info.presets;
} catch {
// keep fallbacks
}
const { FALLBACK_PRESETS } = await import("./components/spawn/model.js");
return FALLBACK_PRESETS;
}
/** A directory the project scan never saw stands in as its own project; one
* outside any repo spawns without a worktree, since there is no repo to cut a
* branch from. Anything still unresolved opens the pre-filled form. */
async function spawnInDirectory(
path: string,
onSpawned: (session: Session) => void,
onFallback: (seed: SpawnSeed) => void,
): Promise<void> {
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
const known = discovered.find((p) => p.path === path);
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
const resolved = resolveFastForward([project], presets, handle, path);
const req = buildFastForwardRequest(resolved);
if (req) {
const inRepo =
known !== undefined ||
(await gitRoot(path)
.then((r) => !!r.root)
.catch(() => false));
if (!inRepo) delete req.worktree;
try {
onSpawned(await createSession(req));
return;
} catch {
// A failed POST degrades to the pre-filled form, never a dead row.
}
}
onFallback(fastForwardSeed(resolved));
}
/** The content pane with nothing open. The ☰ is mobile-only: with no pane
* there is no pane header to carry it, so the drawer would be swipe-only. */
function Welcome({ onSpawn }: { onSpawn(): void }) {
return (
<div className="empty content-welcome">
<div className="empty-glyph" aria-hidden="true">
⌘
</div>
<div className="empty-title">No session open</div>
<p className="empty-hint">
Pick a session from the list, or start a new one. Open sessions keep streaming in the
background — switching back is instant.
</p>
<p className="empty-hint">
Press <kbd>?</kbd> for keyboard shortcuts.
</p>
<div className="empty-actions">
<button
type="button"
className="btn btn-ghost welcome-sessions"
onClick={() => requestToggleDrawer()}
>
☰ Sessions
</button>
<button
type="button"
className="btn btn-primary"
title="Draft pane: n — fast-forward spawn: N"
onClick={onSpawn}
>
+ Spawn session
</button>
</div>
</div>
);
}
+9
View File
@@ -184,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
);
}
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/octet-stream" },
body: file,
}));
}
/** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError(
-659
View File
@@ -1,659 +0,0 @@
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js";
import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
export type { AttachInfo } from "./paneTitle.js";
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
/**
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
*
* The connection is **decoupled from the UI**: it owns a long-lived WS that
* keeps streaming PTY output into the terminal buffer whether or not the view
* is currently mounted. The shell moves `el` between the visible content pane
* and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never
* the connection. Only `dispose()` (eviction, an explicit disconnect, or the
* session ending) tears the socket down — so switching away and back is
* instant, with scrollback already in place.
*/
export interface AttachConnection extends PaneConnection {}
export interface AttachOptions {
/** Invoked when the user clicks the view's explicit "disconnect" control. */
onRequestClose?: (sessionId: Uuid) => void;
/** Invoked when the socket closes on its own — the session ended or the link
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
* shell uses this to auto-close the pane rather than leave a dead one. */
onClosed?: (sessionId: Uuid) => void;
/** Asked on every close: should the socket be retried? The shell answers
* from the catalog — see `shouldRetryClose`. */
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
info?: AttachInfo,
): AttachConnection {
const el = document.createElement("div");
el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = paneTitleHtml(sessionId, info);
const titleAttr = paneTitleAttr(sessionId, info);
el.innerHTML = `
<header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${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>
</header>
<div class="attach-body"></div>
`;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
const keybar = buildKeyBar((data) => sendInput(data));
el.appendChild(keybar.el);
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
stateEl.textContent = s;
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);
});
const term = new Terminal({
cursorBlink: true,
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
// The terminal stays dark in both app themes (convention); colors are
// tuned to the maestro palette — signal-green cursor, slate background.
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
// GPU rendering is scoped to the mounted view, not the connection: acquired
// in onMount, released in onUnmount. See webglRenderer.ts.
let webgl: WebglAddon | null = null;
let dprMedia: MediaQueryList | null = null;
// The glyph atlas is rasterised for one device pixel ratio. Dragging the
// window to a display with a different one (or an Android browser-zoom
// change) leaves every cell blurry until the atlas is rebuilt.
const onDprChange = () => {
try {
term.clearTextureAtlas();
} catch {
// ignore — nothing to rebuild if the renderer already went away
}
watchDpr();
};
function watchDpr(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
try {
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
dprMedia?.addEventListener("change", onDprChange);
} catch {
// ignore — a browser without `resolution` media queries just keeps the
// atlas it has, which is the pre-WebGL behaviour anyway
}
}
function releaseWebgl(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
if (!webgl) return;
const addon = webgl;
webgl = null;
try {
addon.dispose(); // xterm falls back to the DOM renderer
} catch {
// ignore — a lost context can throw on the way out
}
}
function acquireWebgl(): void {
if (webgl || disposed || !webglPolicy.enabled()) return;
try {
const addon = new WebglAddon();
// Context loss is routine on Android — backgrounding the tab is enough.
// Drop back to the DOM renderer, then retry only while the shared budget
// allows and this view is still the one on screen.
addon.onContextLoss(() => {
webglPolicy.noteContextLoss();
releaseWebgl();
if (mounted) acquireWebgl();
});
term.loadAddon(addon);
webgl = addon;
watchDpr();
} catch {
// Context creation failed outright (no GPU, or the browser is already at
// its context cap). Count it like a loss so we stop asking, and stay on
// the DOM renderer.
webglPolicy.noteContextLoss();
webgl = null;
}
}
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
// 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: 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 root = term.element as HTMLElement | undefined;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => {
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
})._core;
return core?._renderService?.dimensions?.css?.cell?.height || 16;
};
if (root) {
let lastY = 0;
let lastX = 0;
let remainder = 0;
root.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
lastY = e.touches[0].clientY;
lastX = e.touches[0].clientX;
remainder = 0;
},
{ passive: true },
);
root.addEventListener(
"touchmove",
(e) => {
if (e.touches.length !== 1) return;
const ch = cellH();
const y = e.touches[0].clientY;
const px = lastY - y + remainder; // finger up → scroll toward newer
lastY = y;
const lines = Math.trunc(px / ch);
remainder = px - lines * ch;
if (lines === 0) return;
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
// reporting on — that's the signal to forward instead of scrollback.
const mouseTracked = term.modes.mouseTrackingMode !== "none";
if (mouseTracked && screen) {
// One wheel event per crossed cell, dispatched at the touch point so
// xterm computes valid cell coords for the mouse report.
const dir = lines > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(lines); i++) {
screen.dispatchEvent(
new WheelEvent("wheel", {
deltaY: dir * ch,
deltaMode: 0,
clientX: lastX,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
}
e.preventDefault();
} else if (term.buffer.active.type !== "alternate") {
term.scrollLines(lines);
e.preventDefault();
}
},
{ passive: false },
);
}
}
// Mobile: the special-key bar is only useful while the soft keyboard is up,
// so reveal it only when the terminal's hidden textarea is focused (CSS still
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
// preventDefault, so the bar stays put during use. Re-fit on the toggle since
// showing/hiding the bar changes the terminal's available height.
const ta = term.textarea;
if (ta) {
ta.addEventListener("focus", () => {
keybar.el.classList.add("keybar-active");
requestAnimationFrame(tryFit);
});
ta.addEventListener("blur", () => {
keybar.el.classList.remove("keybar-active");
requestAnimationFrame(tryFit);
});
}
let mounted = false;
// A previewed pane is visible but must not steal the keyboard from the
// sidebar, so mounting can opt out of the focus grab (and out of the one
// the socket does when it finishes connecting).
let focusOnMount = true;
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
// stops ~1–2 columns short of the edge. Same math, minus that subtraction.
const tryFit = () => {
if (!mounted) return; // parked views have no meaningful size to fit to
try {
const el2 = term.element;
const parent = el2?.parentElement;
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
})._core;
const cell = core?._renderService?.dimensions?.css?.cell;
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
fit.fit(); // fall back if internals aren't ready yet
return;
}
// Measure the viewport's real inner width (clientWidth excludes any
// scrollbar), not the container's — so columns always fit inside the
// viewport whether or not xterm reserves a scrollbar. Falls back to the
// padded container box before the viewport exists.
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
let availW: number;
let availH: number;
if (vp && vp.clientWidth > 0) {
availW = vp.clientWidth;
availH = vp.clientHeight;
} else {
const ps = getComputedStyle(parent);
const ts = getComputedStyle(el2);
availW =
parseInt(ps.width) -
(parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
availH =
parseInt(ps.height) -
(parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
}
const cols = Math.max(2, Math.floor(availW / cell.width));
const rows = Math.max(1, Math.floor(availH / cell.height));
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
try {
fit.fit();
} catch {
// ignore — fit fails if the container is detached or zero-sized
}
}
};
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
// Follow it once per socket; later scrolling remains under the operator's control.
let awaitingReplay = true;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
};
/** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the
* mobile key bar so both flow through the same path. */
const sendInput = (data: string) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
};
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted && focusOnMount) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore unknown control frames
}
return;
}
if (ev.data instanceof ArrayBuffer) {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer".
void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
}
};
ws.onerror = () => {
setState("error", "error");
};
ws.onclose = (ev) => {
// The web proxy relays a daemon rejection (unknown session) as an
// application close code (4000+) carrying a human reason. Show it in the
// pane — both the header and the terminal body — and KEEP the pane up so
// the user can read why, instead of letting it flash and vanish (#39).
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
setState(reason, "error");
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
// An intentional dispose has already set `disposed`; eviction and user
// close handle their own pane teardown.
if (disposed) return;
if (opts.shouldReconnect?.(sessionId, ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setState("reconnecting…", "connecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
setState(`closed (${ev.code})`, "closed");
opts.onClosed?.(sessionId);
};
}
connect();
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
// Ctrl modifier (mobile) folds into the next typed character here.
const dataDisp = term.onData((data) => {
sendInput(keybar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
// FitAddon only recomputes on the events we call it from — a window resize
// misses container-driven changes (sidebar drag, mobile layout settling,
// orientation, late font load). Observe the body directly so columns always
// match the real rendered width and the terminal fills edge to edge.
const ro = new ResizeObserver(() => tryFit());
ro.observe(body);
// Re-fit once the monospace font is actually loaded — cell width changes when
// the fallback is swapped out, which otherwise leaves stale columns.
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
let disposed = false;
return {
sessionId,
el,
onMount(mountOpts?: { focus?: boolean }) {
mounted = true;
focusOnMount = mountOpts?.focus !== false;
acquireWebgl();
// Defer a tick so the element has its content-pane dimensions before fit.
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
});
},
onUnmount() {
mounted = false;
// Parked views hand their GPU context back: the browser's cap is small
// (smaller still on Android) and MAX_PARKED connections outlive the pane.
releaseWebgl();
},
focusTerminal() {
term.focus();
},
// The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take.
sendInput,
updateInfo(next: AttachInfo) {
titleEl.innerHTML = paneTitleHtml(sessionId, next);
titleEl.title = paneTitleAttr(sessionId, next);
},
dispose() {
if (disposed) return;
disposed = true;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
el.remove();
},
};
}
/**
* One key in the mobile key bar.
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
* - `char` is a literal character (the symbols the soft keyboard buries behind
* a page switch), folded through a pending modifier like typed input.
* - `seq` is a plain key (Esc, Tab) sent verbatim.
*/
type KeyDef =
| { label: string; mod: Modifier }
| { label: string; cursor: CursorFinal }
| { label: string; tilde: number }
| { label: string; char: string }
| { label: string; seq: string };
type Modifier = "ctrl" | "alt";
/** CSI final bytes: arrows A–D, Home `H`, End `F`. */
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
/**
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
* 8-column / 2-row grid (#7):
*
* Esc Tab Home ↑ End PgUp - /
* Ctrl Alt ← ↓ → PgDn | ~
*
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a
* real-keyboard inverted-T in the middle with Home/End filling its top corners,
* then the paging pair and the shell symbols that cost a soft-keyboard page
* switch to reach. Narrower screens scroll the bar sideways rather than
* shrinking the caps below a thumb's width.
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
{ label: "Tab", seq: "\t" },
{ label: "Home", cursor: "H" },
{ label: "↑", cursor: "A" },
{ label: "End", cursor: "F" },
{ label: "PgUp", tilde: 5 },
{ label: "-", char: "-" },
{ label: "/", char: "/" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "Alt", mod: "alt" },
{ label: "←", cursor: "D" },
{ label: "↓", cursor: "B" },
{ label: "→", cursor: "C" },
{ label: "PgDn", tilde: 6 },
{ label: "|", char: "|" },
{ label: "~", char: "~" },
];
/**
* Fold a single character into its control code: letters and `@[\]^_` map to
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
*/
interface KeyBar {
readonly el: HTMLElement;
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
consume(data: string): string;
}
/**
* Build the Termux-style special-key bar. Hidden by default; CSS reveals it on
* coarse-pointer (touch) devices. `send` delivers bytes to the PTY.
*
* Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key —
* tapped here or typed on the soft keyboard — carries the modifier, then they
* disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and
* the keyboard stays open.
*/
function buildKeyBar(send: (data: string) => void): KeyBar {
const el = document.createElement("div");
el.className = "attach-keybar";
el.setAttribute("role", "toolbar");
el.setAttribute("aria-label", "Special keys");
const armed: Record<Modifier, boolean> = { ctrl: false, alt: false };
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
const setMod = (mod: Modifier, on: boolean) => {
armed[mod] = on;
modBtns[mod]?.setAttribute("aria-pressed", String(on));
};
const clearMods = () => {
setMod("ctrl", false);
setMod("alt", false);
};
for (const def of KEY_DEFS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "keycap";
btn.textContent = def.label;
if ("mod" in def) {
btn.classList.add("keycap-mod");
btn.setAttribute("aria-pressed", "false");
btn.setAttribute("aria-label", `${def.label} modifier`);
modBtns[def.mod] = btn;
btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod]));
} else if ("cursor" in def) {
btn.addEventListener("click", () => {
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
clearMods();
});
} else if ("tilde" in def) {
btn.addEventListener("click", () => {
send(tildeSeq(def.tilde, armed.ctrl, armed.alt));
clearMods();
});
} else if ("char" in def) {
btn.addEventListener("click", () => {
send(applyModsToChar(def.char, armed.ctrl, armed.alt));
clearMods();
});
} else {
btn.addEventListener("click", () => {
send(def.seq);
clearMods();
});
}
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
// preventDefault suppresses the native `:active` style, so flash an explicit
// press animation here for tactile feedback.
btn.addEventListener("pointerdown", (e) => {
e.preventDefault();
btn.classList.remove("is-pressed");
void btn.offsetWidth; // restart the animation if re-tapped mid-flash
btn.classList.add("is-pressed");
});
btn.addEventListener("animationend", () => btn.classList.remove("is-pressed"));
el.appendChild(btn);
}
return {
el,
consume(data: string): string {
if ((armed.ctrl || armed.alt) && data.length === 1) {
const out = applyModsToChar(data, armed.ctrl, armed.alt);
clearMods();
return out;
}
return data;
},
};
}
-139
View File
@@ -1,139 +0,0 @@
// @vitest-environment happy-dom
//
// The sidebar's half of the command bar's action page (#151): the bar picks
// kill or rename and raises the flow the card already has, so what the page
// runs is what the ✕ button and the `r` key run.
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { KILL_SESSION_EVENT, RENAME_SESSION_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: () => {} },
});
});
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
mounted?.dispose();
mounted = null;
document.body.replaceChildren();
});
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;
}
function mount(s: Session): HTMLElement {
const handle = renderSidebar(storeWith([s]));
mounted = handle;
document.body.appendChild(handle.el);
return handle.el;
}
function raise(name: string, s: Session): void {
document.dispatchEvent(new CustomEvent(name, { detail: s }));
}
/** 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("kill from the action page", () => {
it("goes through the same confirmation the sidebar raises (#160)", 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 s = session();
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
"Kill amber-falcon",
);
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
window.fetch = ((url: string) => {
if (String(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 s = session({ blocked_work: { untracked_files: 1 } });
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
expect(dialog?.textContent).toContain("1 untracked file");
});
});
describe("rename from the action page", () => {
it("expands the folder hiding the card, since the prompt lives in the card", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
folder?.click();
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
raise(RENAME_SESSION_EVENT, s);
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
});
it("raises the card's own prompt, pre-filled with the session's name", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
raise(RENAME_SESSION_EVENT, s);
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
expect(input?.value).toBe("amber-falcon");
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
});
});
-440
View File
@@ -1,440 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
// happy-dom runs without a backing store; renderSidebar reads the persisted
// sidebar width on mount.
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
// No daemon here; renderSidebar treats a failed info fetch as "absolute
// paths are a fine fallback".
window.fetch = () => Promise.reject(new Error("offline"));
});
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
// Like the real store, deliver an immediate snapshot so the sidebar
// renders its rows during construction.
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
}
/** 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 in diff notation", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000001",
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).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", () => {
const clear = session({
id: "aaaaaaaa-0000-0000-0000-000000000002",
blocked_work: {} as InTheWay,
});
const unprobed = session({
id: "aaaaaaaa-0000-0000-0000-000000000003",
});
const el = renderSidebar(storeWith([clear, unprobed])).el;
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
});
it("confirms before an unguarded kill (#160)", 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();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("kills nothing when the confirmation is cancelled (#160)", 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-000000000007" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
await flush();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
});
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000008",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
expect(dialog?.textContent).toContain("refused unless it is forced");
});
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;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-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("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-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);
});
});
describe("agent-reported detail (#112)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("renders the reported detail right after the duration on line 2", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000020",
status: "Dead",
activity: "Error",
activity_since: ago(90_000),
activity_detail: "cargo build failed",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
const order = [...line2!.children].map((c) => c.className);
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
expect(order.indexOf("activity-detail")).toBeLessThan(
order.findIndex((c) => c.startsWith("card-flag")),
);
});
it("omits the detail when no report is in force", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000021",
activity: "Active",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
});
});
describe("session cards (#107)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
const el = renderSidebar(storeWith([s])).el;
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
expect(line1).not.toBeNull();
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
});
it("always renders line 2 for a freshly spawned session", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000011",
activity: "Active",
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
});
it("shows dot + floored duration once the state has been held past 30s", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000012",
activity: "Active",
activity_since: ago(4 * 60_000 + 30_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("dims a name the daemon generated, until it settles (#131)", () => {
const generated = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a1",
metadata: { name: "amber-falcon", name_source: "Generated" },
});
const haiku = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a2",
metadata: { name: "sidebar-rail", name_source: "Haiku" },
});
const chosen = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a3",
metadata: { name: "my-session", name_source: "User" },
});
// Named before `name_source` existed: settled, never dimmed.
const legacy = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a4",
metadata: { name: "older-session" },
});
const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
const provisional = (s: Session) =>
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
expect(provisional(generated)).toBe(true);
expect(provisional(haiku)).toBe(false);
expect(provisional(chosen)).toBe(false);
expect(provisional(legacy)).toBe(false);
});
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;
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", () => {
const mk = (id: string, activity: string) =>
session({
id,
activity: activity as Session["activity"],
status: "Running",
activity_since: ago(90_000),
});
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
const el = renderSidebar(storeWith([idle, wait, error])).el;
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"idle",
);
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"error",
);
});
it("treats an unknown activity variant as idle instead of failing to render", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000016",
activity: "Teleported" as Session["activity"],
activity_since: ago(120_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
expect(line2?.getAttribute("data-activity")).toBe("idle");
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
});
it("moves blocked work onto line 2 as diff notation", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000017",
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("+124");
expect(line2?.textContent).toContain("-18");
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
});
it("shows exited on line 2 for dead sessions", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000018",
status: "Dead",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("exited");
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019",
command: "claude",
metadata: { name: "worker" },
activity: "AwaitingInput",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
const rail = row.querySelector<HTMLElement>(".activity-rail");
expect(rail?.getAttribute("title")).toContain("Awaiting input");
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
it("keeps the attach navigation and depth indentation", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLAnchorElement;
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
});
});
-871
View File
@@ -1,871 +0,0 @@
import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js";
import { CommandSet, defineCommands } from "./commands.js";
import { CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
import {
abbreviateCwd,
abbreviateFolderName,
blockerStats,
customSessionName,
formatInTheWay,
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
isProvisionalName,
sessionName,
sessionTreePath,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createPaletteSelect, createThemeToggle } from "./theme.js";
import type { DaemonLink } from "./daemonLink.js";
import { createStatusIndicator } from "./statusIndicator.js";
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
* multi-view controller lives; dispatched from every Spawn affordance. */
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
export function requestOpenSpawn(): void {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
}
/** Ask the shell to open the command bar with an empty query. Dispatched by
* the sidebar's search field, which is the touch entry point to the bar
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
* Handled in main.ts, which owns the session snapshot the bar needs. */
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
export function requestOpenCommandBar(): void {
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
}
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
* what Tab 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 the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** Ask the sidebar to kill a session — the command bar's action page picking
* `kill` (#151). Detail is the session. Handled here, so the kill goes
* through the same confirm-and-blockers flow the card's ✕ does
* (`docs/WORKTREES.md`); the bar never reimplements it. */
export const KILL_SESSION_EVENT = "catalog-kill-session";
/** Ask the sidebar to rename a session — the command bar's action page picking
* `rename` (#151). Detail is the session. The prompt is the card's own inline
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
export const RENAME_SESSION_EVENT = "catalog-rename-session";
/** A session row was clicked (issue #133). The row's `href` drives normal
* hash navigation, but a split/drag can move focus without ever touching
* `location.hash` — so a click back onto a session whose hash still
* matches fires no `hashchange` and the app never hears about it. This
* event is the click-driven path that doesn't depend on the hash actually
* changing. */
export const SESSION_SELECT_EVENT = "catalog-session-select";
/** 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 rail's colour
// carries it. It survives only in tooltips/aria labels so meaning is not
// colour-only.
const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
"awaiting-input": "Awaiting input",
error: "Error",
};
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
* full height. The row's own padding (`renderNode`) shifts outward with
* nesting depth, carrying the rail with it. */
function activityRailMarkup(slug: string, stateWord: string): string {
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
}
function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now);
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const fragments = [
duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
commandFragment(s.command),
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
];
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");
wrap.className = "tree-folder";
if (node.name) {
const header = document.createElement("button");
header.type = "button";
header.className = "tree-folder-header";
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
// segments to initials (`~/g/n/maestro`) so the dir name stays readable in
// a narrow sidebar; the full path lives in the tooltip.
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span>`;
header.addEventListener("click", () => {
if (collapsed.has(node.path)) collapsed.delete(node.path);
else collapsed.add(node.path);
// re-render handled by caller observing the store; trigger a refresh:
document.dispatchEvent(new CustomEvent("catalog-rerender"));
});
wrap.appendChild(header);
if (isCollapsed) return wrap;
}
for (const child of node.children) {
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
}
return wrap;
}
// The row outlives the snapshot it was built from: a refresh that changes
// nothing structural patches it in place rather than replacing it, so the
// handlers below have to read the current session, not the one captured here.
let cur = node.session;
const s = node.session;
const now = Date.now();
const row = document.createElement("a");
row.className = "tree-leaf session-card";
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);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
${activityRailMarkup(slug, stateWord)}
<span class="card-line1">
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${metaLine(s, now)}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
requestKill(cur, sessionName(cur));
});
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
});
row.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
{
label: "Kill session",
danger: true,
onSelect: () => requestKill(cur, sessionName(cur)),
},
]);
});
leafPatches.set(row, (next) => {
cur = next;
const nextSlug = activityColorSlug(next.activity ?? "Idle");
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
);
const rail = row.querySelector(".activity-rail");
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
const meta = row.querySelector(".card-line2");
if (meta) meta.outerHTML = metaLine(next, Date.now());
});
return row;
}
/** Refresh a standing card from a newer snapshot of its session, keeping the
* element (and so the pointer's `:hover`) exactly where it is. Registered by
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
/**
* Everything about the tree that a patch cannot fix up.
*
* The daemon re-sends a session on every activity, foreground or cwd shift —
* a couple of times a minute per live session — and rebuilding the sidebar for
* each one drops `:hover` from the row under the pointer. Refreshes whose
* signature is unchanged are patched into the standing rows instead; anything
* that moves, renames, appears or dies changes the signature and rebuilds.
*/
function treeSignature(node: TreeNode): string {
if (node.kind === "folder") {
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
if (node.name && collapsed.has(node.path)) return head;
return `${head}(${node.children.map(treeSignature).join(",")})`;
}
const s = node.session;
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
}
/**
* Kill behind a confirmation (#160): a kill is destructive and unattended
* agents live in these sessions, so the web asks first, the way the TUI always
* has. The daemon decides the rest — it refuses a kill over a worktree holding
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
* a forced kill. Anything else surfaces in the dialog that asked.
*/
function requestKill(s: Session, name: string): void {
openKillConfirmDialog({
name,
blockers: sessionBlockers(s),
onKill: async () => {
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;
}
throw e;
}
},
});
}
/**
* 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();
}
// The tree has one empty state: no sessions at all. The old "No matches" one
// belonged to the inline substring filter, which the command bar replaced
// (#157) — the bar's own no-results state took over with it.
function renderEmpty(onSpawn: () => void): HTMLElement {
const el = document.createElement("div");
el.className = "empty";
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
<p class="empty-hint">Start your first session — it'll show up here.</p>
<div class="empty-actions"></div>`;
const spawn = document.createElement("button");
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.addEventListener("click", onSpawn);
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
* pane (quiet), and which one is focused (loud). */
export interface SidebarHighlights {
open: Set<string>;
focused: string | null;
}
/** Live handle to the persistent sidebar (built once, survives navigation). */
export interface SidebarHandle {
/** The `<aside>` element to mount into the shell. */
el: HTMLElement;
/** 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 highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
* with `null`. A preview is a view, not state, so this only paints. */
setPreview(sessionId: string | null): 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;
}
/**
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
* live session status line, and the folder-grouped session tree. Unlike
* the old full-screen catalog this is mounted once by the shell and stays put
* while the content pane swaps between sessions — so the store subscription and
* scroll position survive navigation. The active session is highlighted.
*/
export interface SidebarOptions {
/** Pointerdown on a session row — the multi-view controller decides drag vs
* tap (issue #32). Tap falls through to the row's normal click (open). */
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
/** Daemon link behind the footer's status dot. Omitted in tests and in the
* design kit, which render the sidebar without a daemon. */
link?: DaemonLink;
/** The highlight moved onto a session, or off every session (`null`): the
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
* `source` is there so a mouse sweep can be debounced differently from a
* keyboard step. */
onPreview?: (sessionId: string | null) => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
const el = document.createElement("aside");
el.className = "sidebar";
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
// renders with absolute paths until it lands.
let homeDir: string | undefined;
// The field never holds a query: it is the touch entry point to the command
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
// on-screen keyboard over a field nothing is typed into.
const searchHeader = document.createElement("header");
searchHeader.className = "sidebar-search";
searchHeader.innerHTML = `
<input
type="search"
class="search"
readonly
placeholder="Search sessions…"
aria-label="Search sessions"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
`;
el.appendChild(searchHeader);
const treeHost = document.createElement("div");
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());
footer.appendChild(createPaletteSelect());
// 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);
}
// Daemon status sits beside the spawn button: colour for the link state,
// hover for the release the daemon is running.
const status = opts.link ? createStatusIndicator(opts.link) : null;
if (status) footer.appendChild(status.el);
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 = "Spawn form: 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) => {
if (e.button !== 0 || !opts.onLeafDrag) return;
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
const id = leaf?.dataset.sessionId;
if (id) opts.onLeafDrag(id, e);
});
// The rows are <a> elements, so the browser's native link drag-and-drop would
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
// until release. Suppress it so the pointer-based drag runs from the start.
treeHost.addEventListener("dragstart", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
});
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
// width as a :root CSS var the shell grid reads; clamped to a sane range.
const SIDEBAR_W_KEY = "maestro-sidebar-w";
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
const handle = document.createElement("div");
handle.className = "sidebar-resize";
handle.setAttribute("aria-hidden", "true");
el.appendChild(handle);
let dragStartX = 0;
let dragStartW = 0;
let dragging = false;
handle.addEventListener("pointerdown", (e) => {
dragging = true;
dragStartX = e.clientX;
dragStartW = el.getBoundingClientRect().width;
handle.setPointerCapture(e.pointerId);
document.body.style.userSelect = "none";
e.preventDefault();
});
handle.addEventListener("pointermove", (e) => {
if (!dragging) return;
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
});
const endDrag = (e: PointerEvent) => {
if (!dragging) return;
dragging = false;
try {
handle.releasePointerCapture(e.pointerId);
} catch {
// pointer may already be released
}
document.body.style.userSelect = "";
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
};
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
// Touch entry (#157): the field is a button that looks like a field. It
// never holds a query — focusing or tapping it hands the keyboard straight
// to the bar's own input, which is also why it blurs first: leaving the
// focus here would make the bar impossible to close without reopening it.
const search = searchHeader.querySelector(".search") as HTMLInputElement;
const openBar = () => {
search.value = "";
search.blur();
requestOpenCommandBar();
};
search.addEventListener("focus", openBar);
// A tap can land without a focus event (iOS Safari on a blurred field), so
// the pointer path opens the bar too; the blur above keeps the two from
// stacking two overlays.
search.addEventListener("pointerdown", (e) => {
e.preventDefault();
openBar();
});
let highlights: SidebarHighlights = { open: new Set(), focused: null };
// The previewed row is painted alongside the open/focused tiers so a
// re-render can't drop it, but it is never part of `highlights`: a preview
// is a view, and nothing about it is durable (docs/PREVIEW.md).
let previewing: string | null = null;
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
if (!id) continue;
const focused = highlights.focused === id;
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
n.classList.toggle("is-previewing", previewing === id);
}
};
// ---- 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 sessionOf = (key: string | null): string | null =>
key?.startsWith("session:") ? key.slice("session:".length) : null;
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" });
opts.onPreview?.(sessionOf(navKey));
};
/** 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();
};
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
hovered = id;
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null);
});
treeHost.addEventListener("pointerleave", (e) => {
if (e.pointerType === "touch") return;
setHovered(null);
});
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
// keyboard stays here while j/k runs.
treeHost.tabIndex = -1;
// The keyboard walk's preview belongs to the list having the keyboard: once
// it doesn't, the content pane goes back to what the operator actually opened.
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
const row = rowFor(navKey);
const id = row?.dataset.sessionId;
if (!row || !id) return undefined;
const session = store.snapshot().sessions.get(id);
return session ? { row, session } : undefined;
};
/** `c`: collapse or expand the highlighted folder, the TUI's
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
* the key does nothing rather than toggling some other folder. */
const toggleHighlightedFolder = () => {
const row = rowFor(navKey);
if (!row || !row.classList.contains("tree-folder-header")) return;
row.click();
applyNav();
};
// The session list's half of the command registry (#148). One set per
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
// labels and chords still come from the one registry.
const listKeys = new CommandSet(
defineCommands({
"list.next": () => moveNav(1),
"list.prev": () => moveNav(-1),
"list.activate": () => activateNav(),
"list.toggleFolder": () => toggleHighlightedFolder(),
"list.rename": () => {
const hit = highlightedSession();
if (hit) beginRename(hit.row, hit.session);
},
"list.kill": () => {
const hit = highlightedSession();
if (hit) requestKill(hit.session, sessionName(hit.session));
},
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
}),
);
// The inline rename field lives inside a row and owns its own keys; the set's
// plain-key rule keeps this walk out of it.
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
/** Signature of what is currently on screen — see `treeSignature`. */
let renderedSignature: string | null = null;
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);
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
if (signature === renderedSignature) {
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
if (s) leafPatches.get(row)?.(s);
}
return;
}
renderedSignature = signature;
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
return;
}
if (tree.kind === "folder") {
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
}
applyHighlights();
applyNav();
};
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir;
refresh();
})
.catch(() => {
/* absolute paths are a fine fallback */
});
// Dispatched when something outside the store changed what the tree should
// look like — a folder collapsed, a rename ended — none of which the store
// snapshot can show, so the signature check has to be bypassed.
const rerenderHandler = () => {
renderedSignature = null;
refresh();
};
document.addEventListener("catalog-rerender", rerenderHandler);
// The command bar's action page (#151) picks an action and hands it here:
// both flows are the ones the card already has, reached by session rather
// than by row.
const killHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
requestKill(s, sessionName(s));
};
const renameHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
const row = rowForSession(s);
if (row) beginRename(row, s);
};
document.addEventListener(KILL_SESSION_EVENT, killHandler);
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
/** The card a session is rendered as, expanding whatever folder hides it
* first: the rename prompt lives inside the card, so a collapsed folder
* would otherwise swallow the request. */
function rowForSession(s: Session): HTMLElement | null {
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
const found = find();
if (found) return found;
// Folder paths are the abbreviated directory's segments joined back up
// (see buildTree), so the session's own path is normalised the same way
// before it is compared against them.
const cwd = sessionTreePath(s);
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
for (const folder of [...collapsed]) {
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
}
rerenderHandler();
return find();
}
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
// multi-view controller can seed the spawn modal with the session's dir/command.
const unsubscribe = store.subscribe(refresh);
// Contract granularity: re-render at least every 30s while visible so the
// duration cutover points land within one tick.
const clock = setInterval(() => {
if (document.visibilityState !== "visible") return;
refresh();
}, 30_000);
return {
el,
setActive(id: string | null) {
highlights = {
open: id ? new Set([id]) : new Set(),
focused: id,
};
applyHighlights();
},
setHighlights(h: SidebarHighlights) {
highlights = h;
applyHighlights();
},
setPreview(sessionId: string | null) {
previewing = sessionId;
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);
status?.dispose();
// An unfinished rename would otherwise leave every later sidebar
// refusing to refresh.
renamingId = null;
document.removeEventListener("catalog-rerender", rerenderHandler);
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
unsubscribe();
},
};
}
-105
View File
@@ -1,105 +0,0 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
const ID = "11111111-2222-3333-4444-555555555555";
function session(overrides: Partial<Session> = {}): Session {
return {
id: ID,
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
/** A store whose updates actually reach subscribers, unlike the one-shot
* harness in catalog.test.ts. */
function liveStore(initial: Session[]) {
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
const listeners = new Set<CatalogListener>();
const store = {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
listeners.add(fn);
fn(state);
return () => listeners.delete(fn);
},
} as unknown as CatalogStore;
const push = (sessions: Session[]) => {
state.sessions = new Map(sessions.map((s) => [s.id, s]));
for (const fn of listeners) fn(state);
};
return { store, push };
}
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
it("patches an activity change into the standing row instead of rebuilding it", () => {
const { store, push } = liveStore([session({ activity: "Idle" })]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ activity: "AwaitingInput" })]);
// Same element: a rebuild under the pointer would drop `:hover` and restart
// the row's transitions, which is the flicker this avoids.
expect(rowOf(el)).toBe(before);
// …and the card still tells the truth about the session.
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
});
it("rebuilds when the row set changes", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
expect(rowOf(el)).not.toBe(before);
});
it("rebuilds when a session dies", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ status: "Dead" })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).dataset.status).toBe("dead");
});
it("rebuilds when a session is renamed", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ metadata: { name: "harmony" } })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
});
});
-723
View File
@@ -1,723 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import {
barGroups,
barRows,
closeCommandBar,
commandBarCandidates,
dedupeCommands,
directoryBarCandidates,
directoryCandidate,
isCommandBarOpen,
openCommandBar,
orderSessionsForBar,
parseBarQuery,
sessionBarCandidates,
sessionActionCandidates,
sessionCandidate,
SPAWN_DIRECTORY_EVENT,
} from "./commandBar.js";
import { COMMAND_SPECS, type Command } from "./commands.js";
import type { Session } from "./types.js";
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/me/dev/api",
command: "bash",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
afterEach(() => {
closeCommandBar();
document.body.replaceChildren();
});
function press(key: string, init: KeyboardEventInit = {}): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
}
function type(value: string): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function rowNames(): string[] {
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
}
function activeName(): string | undefined {
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
}
describe("empty-query ordering", () => {
it("sorts most recently focused first", () => {
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
});
it("sorts a never-focused session after every focused one, newest created first", () => {
const focused = session("focused", {
last_focused_at: "2020-01-01T00:00:00Z",
created_at: "2020-01-01T00:00:00Z",
});
const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" });
const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" });
expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([
"focused",
"new-fresh",
"old-fresh",
]);
});
it("sorts dead sessions after live ones regardless of recency", () => {
const deadRecent = session("dead", {
status: "Dead",
last_focused_at: "2026-06-01T00:00:00Z",
});
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
const liveNever = session("never");
expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([
"live",
"never",
"dead",
]);
});
it("treats a null last_focused_at as never focused", () => {
const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]);
});
});
describe("session candidates", () => {
it("carries the candidate table's fields, with the directory abbreviated", () => {
const s = session("api", {
metadata: { name: "api" },
command: "claude --resume",
current_cwd: "/home/me/dev/api",
});
expect(sessionCandidate(s, "/home/me")).toEqual({
kind: "session",
session: s,
fields: {
name: "api",
directory: "~/dev/api",
command: "claude --resume",
tool: "claude",
},
});
});
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
const s = session("edit", {
metadata: {},
command: "hx src/",
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
});
const candidate = sessionCandidate(s, "/home/me");
expect(candidate.fields.tool).toBeUndefined();
expect(candidate.fields.directory).toBe("~/dev/api");
// No `metadata.name`: the sidebar's fallback is the command line.
expect(candidate.fields.name).toBe("hx src/");
});
it("builds candidates in the empty-query order", () => {
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
});
});
describe("rows and grouping", () => {
it("an empty query keeps every session in input order", () => {
const candidates = sessionBarCandidates([session("a"), session("b")]);
expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]);
});
it("groups by kind without reordering the ranking", () => {
const candidates = sessionBarCandidates([
session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }),
session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }),
]);
const rows = barRows("api", candidates);
const groups = barGroups(rows);
expect(groups).toHaveLength(1);
expect(groups[0].title).toBe("Sessions");
expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual(
rows.map((r) => r.candidate.session.id),
);
// F6: the name beats the directory, and the directory match highlights
// inside the directory.
expect(groups[0].results[0].candidate.session.id).toBe("api");
expect(groups[0].results[1].field).toBe("directory");
});
});
describe("the overlay", () => {
const sessions = [
session("api", {
metadata: { name: "api" },
current_cwd: "/home/me/dev/api",
last_focused_at: "2026-01-01T11:00:00Z",
}),
session("worker", {
metadata: { name: "worker" },
current_cwd: "/home/me/dev/worker",
last_focused_at: "2026-01-01T10:00:00Z",
}),
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
];
// Sessions only: the mixed list's command half is the sigil describe's
// business, and the registry is whatever another test file happened to bind.
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
return openCommandBar(
over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} },
);
}
it("is a registry command, so the help overlay lists it", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
expect(spec?.keys).toEqual(["ctrl-k"]);
expect(spec?.scope).toBe("global");
});
it("lists sessions by recency under a kind header on an empty query", () => {
open();
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
"Sessions",
]);
expect(rowNames()).toEqual(["api", "worker", "notes"]);
expect(activeName()).toBe("api");
});
it("opening twice leaves one overlay", () => {
open();
open();
expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1);
expect(isCommandBarOpen()).toBe(true);
});
it("ranks a typed query through the matcher and highlights the matched range", () => {
open();
type("wor");
expect(rowNames()).toEqual(["worker"]);
const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent);
expect(marks).toEqual(["wor"]);
});
it("highlights inside the directory when that is the field that matched", () => {
open();
type("notes");
const row = document.querySelector(".cmdbar-row") as HTMLElement;
expect(row.dataset.sessionId).toBe("notes");
type("dev/wor");
expect(rowNames()).toEqual(["worker"]);
expect(
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
).toBe("dev/wor");
});
it("renders a no-results state rather than an empty list", () => {
open();
type("zzz");
expect(rowNames()).toEqual([]);
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
});
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
open();
press("ArrowUp");
expect(activeName()).toBe("api");
press("ArrowDown");
expect(activeName()).toBe("worker");
press("n", { ctrlKey: true });
expect(activeName()).toBe("notes");
press("ArrowDown");
expect(activeName()).toBe("notes");
press("p", { ctrlKey: true });
expect(activeName()).toBe("worker");
});
it("puts the highlight back on the best row whenever the query changes", () => {
open();
press("ArrowDown");
expect(activeName()).toBe("worker");
type("o");
expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent);
});
it("opens the highlighted session through the shared open path on Enter", () => {
const seen: string[] = [];
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
document.addEventListener(SESSION_SELECT_EVENT, listener);
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
press("ArrowDown");
press("Enter");
document.removeEventListener(SESSION_SELECT_EVENT, listener);
expect(seen).toEqual(["worker"]);
expect(isCommandBarOpen()).toBe(false);
});
it("Enter on an empty result list does nothing", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
type("zzz");
press("Enter");
expect(onOpenSession).not.toHaveBeenCalled();
expect(isCommandBarOpen()).toBe(true);
});
it("closes on Escape and on a press outside the panel", () => {
open();
press("Escape");
expect(isCommandBarOpen()).toBe(false);
open();
(document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(isCommandBarOpen()).toBe(false);
});
it("stays open for a press inside the panel", () => {
open();
(document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(isCommandBarOpen()).toBe(true);
});
it("returns the keyboard to whatever held it when it opened", () => {
const before = document.createElement("button");
document.body.appendChild(before);
before.focus();
open();
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
press("Escape");
expect(document.activeElement).toBe(before);
});
it("clicking a row opens that session", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
expect(onOpenSession).toHaveBeenCalledWith("worker");
expect(isCommandBarOpen()).toBe(false);
});
});
// ---- the sigils (#150) ----
function command(id: string, label: string, keys: string[], run = () => {}): Command {
return { id, label, group: "Test", keys, scope: "global", run };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
function rowKeys(): string[][] {
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
);
}
describe("parsing the query line", () => {
it("reads a sigil only in first position", () => {
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
});
it("leaves a sigil character elsewhere in the query literal", () => {
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
// The case that matters: the second `~` of a directory query reaches the
// matcher.
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
});
it("returns to the mixed list when the sigil is backspaced away", () => {
expect(parseBarQuery(">").sigil).toBe(">");
expect(parseBarQuery("").sigil).toBeNull();
});
});
describe("command candidates", () => {
it("dedupes by action, not by chord", () => {
const run = () => {};
const kill = command("a.kill", "Kill the session", ["x"], run);
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
const other = command("c.rename", "Rename the session", ["r"]);
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
});
it("matches on the label only, and keeps the chord for display", () => {
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
});
});
describe("directory candidates", () => {
it("abbreviates the path it shows and keeps the one it spawns in", () => {
const c = directoryCandidate("/home/me/dev/api", "/home/me");
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
});
it("expands a typed `~` and dedupes by the directory spawned in", () => {
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
"/home/me/dev/api",
"/tmp/scratch",
]);
});
});
describe("the sigils in the overlay", () => {
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
const ran: string[] = [];
const commands = [
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
];
afterEach(() => {
ran.length = 0;
});
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
return openCommandBar({
sessions,
homeDir: "/home/me",
commands,
onOpenSession: () => {},
loadDirectories: async () => [],
listDirEntries: async () => [],
onSpawnDirectory: () => {},
...over,
});
}
it("lists every command in registry order under `>`, with its chord and no header", () => {
open();
type(">");
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
expect(rowKeys()).toEqual([["n"], ["x"]]);
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("matches a command on its label and never on its chord", () => {
open();
type(">kill");
expect(rowNames()).toEqual(["Kill the highlighted session"]);
// `n` and `x` are chords, not label text: querying one finds nothing that
// the label does not also justify.
type(">ctrlx");
expect(rowNames()).toEqual([]);
});
it("runs the highlighted command on Enter", () => {
open();
type(">kill");
press("Enter");
expect(ran).toEqual(["list.kill"]);
expect(isCommandBarOpen()).toBe(false);
});
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
open();
type("s");
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
"Sessions",
"Commands",
]);
type(">spawn");
expect(rowNames()).toEqual(["Open the spawn form"]);
// Backspacing the sigil away is ordinary editing.
type("spawn");
expect(rowNames()).toContain("Open the spawn form");
type("");
expect(rowNames()).toEqual(["api"]);
});
it("lists recent dirs then projects under `~`", async () => {
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
type("~");
await flush();
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("fills the list in from /api/dirs as a path is typed", async () => {
const asked: string[] = [];
open({
loadDirectories: async () => ["/home/me/notes"],
listDirEntries: async (dir) => {
asked.push(dir);
return ["maestro", "api"];
},
});
// The second `~` is a literal query character and reaches the matcher.
type("~~/dev/mae");
await flush();
expect(asked).toEqual(["~/dev/"]);
expect(rowNames()).toEqual(["~/dev/maestro"]);
});
it("spawns in the highlighted directory on Enter", async () => {
const spawned: string[] = [];
open({
loadDirectories: async () => ["~/dev/api"],
onSpawnDirectory: (path) => spawned.push(path),
});
type("~api");
await flush();
press("Enter");
expect(spawned).toEqual(["/home/me/dev/api"]);
expect(isCommandBarOpen()).toBe(false);
});
it("raises the spawn event when the caller supplies no handler", async () => {
const seen: string[] = [];
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
openCommandBar({
sessions,
homeDir: "/home/me",
commands,
onOpenSession: () => {},
loadDirectories: async () => ["/home/me/dev/api"],
listDirEntries: async () => [],
});
type("~");
await flush();
press("Enter");
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
expect(seen).toEqual(["/home/me/dev/api"]);
});
});
describe("the focus round-trip over a pane (#152)", () => {
function terminalTextarea(): HTMLTextAreaElement {
const ta = document.createElement("textarea");
ta.className = "xterm-helper-textarea";
document.body.appendChild(ta);
return ta;
}
it("hands the keyboard back to the terminal that had it", () => {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
press("Escape");
expect(document.activeElement).toBe(ta);
});
it("reports no focus change to the daemon, opening or closing", () => {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
try {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
press("Escape");
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
String(url).includes("/focus"),
);
expect(focusCalls).toEqual([]);
} finally {
vi.unstubAllGlobals();
}
});
});
describe("the session action page (#151)", () => {
const sessions = [
session("api", {
metadata: { name: "api" },
current_cwd: "/home/me/dev/api",
last_focused_at: "2026-01-01T11:00:00Z",
}),
session("worker", {
metadata: { name: "worker" },
current_cwd: "/home/me/dev/worker",
last_focused_at: "2026-01-01T10:00:00Z",
}),
];
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
return openCommandBar({
sessions,
homeDir: "/home/me",
commands: [],
onOpenSession: () => {},
...over,
});
}
function headerText(): string | undefined {
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
}
it("lists kill, rename and spawn similar for the highlighted session", () => {
open();
press("Tab");
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
expect(activeName()).toBe("Kill session");
});
it("names the session being acted on", () => {
open();
press("ArrowDown");
press("Tab");
expect(headerText()).toContain("worker");
});
it("drops the kind headers, since the page is one kind", () => {
open();
press("Tab");
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("stays fuzzy-searchable over the three actions", () => {
open();
press("Tab");
type("ren");
expect(rowNames()).toEqual(["Rename session"]);
type("spwn");
expect(rowNames()).toEqual(["Spawn similar"]);
type("zzz");
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
});
it("does nothing on a command row", () => {
openCommandBar({
sessions,
homeDir: "/home/me",
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
onOpenSession: () => {},
});
type(">");
const before = rowNames();
press("Tab");
expect(rowNames()).toEqual(before);
expect(headerText()).toBeUndefined();
});
it("does nothing on a directory row", async () => {
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
type("~");
await Promise.resolve();
press("Tab");
expect(rowNames()).toEqual(["~/dev/api"]);
expect(headerText()).toBeUndefined();
});
it("returns to the result list on Escape, with the previous query intact", () => {
open();
type("wor");
press("Tab");
expect(headerText()).toContain("worker");
press("Escape");
expect(isCommandBarOpen()).toBe(true);
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("returns to the result list on backspace at an empty query", () => {
open();
type("wor");
press("Tab");
type("");
press("Backspace");
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("keeps backspace ordinary while the page's query has text", () => {
open();
press("Tab");
type("ren");
press("Backspace");
expect(headerText()).toContain("api");
expect(rowNames()).toEqual(["Rename session"]);
});
it("closes the bar when the page's Escape has already been used", () => {
open();
press("Tab");
press("Escape");
press("Escape");
expect(isCommandBarOpen()).toBe(false);
});
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
const seen: string[] = [];
for (const [hook, key] of [
["onKillSession", "Kill session"],
["onRenameSession", "Rename session"],
["onSpawnSimilar", "Spawn similar"],
] as const) {
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
type("wor");
press("Tab");
type(key);
press("Enter");
expect(isCommandBarOpen()).toBe(false);
}
expect(seen).toEqual([
"onKillSession:worker",
"onRenameSession:worker",
"onSpawnSimilar:worker",
]);
});
it("hands each action to the flow it already has, by default", () => {
const seen: string[] = [];
const listeners: Array<[string, EventListener]> = [
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
];
for (const [name, fn] of listeners) document.addEventListener(name, fn);
try {
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
open();
press("Tab");
type(action);
press("Enter");
}
} finally {
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
}
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
});
it("offers no action beyond the three", () => {
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
"kill",
"rename",
"spawn-similar",
]);
});
});
-809
View File
@@ -1,809 +0,0 @@
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
// list, opened from the sidebar, closed with Escape.
//
// Three kinds live here: sessions and commands, ranked together by default,
// and directories, which need the `~` sigil. `tab` on a session drills into
// its action page (#151), which is the same overlay over three actions.
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import { keyCaps, publishedCommands, type Command } from "./commands.js";
import {
matchAndRank,
type CandidateKind,
type CommandCandidate,
type DirectoryCandidate,
type MatchRange,
type MatchResult,
type SessionCandidate,
} from "./matcher.js";
import { splitPath } from "./paths.js";
import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
/** A matcher candidate that remembers what it came from, so a result can be
* acted on without a second lookup. The extra property is invisible to the
* matcher, which only ever reads `kind` and `fields`. */
export type SessionBarCandidate = SessionCandidate & { session: Session };
export type CommandBarCandidate = CommandCandidate & { command: Command };
/** `fields.path` is the abbreviated path the operator reads and the matcher
* ranks; `path` is the one a spawn is made in. */
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
/** One row of a session's action page. It is a `command` to the matcher — a
* single `label` field, ranked exactly like a registry command — and carries
* the action it stands for instead of a registry entry. */
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
export type BarCandidate =
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
export type BarResult = MatchResult<BarCandidate>;
/** Raised when Enter lands on a directory row. The bar picks the directory; the
* shell owns the spawn, which is the same fast-forward path `N` takes. */
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
/** One kind's slice of the ranked list, in rank order. */
export interface BarGroup<C extends BarCandidate = BarCandidate> {
kind: CandidateKind;
title: string;
results: MatchResult<C>[];
}
const KIND_TITLES: Record<CandidateKind, string> = {
session: "Sessions",
command: "Commands",
directory: "Directories",
};
// ---- the sigils ----
export type BarSigil = ">" | "~";
export interface BarQuery {
/** The sigil in force, or null for the mixed list. */
sigil: BarSigil | null;
/** What the matcher sees: everything after a sigil, or the whole query. */
query: string;
}
/** Split a raw query line into its sigil and the query proper.
*
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
* literal query character, which is what a directory query with a second `~`
* in it depends on. Backspacing past a sigil needs no special case either —
* the first character goes and the query is a mixed one again. */
export function parseBarQuery(raw: string): BarQuery {
const first = raw[0];
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
return { sigil: null, query: raw };
}
// ---- the session action page ----
/** The actions a session's action page offers, and no others: the bar picks
* one and hands off to the flow that already implements it
* (docs/COMMAND-BAR.md, "Drill-in"). */
export type SessionActionId = "kill" | "rename" | "spawn-similar";
export interface SessionAction {
id: SessionActionId;
label: string;
}
export const SESSION_ACTIONS: readonly SessionAction[] = [
{ id: "kill", label: "Kill session" },
{ id: "rename", label: "Rename session" },
{ id: "spawn-similar", label: "Spawn similar" },
];
/** The action page's candidates, in the order above — which is the order an
* empty query lists them in, since the matcher keeps input order. */
export function sessionActionCandidates(): ActionBarCandidate[] {
return SESSION_ACTIONS.map((action) => ({
kind: "command",
action,
fields: { label: action.label },
}));
}
/** Narrow a `command` candidate to an action row. Both share the matcher's
* `command` kind, so what separates them is what they carry. */
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
return candidate.kind === "command" && "action" in candidate;
}
// ---- candidates ----
function focusedAt(s: Session): number | null {
const stamp = s.last_focused_at;
if (typeof stamp !== "string" || stamp === "") return null;
const ms = Date.parse(stamp);
return Number.isNaN(ms) ? null : ms;
}
function createdAt(s: Session): number {
const ms = Date.parse(s.created_at ?? "");
return Number.isNaN(ms) ? 0 : ms;
}
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
* focused first; a session that has never been focused sorts after every
* session that has, most recently created first among those; and a dead
* session sorts after every live one regardless of recency, because a session
* you cannot attach to is not where you just were.
*
* This is also the input order for a non-empty query, which is what the
* matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0;
const deadB = b.status === "Dead" ? 1 : 0;
if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a);
const focusB = focusedAt(b);
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
return createdAt(b) - createdAt(a);
});
}
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
* table names, with the directory abbreviated the way the sidebar shows it. */
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
const treePath = sessionTreePath(s);
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
const tool = splitCommand(s.command).tool ?? undefined;
return {
kind: "session",
session: s,
fields: {
name: sessionName(s),
...(directory ? { directory } : {}),
...(s.command ? { command: s.command } : {}),
...(tool ? { tool } : {}),
},
};
}
/** Every session as a candidate, in the empty-query order. */
export function sessionBarCandidates(
sessions: readonly Session[],
homeDir?: string,
): SessionBarCandidate[] {
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
}
/** Drop the commands that would list twice. Several chords can be bound to one
* action — the registry already carries its aliases on one entry, but nothing
* stops two entries running the same handler, and listed naively that is two
* identical rows. The dedupe is by **action**, never by chord: the chord is
* displayed, so a command with two of them is still one row. */
export function dedupeCommands(commands: readonly Command[]): Command[] {
const seenRun = new Set<Command["run"]>();
const seenLabel = new Set<string>();
const kept: Command[] = [];
for (const command of commands) {
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
seenRun.add(command.run);
seenLabel.add(command.label);
kept.push(command);
}
return kept;
}
/** Every bound command as a candidate, in registry order. Only the label is a
* field: the chord is displayed beside the row, never matched, because
* matching it would make `>` results depend on a user's rebinds. */
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
return dedupeCommands(commands).map((command) => ({
kind: "command",
command,
fields: { label: command.label },
}));
}
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
* a path the operator typed or `/api/dirs` completed. */
export function expandHome(path: string, homeDir?: string): string {
if (!homeDir) return path;
if (path === "~") return homeDir;
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
}
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
const absolute = expandHome(path, homeDir);
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
}
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
* first mention of each — recent dirs (newest first) ahead of the project
* list, which is the empty-query order the contract asks for. */
export function directoryBarCandidates(
paths: readonly string[],
homeDir?: string,
): DirectoryBarCandidate[] {
const seen = new Set<string>();
const out: DirectoryBarCandidate[] = [];
for (const path of paths) {
if (path === "") continue;
const candidate = directoryCandidate(path, homeDir);
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
out.push(candidate);
}
return out;
}
/** The directory list the bar starts from: what the spawn form already knows —
* `GET /api/recent-dirs` and the project list — either alone if the other
* fails. */
export async function loadBarDirectories(): Promise<string[]> {
const [recents, discovered] = await Promise.all([
recentDirs()
.then((r) => r.dirs.map((d) => d.cwd))
.catch(() => [] as string[]),
fetchProjects()
.then((list) => list.map((p) => p.path))
.catch(() => [] as string[]),
]);
return [...recents, ...discovered];
}
// ---- grouping ----
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: a group holds its matches in rank order,
* and groups appear in the order their best-scoring member does — so the first
* row under the first header is the globally best result. */
export function barGroups<C extends BarCandidate>(
results: readonly MatchResult<C>[],
): BarGroup<C>[] {
const groups: BarGroup<C>[] = [];
for (const result of results) {
const kind = result.candidate.kind;
let group = groups.find((g) => g.kind === kind);
if (!group) {
group = { kind, title: KIND_TITLES[kind], results: [] };
groups.push(group);
}
group.results.push(result);
}
return groups;
}
/** The rows a query produces, flat and in rank order — what the highlight walks
* and what Enter acts on. Headers are labels, not rows, so they are never in
* this list. */
export function barRows<C extends BarCandidate>(
query: string,
candidates: readonly C[],
): MatchResult<C>[] {
return matchAndRank(query, candidates);
}
// ---- rendering ----
/** Text with its matched ranges wrapped in `<mark>`. Ranges come from the
* matcher as `[start, end)` character pairs over the field that won. */
export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment {
const frag = document.createDocumentFragment();
let cursor = 0;
for (const [start, end] of ranges) {
if (start > cursor) frag.append(text.slice(cursor, start));
const mark = document.createElement("mark");
mark.className = "cmdbar-hit";
mark.textContent = text.slice(start, end);
frag.append(mark);
cursor = end;
}
if (cursor < text.length) frag.append(text.slice(cursor));
return frag;
}
/** The name line of a row: the session's name, the command's label, or the
* directory's path. */
function rowName(candidate: BarCandidate): string {
switch (candidate.kind) {
case "session":
return candidate.fields.name;
case "command":
return candidate.fields.label;
case "directory":
return candidate.fields.path;
}
}
/** The secondary line under a session row: the field the match was found in
* when that was not the name, so a session found through its directory says
* so. Only sessions have one — a command shows keycaps instead, and a
* directory's path is already the name. */
function metaText(result: BarResult): string | undefined {
if (result.candidate.kind !== "session") return undefined;
const fields = result.candidate.fields;
switch (result.field) {
case "directory":
return fields.directory;
case "command":
return fields.command;
case "tool":
return fields.tool;
default:
return fields.directory;
}
}
function renderRow(result: BarResult, index: number): HTMLElement {
const candidate = result.candidate;
const row = document.createElement("div");
row.className = "cmdbar-row";
row.setAttribute("role", "option");
row.id = `cmdbar-row-${index}`;
row.dataset.index = String(index);
row.dataset.kind = candidate.kind;
if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") {
row.dataset.commandId = candidate.command.id;
} else {
row.dataset.path = candidate.path;
}
const name = document.createElement("span");
name.className = "cmdbar-row-name";
const nameText = rowName(candidate);
const nameMatched =
result.field === "name" || result.field === "label" || result.field === "path";
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
row.appendChild(name);
// The chord is teaching material, not a match target: it is rendered from
// the live registry entry and never fed to the matcher.
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
const keys = document.createElement("span");
keys.className = "cmdbar-row-keys";
for (const cap of keyCaps(candidate.command.keys[0])) {
const kbd = document.createElement("kbd");
kbd.textContent = cap;
keys.appendChild(kbd);
}
row.appendChild(keys);
return row;
}
const meta = metaText(result);
if (meta) {
const el = document.createElement("span");
el.className = "cmdbar-row-meta";
const highlighted = result.field !== undefined && result.field !== "name";
el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta);
row.appendChild(el);
}
return row;
}
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
mixed: "Go to a session…",
">": "Run a command…",
"~": "Spawn in a directory…",
};
// ---- the overlay ----
export interface CommandBarOptions {
/** Sessions to build candidates from, as the store has them right now. */
sessions: readonly Session[];
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
homeDir?: string;
/** Open the highlighted session. Defaults to the shared open path — the
* sidebar's click event — which is what the contract requires; tests pass
* their own. */
onOpenSession?: (sessionId: string) => void;
/** The command registry to list under `>`. Defaults to every bound command,
* in registry order. */
commands?: readonly Command[];
/** The directory list behind `~`, fetched once on first use. */
loadDirectories?: () => Promise<string[]>;
/** One directory's subdirectories, for filling the list in as a path is
* typed. Defaults to `GET /api/dirs`. */
listDirEntries?: (dir: string) => Promise<string[]>;
/** Spawn a session in the highlighted directory. Defaults to raising
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
* spawn. */
onSpawnDirectory?: (path: string) => void;
/** The action page's three actions (#151). Each defaults to raising the
* event the flow that already implements it listens on — the sidebar's
* confirm-and-blockers kill, its inline rename prompt, the spawn form
* seeded from the session. The bar picks the action and nothing more. */
onKillSession?: (session: Session) => void;
onRenameSession?: (session: Session) => void;
onSpawnSimilar?: (session: Session) => void;
}
interface OpenBar {
el: HTMLElement;
dispose: () => void;
}
let current: OpenBar | null = null;
export function isCommandBarOpen(): boolean {
return current !== null;
}
export function closeCommandBar(): void {
current?.dispose();
}
/** Mount the bar. Opening while one is up replaces it, so a stray second call
* can never stack two overlays. */
export function openCommandBar(opts: CommandBarOptions): HTMLElement {
closeCommandBar();
// Closing returns the keyboard to whatever held it when the bar opened: a
// round trip through the bar never silently moves focus.
const previouslyFocused = document.activeElement as HTMLElement | null;
const openSession =
opts.onOpenSession ??
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
const spawnDirectory =
opts.onSpawnDirectory ??
((path: string) =>
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
const sessionEvent =
(name: string) =>
(session: Session): void => {
document.dispatchEvent(new CustomEvent(name, { detail: session }));
};
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
const listEntries =
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
opts.sessions,
opts.homeDir,
);
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
opts.commands ?? publishedCommands(),
);
const el = document.createElement("div");
el.className = "cmdbar-overlay";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", "Command bar");
const panel = document.createElement("div");
panel.className = "cmdbar-panel";
const input = document.createElement("input");
input.type = "text";
input.className = "cmdbar-input";
input.setAttribute("role", "combobox");
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", "cmdbar-results");
input.setAttribute("aria-label", "Search sessions, commands and directories");
input.setAttribute("autocomplete", "off");
input.spellcheck = false;
input.placeholder = PLACEHOLDERS.mixed;
const results = document.createElement("div");
results.className = "cmdbar-results";
results.id = "cmdbar-results";
results.setAttribute("role", "listbox");
panel.append(input, results);
el.appendChild(panel);
document.body.appendChild(el);
let rows: BarResult[] = [];
let highlighted = 0;
// The action page (#151). `page` is the session being acted on, `stashed`
// the result-list query it was drilled into from — backing out restores it,
// so the round trip costs the operator nothing.
let page: Session | null = null;
let stashed = "";
let header: HTMLElement | null = null;
// `~`'s candidates: the recents-and-projects list, fetched once, plus
// whatever `/api/dirs` has filled in for the directory being typed.
let directories: DirectoryBarCandidate[] = [];
let directoriesRequested = false;
let probed: DirectoryBarCandidate[] = [];
let probedDir: string | null = null;
const listings = new Map<string, string[]>();
function alive(): boolean {
return current?.el === el;
}
/** Fetch the recents-and-projects list the first time `~` is used. */
function ensureDirectories(): void {
if (directoriesRequested) return;
directoriesRequested = true;
loadDirectories()
.then((paths) => {
directories = directoryBarCandidates(paths, opts.homeDir);
if (alive()) render();
})
.catch(() => {
/* no directories is a shorter list, not an error state */
});
}
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
* the directory it names is listed and its children join the candidates, so
* `~` reaches a directory that was never spawned in. */
function probeDirectories(query: string): void {
const { dir } = splitPath(query);
if (dir === "") {
probed = [];
probedDir = null;
return;
}
if (dir === probedDir) return;
probedDir = dir;
const cached = listings.get(dir);
if (cached) {
probed = directoryBarCandidates(
cached.map((entry) => `${dir}${entry}`),
opts.homeDir,
);
return;
}
probed = [];
listEntries(dir)
.then((entries) => {
listings.set(dir, entries);
if (!alive() || probedDir !== dir) return;
probed = directoryBarCandidates(
entries.map((entry) => `${dir}${entry}`),
opts.homeDir,
);
render();
})
.catch(() => {
/* an unreadable directory contributes nothing */
});
}
/** What the matcher sees for the query line as it stands. With no sigil the
* list is mixed — sessions and commands together — except on an empty query,
* where the bar is a session switcher. */
function candidatesFor(parsed: BarQuery): BarCandidate[] {
if (page) return sessionActionCandidates();
switch (parsed.sigil) {
case ">":
return [...commandCandidates];
case "~": {
ensureDirectories();
probeDirectories(parsed.query);
const seen = new Set<string>();
return [...directories, ...probed].filter((c) =>
seen.has(c.path) ? false : (seen.add(c.path), true),
);
}
default:
return parsed.query === ""
? [...sessionCandidates]
: [...sessionCandidates, ...commandCandidates];
}
}
function paintHighlight(): void {
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
const active = Number(row.dataset.index) === highlighted;
row.classList.toggle("is-active", active);
row.setAttribute("aria-selected", active ? "true" : "false");
if (active) {
row.scrollIntoView({ block: "nearest" });
input.setAttribute("aria-activedescendant", row.id);
}
}
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
}
/** The line naming the session being acted on, so the target is never
* ambiguous. Present only while the action page is up. */
function paintHeader(): void {
header?.remove();
header = null;
if (!page) return;
header = document.createElement("div");
header.className = "cmdbar-page-header";
header.setAttribute("role", "presentation");
header.textContent = `Actions for "${sessionName(page)}"`;
panel.insertBefore(header, results);
}
function render(): void {
// On the action page the query line is over the three actions, so there is
// no sigil to read: `>` and `~` are ordinary characters there.
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
input.placeholder = page
? `Act on ${sessionName(page)}…`
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
paintHeader();
rows = barRows(parsed.query, candidatesFor(parsed));
// The highlight starts on the globally best row every time the list
// changes: an old index would point at a different result.
highlighted = 0;
results.replaceChildren();
if (rows.length === 0) {
const empty = document.createElement("div");
empty.className = "cmdbar-empty";
empty.textContent = "No matches";
results.appendChild(empty);
paintHighlight();
return;
}
let index = 0;
for (const group of barGroups(rows)) {
// While a sigil is in force there is one kind, so the header is
// dropped — and the action page is one kind too.
if (parsed.sigil === null && !page) {
const header = document.createElement("div");
header.className = "cmdbar-group-title";
header.setAttribute("role", "presentation");
header.textContent = group.title;
results.appendChild(header);
}
for (const result of group.results) {
const at = index;
const row = renderRow(result, at);
row.addEventListener("click", () => activate(at));
results.appendChild(row);
index += 1;
}
}
paintHighlight();
}
/** The highlight does not wrap: it stops at the first and last row. */
function move(delta: number): void {
if (rows.length === 0) return;
highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta));
paintHighlight();
}
/** `tab`: drill into the highlighted session's action page. No effect on any
* other kind, and none on the page itself, which has no session rows. */
function drillIn(): void {
const candidate = rows[highlighted]?.candidate;
if (page || candidate?.kind !== "session") return;
page = candidate.session;
stashed = input.value;
input.value = "";
render();
}
/** Escape, or backspace at an empty query: back to the result list with the
* query the drill-in was made from. */
function leavePage(): void {
page = null;
input.value = stashed;
stashed = "";
render();
}
function runAction(action: SessionAction, session: Session): void {
close();
switch (action.id) {
case "kill":
killSession(session);
return;
case "rename":
renameSession(session);
return;
case "spawn-similar":
spawnSimilar(session);
return;
}
}
function activate(index: number): void {
const result = rows[index];
if (!result) return;
const candidate = result.candidate;
if (isActionCandidate(candidate)) {
// The page cannot be up without a session to act on.
if (page) runAction(candidate.action, page);
return;
}
close();
switch (candidate.kind) {
case "session":
openSession(candidate.session.id);
return;
case "command":
candidate.command.run();
return;
case "directory":
spawnDirectory(candidate.path);
return;
}
}
function onKeydown(e: KeyboardEvent): void {
switch (e.key) {
case "Escape":
e.preventDefault();
// On an action page Escape is one level up, not the way out.
if (page) leavePage();
else close();
return;
case "Tab":
e.preventDefault();
drillIn();
return;
case "Backspace":
// Ordinary editing, except at an empty query on the action page.
if (page && input.value === "") {
e.preventDefault();
leavePage();
}
return;
case "Enter":
e.preventDefault();
activate(highlighted);
return;
case "ArrowDown":
e.preventDefault();
move(1);
return;
case "ArrowUp":
e.preventDefault();
move(-1);
return;
case "n":
if (e.ctrlKey) {
e.preventDefault();
move(1);
}
return;
case "p":
if (e.ctrlKey) {
e.preventDefault();
move(-1);
}
return;
}
}
function close(): void {
if (current?.el !== el) return;
current = null;
el.remove();
// Restoring focus is what makes the bar a view over the app rather than a
// place in it; a detached node cannot take it.
if (previouslyFocused?.isConnected) previouslyFocused.focus();
}
input.addEventListener("keydown", onKeydown);
input.addEventListener("input", render);
// A press on the scrim closes; one inside the panel is left alone.
el.addEventListener("mousedown", (ev) => {
if (!panel.contains(ev.target as Node)) close();
});
current = { el, dispose: close };
render();
input.focus();
return el;
}
@@ -0,0 +1,34 @@
// A session's action page (docs/COMMAND-BAR.md, "Drill-in"): the same overlay
// over kill, rename and spawn similar, still fuzzy searchable. The page names
// the session it acts on, so the target is never ambiguous.
import { sessionName, type Session } from "../../types.js";
import { ResultList } from "./ResultList.js";
import type { BarGroup } from "./model.js";
export interface ActionPageProps {
session: Session;
/** The three actions, ranked by the query line — one kind, so no headers. */
groups: BarGroup[];
highlighted: number;
onActivate(index: number): void;
}
export function ActionPage({ session, groups, highlighted, onActivate }: ActionPageProps) {
return (
<>
<div
role="presentation"
className="cmdbar-page-header border-b border-border px-sp-3 py-sp-1 text-fs-11 text-muted"
>
{`Actions for "${sessionName(session)}"`}
</div>
<ResultList
groups={groups}
showHeaders={false}
highlighted={highlighted}
onActivate={onActivate}
/>
</>
);
}
@@ -0,0 +1,407 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "../../uiEvents.js";
import { COMMAND_SPECS, type Command } from "../../commands.js";
import type { Session } from "../../types.js";
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
vi.mock("../../api.js", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listDir: vi.fn(async () => ({ path: "", entries: [] })),
projects: vi.fn(async () => []),
recentDirs: vi.fn(async () => ({ dirs: [] })),
}));
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/me/dev/api",
command: "bash",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
function command(over: Partial<Command> = {}): Command {
return {
id: "cmd",
label: "A command",
group: "Test",
keys: ["a"],
scope: "global",
run: () => {},
...over,
};
}
const sessions = [
session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }),
session("worker", {
current_cwd: "/home/me/dev/worker",
last_focused_at: "2026-01-01T10:00:00Z",
}),
session("notes", { current_cwd: "/home/me/notes" }),
];
function open(over: Partial<CommandBarProps> = {}) {
return render(
<CommandBar
sessions={sessions}
homeDir="/home/me"
commands={[]}
onOpenSession={() => {}}
onClose={() => {}}
{...over}
/>,
);
}
function input(): HTMLInputElement {
return screen.getByRole("combobox") as HTMLInputElement;
}
function type(value: string): void {
fireEvent.change(input(), { target: { value } });
}
function press(key: string, init: Record<string, unknown> = {}): void {
fireEvent.keyDown(input(), { key, ...init });
}
function rowNames(): string[] {
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
}
function activeName(): string | undefined {
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("the overlay", () => {
it("is a registry command, so the help overlay lists it", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
expect(spec?.keys).toEqual(["ctrl-k"]);
expect(spec?.scope).toBe("global");
});
it("lists sessions by recency under a kind header on an empty query", () => {
open();
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
"Sessions",
]);
expect(rowNames()).toEqual(["api", "worker", "notes"]);
expect(activeName()).toBe("api");
});
it("ranks a typed query through the matcher and highlights the matched range", () => {
open();
type("wor");
expect(rowNames()).toEqual(["worker"]);
expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual(
["wor"],
);
});
it("highlights inside the directory when that is the field that matched", () => {
open();
type("dev/wor");
expect(rowNames()).toEqual(["worker"]);
expect(
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
).toBe("dev/wor");
});
it("renders a no-results state rather than an empty list", () => {
open();
type("zzz");
expect(rowNames()).toEqual([]);
expect(screen.getByText("No matches")).toBeDefined();
});
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
open();
press("ArrowUp");
expect(activeName()).toBe("api");
press("ArrowDown");
expect(activeName()).toBe("worker");
press("n", { ctrlKey: true });
expect(activeName()).toBe("notes");
press("ArrowDown");
expect(activeName()).toBe("notes");
press("p", { ctrlKey: true });
expect(activeName()).toBe("worker");
});
it("puts the highlight back on the best row whenever the query changes", () => {
open();
press("ArrowDown");
expect(activeName()).toBe("worker");
type("o");
expect(activeName()).toBe(rowNames()[0]);
});
it("opens the highlighted session through the caller's open path on Enter", () => {
const onOpenSession = vi.fn();
const onClose = vi.fn();
open({ onOpenSession, onClose });
press("ArrowDown");
press("Enter");
expect(onOpenSession).toHaveBeenCalledWith("worker");
expect(onClose).toHaveBeenCalled();
});
it("raises the shared select event when the caller supplies no handler", () => {
const listener = vi.fn();
document.addEventListener(SESSION_SELECT_EVENT, listener);
open({ onOpenSession: undefined });
press("Enter");
document.removeEventListener(SESSION_SELECT_EVENT, listener);
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api");
});
it("Enter on an empty result list does nothing", () => {
const onOpenSession = vi.fn();
const onClose = vi.fn();
open({ onOpenSession, onClose });
type("zzz");
press("Enter");
expect(onOpenSession).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it("closes on Escape and on a press outside the panel", () => {
const onClose = vi.fn();
open({ onClose });
press("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(2);
});
it("stays open for a press inside the panel", () => {
const onClose = vi.fn();
open({ onClose });
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
expect(onClose).not.toHaveBeenCalled();
});
it("returns the keyboard to whatever held it when it opened", () => {
const before = document.createElement("input");
document.body.appendChild(before);
before.focus();
const view = open();
expect(document.activeElement).toBe(input());
view.unmount();
expect(document.activeElement).toBe(before);
before.remove();
});
it("clicking a row opens that session", () => {
const onOpenSession = vi.fn();
open({ onOpenSession });
fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]);
expect(onOpenSession).toHaveBeenCalledWith("worker");
});
});
describe("the sigils", () => {
const commands = [
command({ id: "help.toggle", label: "Show help", keys: ["?"] }),
command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }),
];
it("lists every command in registry order under `>`, with its chord and no header", () => {
open({ commands });
type(">");
expect(rowNames()).toEqual(["Show help", "Open the spawn form"]);
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual(
["?", "n"],
);
});
it("matches a command on its label and never on its chord", () => {
open({ commands });
type(">help");
expect(rowNames()).toEqual(["Show help"]);
type(">?");
expect(rowNames()).toEqual([]);
});
it("runs the highlighted command on Enter", () => {
const run = vi.fn();
open({ commands: [command({ label: "Run me", run })] });
type(">run");
press("Enter");
expect(run).toHaveBeenCalled();
});
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
open({ commands });
type(">show");
expect(rowNames()).toEqual(["Show help"]);
type("");
expect(rowNames()).toEqual(["api", "worker", "notes"]);
});
it("lists recent dirs then projects under `~`", async () => {
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"]));
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("fills the list in from /api/dirs as a path is typed", async () => {
const listDirEntries = vi.fn(async () => ["maestro", "api"]);
open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries });
// The second `~` is a literal query character and reaches the matcher.
type("~~/dev/mae");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
expect(listDirEntries).toHaveBeenCalledWith("~/dev/");
});
it("spawns in the highlighted directory on Enter", async () => {
const onSpawnDirectory = vi.fn();
open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
press("Enter");
expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x");
});
it("raises the spawn event when the caller supplies no handler", async () => {
const listener = vi.fn();
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
open({ loadDirectories: async () => ["/srv/x"] });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
press("Enter");
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x");
});
});
describe("the session action page", () => {
function drill(over: Partial<CommandBarProps> = {}) {
const view = open(over);
press("Tab");
return view;
}
it("lists kill, rename and spawn similar for the highlighted session", () => {
drill();
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
});
it("names the session being acted on", () => {
drill();
expect(screen.getByText('Actions for "api"')).toBeDefined();
});
it("drops the kind headers, since the page is one kind", () => {
drill();
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("stays fuzzy-searchable over the three actions", () => {
drill();
type("ren");
expect(rowNames()).toEqual(["Rename session"]);
});
it("does nothing on a command row", () => {
open({ commands: [command({ label: "Show help" })] });
type(">show");
press("Tab");
expect(rowNames()).toEqual(["Show help"]);
});
it("does nothing on a directory row", async () => {
open({ loadDirectories: async () => ["/srv/x"] });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
press("Tab");
expect(rowNames()).toEqual(["/srv/x"]);
});
it("returns to the result list on Escape, with the previous query intact", () => {
const onClose = vi.fn();
open({ onClose });
type("wor");
press("Tab");
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
press("Escape");
expect(onClose).not.toHaveBeenCalled();
expect(input().value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("returns to the result list on backspace at an empty query", () => {
drill();
fireEvent.keyDown(input(), { key: "Backspace" });
expect(rowNames()).toEqual(["api", "worker", "notes"]);
});
it("keeps backspace ordinary while the page's query has text", () => {
drill();
type("ren");
fireEvent.keyDown(input(), { key: "Backspace" });
expect(rowNames()).toEqual(["Rename session"]);
});
it("closes the bar when the page's Escape has already been used", () => {
const onClose = vi.fn();
drill({ onClose });
press("Escape");
press("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
for (const [query, key] of [
["kill", "onKillSession"],
["rename", "onRenameSession"],
["spawn", "onSpawnSimilar"],
] as const) {
const handler = vi.fn();
const onClose = vi.fn();
drill({ [key]: handler, onClose });
type(query);
press("Enter");
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
expect(onClose).toHaveBeenCalled();
cleanup();
}
});
it("hands each action to the flow it already has, by default", () => {
for (const [query, event] of [
["kill", KILL_SESSION_EVENT],
["rename", RENAME_SESSION_EVENT],
["spawn", SPAWN_SIMILAR_EVENT],
] as const) {
const listener = vi.fn();
document.addEventListener(event, listener);
drill();
type(query);
press("Enter");
document.removeEventListener(event, listener);
expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api");
cleanup();
}
});
});
@@ -0,0 +1,379 @@
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
// list. Sessions and commands rank together by default; `>` narrows to
// commands, `~` to directories. `tab` on a session drills into its action page.
//
// The bar picks an action and hands off — it never reimplements one. Each
// handler defaults to the event the flow that already implements it listens
// on, so the React bar and the legacy shell raise the same flows.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "../../uiEvents.js";
import { publishedCommands, type Command } from "../../commands.js";
import { sessionName, type Session } from "../../types.js";
import { ActionPage } from "./ActionPage.js";
import { ResultList } from "./ResultList.js";
import {
barGroups,
barRows,
commandBarCandidates,
dirEntryPaths,
directoryBarCandidates,
isActionCandidate,
listDirEntries as defaultListDirEntries,
loadBarDirectories,
parseBarQuery,
probeDir,
sessionActionCandidates,
sessionBarCandidates,
PLACEHOLDERS,
type BarCandidate,
type BarResult,
type DirectoryBarCandidate,
type SessionAction,
} from "./model.js";
/** Raised when Enter lands on a directory row and no handler was given. The
* bar picks the directory; the shell owns the spawn, which is the same
* fast-forward path `N` takes. */
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
export interface CommandBarProps {
/** Sessions to build candidates from, as the store has them right now. */
sessions: readonly Session[];
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
homeDir?: string;
/** The command registry to list under `>`. Defaults to every bound command,
* in registry order. */
commands?: readonly Command[];
/** The directory list behind `~`, fetched once on first use. */
loadDirectories?: () => Promise<string[]>;
/** One directory's subdirectories, for filling the list in as a path is
* typed. Defaults to `GET /api/dirs`. */
listDirEntries?: (dir: string) => Promise<string[]>;
onOpenSession?(sessionId: string): void;
onSpawnDirectory?(path: string): void;
onKillSession?(session: Session): void;
onRenameSession?(session: Session): void;
onSpawnSimilar?(session: Session): void;
/** Closing returns the keyboard to whatever held it when the bar opened. */
onClose(): void;
}
function raise(name: string, detail: unknown): void {
document.dispatchEvent(new CustomEvent(name, { detail }));
}
export function CommandBar(props: CommandBarProps) {
const {
sessions,
homeDir,
commands,
loadDirectories = loadBarDirectories,
listDirEntries = defaultListDirEntries,
onClose,
} = props;
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [raw, setRaw] = useState("");
const [highlighted, setHighlighted] = useState(0);
// The action page: `page` is the session being acted on, `stashed` the
// result-list query it was drilled into from — backing out restores it, so
// the round trip costs the operator nothing.
const [page, setPage] = useState<Session | null>(null);
const [stashed, setStashed] = useState("");
// `~`'s candidates: the recents-and-projects list, fetched once, plus
// whatever `/api/dirs` has filled in for the directory being typed.
const [directories, setDirectories] = useState<DirectoryBarCandidate[]>([]);
const [probed, setProbed] = useState<DirectoryBarCandidate[]>([]);
const requestedRef = useRef(false);
const listingsRef = useRef(new Map<string, string[]>());
// On the action page the query line is over the three actions, so there is
// no sigil to read: `>` and `~` are ordinary characters there.
const parsed = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]);
const sessionCandidates = useMemo(
() => sessionBarCandidates(sessions, homeDir),
[sessions, homeDir],
);
const commandCandidates = useMemo(
() => commandBarCandidates(commands ?? publishedCommands()),
[commands],
);
// Closing returns the keyboard to whatever held it when the bar opened: a
// round trip through the bar never silently moves focus.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
inputRef.current?.focus();
return () => {
if (previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, []);
// The recents-and-projects list, the first time `~` is used. No directories
// is a shorter list, not an error state.
useEffect(() => {
if (parsed.sigil !== "~" || requestedRef.current) return;
requestedRef.current = true;
let live = true;
loadDirectories()
.then((paths) => {
if (live) setDirectories(directoryBarCandidates(paths, homeDir));
})
.catch(() => {});
return () => {
live = false;
};
}, [parsed.sigil, loadDirectories, homeDir]);
// Fill the list in from `GET /api/dirs` once the query looks like a path: the
// directory it names is listed and its children join the candidates, so `~`
// reaches a directory that was never spawned in.
const dir = parsed.sigil === "~" ? probeDir(parsed.query) : "";
useEffect(() => {
if (dir === "") {
setProbed([]);
return;
}
const cached = listingsRef.current.get(dir);
if (cached) {
setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir));
return;
}
let live = true;
setProbed([]);
listDirEntries(dir)
.then((entries) => {
listingsRef.current.set(dir, entries);
if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir));
})
.catch(() => {
/* an unreadable directory contributes nothing */
});
return () => {
live = false;
};
}, [dir, listDirEntries, homeDir]);
/** What the matcher sees for the query line as it stands. With no sigil the
* list is mixed — sessions and commands together — except on an empty query,
* where the bar is a session switcher. */
const candidates: BarCandidate[] = useMemo(() => {
if (page) return sessionActionCandidates();
switch (parsed.sigil) {
case ">":
return [...commandCandidates];
case "~": {
const seen = new Set<string>();
return [...directories, ...probed].filter((c) =>
seen.has(c.path) ? false : (seen.add(c.path), true),
);
}
default:
return parsed.query === ""
? [...sessionCandidates]
: [...sessionCandidates, ...commandCandidates];
}
}, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]);
const groups = useMemo(
() => barGroups(barRows(parsed.query, candidates)),
[parsed.query, candidates],
);
// Grouping is a rendering of the ranking, so the flattened groups are the
// rows in the order they appear — which is the order the highlight walks and
// Enter acts on.
const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]);
// The highlight starts on the globally best row every time the list changes:
// an old index would point at a different result.
useEffect(() => {
setHighlighted(0);
}, [rows]);
/** The highlight does not wrap: it stops at the first and last row. */
const move = useCallback(
(delta: number) => {
setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta)));
},
[rows.length],
);
const runAction = useCallback(
(action: SessionAction, session: Session) => {
onClose();
switch (action.id) {
case "kill":
(props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session);
return;
case "rename":
(props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session);
return;
case "spawn-similar":
(props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session);
return;
}
},
[onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar],
);
const activate = useCallback(
(index: number) => {
const result = rows[index];
if (!result) return;
const candidate = result.candidate;
if (isActionCandidate(candidate)) {
// The page cannot be up without a session to act on.
if (page) runAction(candidate.action, page);
return;
}
onClose();
switch (candidate.kind) {
case "session":
(props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))(
candidate.session.id,
);
return;
case "command":
candidate.command.run();
return;
case "directory":
(props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))(
candidate.path,
);
return;
}
},
[rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory],
);
/** `tab`: drill into the highlighted session's action page. No effect on any
* other kind, and none on the page itself, which has no session rows. */
const drillIn = useCallback(() => {
const candidate = rows[highlighted]?.candidate;
if (page || candidate?.kind !== "session") return;
setPage(candidate.session);
setStashed(raw);
setRaw("");
}, [rows, highlighted, page, raw]);
/** Escape, or backspace at an empty query: back to the result list with the
* query the drill-in was made from. */
const leavePage = useCallback(() => {
setPage(null);
setRaw(stashed);
setStashed("");
}, [stashed]);
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "Escape":
e.preventDefault();
// On an action page Escape is one level up, not the way out.
if (page) leavePage();
else onClose();
return;
case "Tab":
e.preventDefault();
drillIn();
return;
case "Backspace":
// Ordinary editing, except at an empty query on the action page.
if (page && raw === "") {
e.preventDefault();
leavePage();
}
return;
case "Enter":
e.preventDefault();
activate(highlighted);
return;
case "ArrowDown":
e.preventDefault();
move(1);
return;
case "ArrowUp":
e.preventDefault();
move(-1);
return;
case "n":
if (e.ctrlKey) {
e.preventDefault();
move(1);
}
return;
case "p":
if (e.ctrlKey) {
e.preventDefault();
move(-1);
}
return;
}
};
const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined;
return createPortal(
<div
className="cmdbar-overlay fixed inset-0 z-modal flex items-start justify-center bg-bg/70 pt-[12vh] backdrop-blur-sm max-[600px]:items-end max-[600px]:pt-0"
role="dialog"
aria-modal="true"
aria-label="Command bar"
onMouseDown={(e) => {
// A press on the scrim closes; one inside the panel is left alone.
if (!panelRef.current?.contains(e.target as Node)) onClose();
}}
>
<div
ref={panelRef}
className="cmdbar-panel flex w-full max-w-[560px] flex-col overflow-hidden rounded-r-3 border border-border bg-surface shadow-2 max-[600px]:max-w-none max-[600px]:rounded-b-none"
>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="cmdbar-results"
aria-label="Search sessions, commands and directories"
aria-activedescendant={activeRowId}
autoComplete="off"
spellCheck={false}
className="cmdbar-input w-full border-b border-border bg-transparent px-sp-3 py-sp-2 text-fs-15 text-ink outline-none"
placeholder={page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onKeyDown={onKeyDown}
/>
{page ? (
<ActionPage
session={page}
groups={groups}
highlighted={highlighted}
onActivate={activate}
/>
) : (
<ResultList
groups={groups}
// While a sigil is in force there is one kind, so the header is
// dropped.
showHeaders={parsed.sigil === null}
highlighted={highlighted}
onActivate={activate}
/>
)}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,171 @@
// The bar's result list: kind headers over rank-ordered rows
// (docs/COMMAND-BAR.md, "Structure"). Headers are labels, never rows — the
// highlight walks the rows alone, in the order they are rendered.
import { useEffect, useRef } from "react";
import { keyCaps } from "../../commands.js";
import {
highlightSegments,
isActionCandidate,
metaText,
rowName,
type BarGroup,
type BarResult,
} from "./model.js";
export interface ResultListProps {
groups: BarGroup[];
/** Dropped while a sigil is in force or on the action page: one kind needs
* no header. */
showHeaders: boolean;
/** Index into the flattened rows, in the order they render. */
highlighted: number;
onActivate(index: number): void;
}
function Highlighted({ text, ranges }: { text: string; ranges: BarResult["ranges"] }) {
return (
<>
{highlightSegments(text, ranges).map((segment, i) =>
segment.hit ? (
<mark key={i} className="cmdbar-hit bg-transparent font-semibold text-accent">
{segment.text}
</mark>
) : (
<span key={i}>{segment.text}</span>
),
)}
</>
);
}
function Row({
result,
index,
active,
onActivate,
}: {
result: BarResult;
index: number;
active: boolean;
onActivate(index: number): void;
}) {
const ref = useRef<HTMLDivElement>(null);
const candidate = result.candidate;
useEffect(() => {
if (active) ref.current?.scrollIntoView({ block: "nearest" });
}, [active]);
const name = rowName(candidate);
const nameMatched =
result.field === "name" || result.field === "label" || result.field === "path";
const meta = metaText(result);
const dead = candidate.kind === "session" && candidate.session.status === "Dead";
const chord =
candidate.kind === "command" && !isActionCandidate(candidate)
? candidate.command.keys[0]
: undefined;
return (
<div
ref={ref}
role="option"
id={`cmdbar-row-${index}`}
aria-selected={active}
data-index={index}
data-kind={candidate.kind}
data-session-id={candidate.kind === "session" ? candidate.session.id : undefined}
data-action-id={isActionCandidate(candidate) ? candidate.action.id : undefined}
data-command-id={
candidate.kind === "command" && !isActionCandidate(candidate)
? candidate.command.id
: undefined
}
data-path={candidate.kind === "directory" ? candidate.path : undefined}
className={[
"cmdbar-row flex cursor-pointer items-baseline gap-sp-2 rounded-r-1 px-sp-3 py-sp-1 text-fs-13",
active ? "is-active bg-surface-2 text-ink" : "text-ink",
dead ? "is-dead opacity-60" : "",
]
.filter(Boolean)
.join(" ")}
onClick={() => onActivate(index)}
>
<span className="cmdbar-row-name truncate">
{nameMatched ? <Highlighted text={name} ranges={result.ranges} /> : name}
</span>
{chord !== undefined ? (
<span className="cmdbar-row-keys ml-auto flex gap-1">
{keyCaps(chord).map((cap, i) => (
<kbd
key={i}
className="rounded-r-1 border border-border px-1 text-fs-11 text-muted"
>
{cap}
</kbd>
))}
</span>
) : meta ? (
<span className="cmdbar-row-meta ml-auto truncate text-fs-11 text-muted">
{result.field !== undefined && result.field !== "name" ? (
<Highlighted text={meta} ranges={result.ranges} />
) : (
meta
)}
</span>
) : null}
</div>
);
}
export function ResultList({ groups, showHeaders, highlighted, onActivate }: ResultListProps) {
if (groups.length === 0) {
return (
<div
id="cmdbar-results"
role="listbox"
aria-label="Results"
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
>
<div className="cmdbar-empty px-sp-3 py-sp-2 text-fs-12 text-muted">No matches</div>
</div>
);
}
let index = 0;
return (
<div
id="cmdbar-results"
role="listbox"
aria-label="Results"
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
>
{groups.map((group) => (
<div key={group.kind}>
{showHeaders ? (
<div
role="presentation"
className="cmdbar-group-title px-sp-3 pt-sp-2 pb-1 text-fs-11 uppercase tracking-wide text-muted"
>
{group.title}
</div>
) : null}
{group.results.map((result) => {
const at = index++;
return (
<Row
key={at}
result={result}
index={at}
active={at === highlighted}
onActivate={onActivate}
/>
);
})}
</div>
))}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
export { ResultList, type ResultListProps } from "./ResultList.js";
export { ActionPage, type ActionPageProps } from "./ActionPage.js";
export { useCommandBar, type CommandBarControls, type UseCommandBarOptions } from "./useCommandBar.js";
export * from "./model.js";
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import type { Command } from "../../commands.js";
import type { Session } from "../../types.js";
import {
barGroups,
barRows,
commandBarCandidates,
dedupeCommands,
directoryBarCandidates,
directoryCandidate,
highlightSegments,
orderSessionsForBar,
parseBarQuery,
sessionActionCandidates,
sessionBarCandidates,
sessionCandidate,
} from "./model.js";
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/me/dev/api",
command: "bash",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
function command(over: Partial<Command> = {}): Command {
return {
id: "a",
label: "A command",
group: "Test",
keys: ["a"],
scope: "global",
run: () => {},
...over,
};
}
describe("empty-query ordering", () => {
it("sorts most recently focused first", () => {
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
});
it("sorts a never-focused session after every focused one, newest created first", () => {
const focused = session("focused", { last_focused_at: "2026-01-01T09:00:00Z" });
const old = session("old", { created_at: "2026-01-01T00:00:00Z" });
const fresh = session("fresh", { created_at: "2026-01-02T00:00:00Z" });
expect(orderSessionsForBar([old, fresh, focused]).map((s) => s.id)).toEqual([
"focused",
"fresh",
"old",
]);
});
it("sorts dead sessions after live ones regardless of recency", () => {
const dead = session("dead", { status: "Dead", last_focused_at: "2026-01-01T12:00:00Z" });
const live = session("live", { last_focused_at: "2026-01-01T10:00:00Z" });
expect(orderSessionsForBar([dead, live]).map((s) => s.id)).toEqual(["live", "dead"]);
});
it("treats an empty last_focused_at as never focused", () => {
const blank = session("blank", { last_focused_at: "" });
const focused = session("focused", { last_focused_at: "2026-01-01T10:00:00Z" });
expect(orderSessionsForBar([blank, focused]).map((s) => s.id)).toEqual(["focused", "blank"]);
});
});
describe("session candidates", () => {
it("carries the candidate table's fields, with the directory abbreviated", () => {
const s = session("api", {
metadata: { name: "api" },
current_cwd: "/home/me/dev/api",
command: "claude --resume",
});
expect(sessionCandidate(s, "/home/me").fields).toEqual({
name: "api",
directory: "~/dev/api",
command: "claude --resume",
tool: "claude",
});
});
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
const s = session("edit", {
metadata: {},
command: "hx src/",
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
});
const candidate = sessionCandidate(s, "/home/me");
expect(candidate.fields.tool).toBeUndefined();
expect(candidate.fields.directory).toBe("~/dev/api");
// No `metadata.name`: the sidebar's fallback is the command line.
expect(candidate.fields.name).toBe("hx src/");
});
it("builds candidates in the empty-query order", () => {
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
});
});
describe("rows and grouping", () => {
it("an empty query keeps every candidate in input order", () => {
const candidates = sessionBarCandidates([session("a"), session("b")]);
expect(barRows("", candidates).map((r) => r.score)).toEqual([0, 0]);
});
it("groups by kind without reordering the ranking", () => {
const rows = barRows("kill", [
...sessionBarCandidates([session("killer-app", { metadata: { name: "killer-app" } })]),
...commandBarCandidates([command({ label: "Kill selected session" })]),
]);
const groups = barGroups(rows);
expect(groups.map((g) => g.title)).toEqual(["Sessions", "Commands"]);
expect(groups.flatMap((g) => g.results)).toEqual(rows);
});
});
describe("parsing the query line", () => {
it("reads a sigil only in first position", () => {
expect(parseBarQuery(">help")).toEqual({ sigil: ">", query: "help" });
expect(parseBarQuery("~dev")).toEqual({ sigil: "~", query: "dev" });
});
it("leaves a sigil character elsewhere in the query literal", () => {
expect(parseBarQuery("~dev/~scratch")).toEqual({ sigil: "~", query: "dev/~scratch" });
expect(parseBarQuery("a>b")).toEqual({ sigil: null, query: "a>b" });
});
it("returns to the mixed list when the sigil is backspaced away", () => {
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
});
});
describe("command candidates", () => {
it("dedupes by action, not by chord", () => {
const run = () => {};
const cmds = [
command({ id: "one", label: "Do it", keys: ["j"], run }),
command({ id: "two", label: "Do it", keys: ["down"], run }),
];
expect(commandBarCandidates(cmds)).toHaveLength(1);
expect(dedupeCommands(cmds)).toHaveLength(1);
});
it("matches on the label only, never on the chord", () => {
const candidates = commandBarCandidates([command({ label: "Show help", keys: ["ctrl-q"] })]);
expect(barRows("ctrlq", candidates)).toEqual([]);
expect(barRows("show", candidates)).toHaveLength(1);
});
});
describe("directory candidates", () => {
it("abbreviates the path it shows and keeps the one it spawns in", () => {
const candidate = directoryCandidate("/home/me/dev/api", "/home/me");
expect(candidate.fields.path).toBe("~/dev/api");
expect(candidate.path).toBe("/home/me/dev/api");
});
it("expands a typed `~` and dedupes by the directory spawned in", () => {
const candidates = directoryBarCandidates(
["~/dev/api", "/home/me/dev/api", "", "/srv/x"],
"/home/me",
);
expect(candidates.map((c) => c.path)).toEqual(["/home/me/dev/api", "/srv/x"]);
});
});
describe("the action page's candidates", () => {
it("offers no action beyond the three", () => {
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
"kill",
"rename",
"spawn-similar",
]);
});
});
describe("highlight segments", () => {
it("splits the text on the matcher's ranges", () => {
expect(highlightSegments("web-search", [[0, 1], [4, 5]])).toEqual([
{ text: "w", hit: true },
{ text: "eb-", hit: false },
{ text: "s", hit: true },
{ text: "earch", hit: false },
]);
});
});
+366
View File
@@ -0,0 +1,366 @@
// The command bar's pure model (docs/COMMAND-BAR.md), lifted from
// ../../commandBar.ts for the React port (#247): what a candidate is, the
// order an empty query lists them in, how a query line splits into a sigil and
// a query, and how a ranked list groups for rendering.
//
// Nothing here touches the DOM or the network apart from `loadBarDirectories`,
// which is the one place the `~` list comes from.
import { listDir, projects as fetchProjects, recentDirs } from "../../api.js";
import type { Command } from "../../commands.js";
import { splitPath } from "../../paths.js";
import { splitCommand } from "../../toolIcon.js";
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "../../types.js";
import {
matchAndRank,
type CandidateKind,
type CommandCandidate,
type DirectoryCandidate,
type MatchRange,
type MatchResult,
type SessionCandidate,
} from "../../utils/matcher.js";
/** A matcher candidate that remembers what it came from, so a result can be
* acted on without a second lookup. The extra property is invisible to the
* matcher, which only ever reads `kind` and `fields`. */
export type SessionBarCandidate = SessionCandidate & { session: Session };
export type CommandBarCandidate = CommandCandidate & { command: Command };
/** `fields.path` is the abbreviated path the operator reads and the matcher
* ranks; `path` is the one a spawn is made in. */
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
/** One row of a session's action page. It is a `command` to the matcher — a
* single `label` field, ranked exactly like a registry command — and carries
* the action it stands for instead of a registry entry. */
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
export type BarCandidate =
| SessionBarCandidate
| CommandBarCandidate
| ActionBarCandidate
| DirectoryBarCandidate;
export type BarResult = MatchResult<BarCandidate>;
/** One kind's slice of the ranked list, in rank order. */
export interface BarGroup<C extends BarCandidate = BarCandidate> {
kind: CandidateKind;
title: string;
results: MatchResult<C>[];
}
const KIND_TITLES: Record<CandidateKind, string> = {
session: "Sessions",
command: "Commands",
directory: "Directories",
};
// ---- the sigils ----
export type BarSigil = ">" | "~";
export interface BarQuery {
/** The sigil in force, or null for the mixed list. */
sigil: BarSigil | null;
/** What the matcher sees: everything after a sigil, or the whole query. */
query: string;
}
export const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
mixed: "Go to a session…",
">": "Run a command…",
"~": "Spawn in a directory…",
};
/** Split a raw query line into its sigil and the query proper.
*
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
* literal query character, which is what a directory query with a second `~`
* in it depends on. Backspacing past a sigil needs no special case either —
* the first character goes and the query is a mixed one again. */
export function parseBarQuery(raw: string): BarQuery {
const first = raw[0];
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
return { sigil: null, query: raw };
}
// ---- the session action page ----
/** The actions a session's action page offers, and no others: the bar picks
* one and hands off to the flow that already implements it
* (docs/COMMAND-BAR.md, "Drill-in"). */
export type SessionActionId = "kill" | "rename" | "spawn-similar";
export interface SessionAction {
id: SessionActionId;
label: string;
}
export const SESSION_ACTIONS: readonly SessionAction[] = [
{ id: "kill", label: "Kill session" },
{ id: "rename", label: "Rename session" },
{ id: "spawn-similar", label: "Spawn similar" },
];
/** The action page's candidates, in the order above — which is the order an
* empty query lists them in, since the matcher keeps input order. */
export function sessionActionCandidates(): ActionBarCandidate[] {
return SESSION_ACTIONS.map((action) => ({
kind: "command",
action,
fields: { label: action.label },
}));
}
/** Narrow a `command` candidate to an action row. Both share the matcher's
* `command` kind, so what separates them is what they carry. */
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
return candidate.kind === "command" && "action" in candidate;
}
// ---- candidates ----
function focusedAt(s: Session): number | null {
const stamp = s.last_focused_at;
if (typeof stamp !== "string" || stamp === "") return null;
const ms = Date.parse(stamp);
return Number.isNaN(ms) ? null : ms;
}
function createdAt(s: Session): number {
const ms = Date.parse(s.created_at ?? "");
return Number.isNaN(ms) ? 0 : ms;
}
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
* focused first; a session that has never been focused sorts after every
* session that has, most recently created first among those; and a dead
* session sorts after every live one regardless of recency, because a session
* you cannot attach to is not where you just were.
*
* This is also the input order for a non-empty query, which is what the
* matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0;
const deadB = b.status === "Dead" ? 1 : 0;
if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a);
const focusB = focusedAt(b);
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
return createdAt(b) - createdAt(a);
});
}
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
* table names, with the directory abbreviated the way the sidebar shows it. */
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
const treePath = sessionTreePath(s);
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
const tool = splitCommand(s.command).tool ?? undefined;
return {
kind: "session",
session: s,
fields: {
name: sessionName(s),
...(directory ? { directory } : {}),
...(s.command ? { command: s.command } : {}),
...(tool ? { tool } : {}),
},
};
}
/** Every session as a candidate, in the empty-query order. */
export function sessionBarCandidates(
sessions: readonly Session[],
homeDir?: string,
): SessionBarCandidate[] {
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
}
/** Drop the commands that would list twice. Several chords can be bound to one
* action — the registry already carries its aliases on one entry, but nothing
* stops two entries running the same handler, and listed naively that is two
* identical rows. The dedupe is by **action**, never by chord: the chord is
* displayed, so a command with two of them is still one row. */
export function dedupeCommands(commands: readonly Command[]): Command[] {
const seenRun = new Set<Command["run"]>();
const seenLabel = new Set<string>();
const kept: Command[] = [];
for (const command of commands) {
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
seenRun.add(command.run);
seenLabel.add(command.label);
kept.push(command);
}
return kept;
}
/** Every bound command as a candidate, in registry order. Only the label is a
* field: the chord is displayed beside the row, never matched, because
* matching it would make `>` results depend on a user's rebinds. */
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
return dedupeCommands(commands).map((command) => ({
kind: "command",
command,
fields: { label: command.label },
}));
}
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
* a path the operator typed or `/api/dirs` completed. */
export function expandHome(path: string, homeDir?: string): string {
if (!homeDir) return path;
if (path === "~") return homeDir;
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
}
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
const absolute = expandHome(path, homeDir);
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
}
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
* first mention of each — recent dirs (newest first) ahead of the project
* list, which is the empty-query order the contract asks for. */
export function directoryBarCandidates(
paths: readonly string[],
homeDir?: string,
): DirectoryBarCandidate[] {
const seen = new Set<string>();
const out: DirectoryBarCandidate[] = [];
for (const path of paths) {
if (path === "") continue;
const candidate = directoryCandidate(path, homeDir);
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
out.push(candidate);
}
return out;
}
/** The directory list the bar starts from: what the spawn form already knows —
* `GET /api/recent-dirs` and the project list — either alone if the other
* fails. */
export async function loadBarDirectories(): Promise<string[]> {
const [recents, discovered] = await Promise.all([
recentDirs()
.then((r) => r.dirs.map((d) => d.cwd))
.catch(() => [] as string[]),
fetchProjects()
.then((list) => list.map((p) => p.path))
.catch(() => [] as string[]),
]);
return [...recents, ...discovered];
}
/** One directory's children as candidate paths, for filling the `~` list in as
* a path is typed. The directory being listed comes from the query itself. */
export function dirEntryPaths(dir: string, entries: readonly string[]): string[] {
return entries.map((entry) => `${dir}${entry}`);
}
/** The directory a `~` query names, or `""` when it names none yet. */
export function probeDir(query: string): string {
return splitPath(query).dir;
}
/** `GET /api/dirs`, in the shape the bar wants it. */
export function listDirEntries(dir: string): Promise<string[]> {
return listDir(dir).then((listing) => listing.entries);
}
// ---- grouping ----
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: a group holds its matches in rank order,
* and groups appear in the order their best-scoring member does — so the first
* row under the first header is the globally best result. */
export function barGroups<C extends BarCandidate>(
results: readonly MatchResult<C>[],
): BarGroup<C>[] {
const groups: BarGroup<C>[] = [];
for (const result of results) {
const kind = result.candidate.kind;
let group = groups.find((g) => g.kind === kind);
if (!group) {
group = { kind, title: KIND_TITLES[kind], results: [] };
groups.push(group);
}
group.results.push(result);
}
return groups;
}
/** The rows a query produces, flat and in rank order — what the highlight walks
* and what Enter acts on. Headers are labels, not rows, so they are never in
* this list. */
export function barRows<C extends BarCandidate>(
query: string,
candidates: readonly C[],
): MatchResult<C>[] {
return matchAndRank(query, candidates);
}
// ---- row text ----
/** The name line of a row: the session's name, the command's label, or the
* directory's path. */
export function rowName(candidate: BarCandidate): string {
switch (candidate.kind) {
case "session":
return candidate.fields.name;
case "command":
return candidate.fields.label;
case "directory":
return candidate.fields.path;
}
}
/** The secondary line under a session row: the field the match was found in
* when that was not the name, so a session found through its directory says
* so. Only sessions have one — a command shows keycaps instead, and a
* directory's path is already the name. */
export function metaText(result: BarResult): string | undefined {
if (result.candidate.kind !== "session") return undefined;
const fields = result.candidate.fields;
switch (result.field) {
case "directory":
return fields.directory;
case "command":
return fields.command;
case "tool":
return fields.tool;
default:
return fields.directory;
}
}
/** One run of a row's text, flagged when the matcher matched inside it. The
* React rendering of `renderHighlighted`: ranges come from the matcher as
* `[start, end)` character pairs over the field that won. */
export interface HighlightSegment {
text: string;
hit: boolean;
}
export function highlightSegments(
text: string,
ranges: readonly MatchRange[],
): HighlightSegment[] {
const out: HighlightSegment[] = [];
let cursor = 0;
for (const [start, end] of ranges) {
if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false });
out.push({ text: text.slice(start, end), hit: true });
cursor = end;
}
if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false });
return out;
}
export { sessionName };
export type { CandidateKind, MatchRange, MatchResult };
@@ -0,0 +1,76 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OPEN_COMMAND_BAR_EVENT } from "../../uiEvents.js";
import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js";
let controls: ReturnType<typeof useCommandBar>;
function Probe(opts: UseCommandBarOptions) {
controls = useCommandBar(opts);
return <div>{controls.open ? "open" : "closed"}</div>;
}
function ctrlK(target: EventTarget = window): void {
act(() => {
target.dispatchEvent(
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }),
);
});
}
afterEach(cleanup);
describe("useCommandBar", () => {
it("opens on ctrl-k, from the capture phase so an attached xterm never sees it", () => {
const view = render(<Probe />);
ctrlK();
expect(view.container.textContent).toBe("open");
});
it("stays deaf while the bar is already up — its own input owns the keys", () => {
const onOpen = vi.fn();
render(<Probe onOpen={onOpen} />);
ctrlK();
ctrlK();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("leaves a form field's own Ctrl-K alone", () => {
const field = document.createElement("input");
document.body.appendChild(field);
const view = render(<Probe />);
ctrlK(field);
expect(view.container.textContent).toBe("closed");
field.remove();
});
it("opens from the sidebar's search field, the touch entry point", () => {
const view = render(<Probe />);
act(() => {
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
});
expect(view.container.textContent).toBe("open");
});
it("runs the caller's hook before opening, so a preview is discarded first", () => {
const onOpen = vi.fn();
render(<Probe onOpen={onOpen} />);
ctrlK();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("closes on demand", () => {
const view = render(<Probe />);
ctrlK();
act(() => controls.closeBar());
expect(view.container.textContent).toBe("closed");
});
it("binds nothing while disabled — another overlay owns the keyboard", () => {
const view = render(<Probe enabled={false} />);
ctrlK();
expect(view.container.textContent).toBe("closed");
});
});
@@ -0,0 +1,65 @@
// Opening and closing the bar (docs/COMMAND-BAR.md, "Opening"). `ctrl-k` is a
// registry command like any other, so the `?` overlay lists it without a
// second list; the touch entry point — the sidebar's search field — raises
// `OPEN_COMMAND_BAR_EVENT` and lands on the same open.
import { useCallback, useEffect, useState } from "react";
import { OPEN_COMMAND_BAR_EVENT } from "../../uiEvents.js";
import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js";
export interface UseCommandBarOptions {
/** Run before the bar opens — the shell discards a preview here, since the
* bar is not previewed into (docs/PREVIEW.md). */
onOpen?(): void;
/** Off while another overlay owns the keyboard (the restart overlay, the
* help dialog). Defaults to on. */
enabled?: boolean;
}
export interface CommandBarControls {
open: boolean;
openBar(): void;
closeBar(): void;
}
export function useCommandBar(opts: UseCommandBarOptions = {}): CommandBarControls {
const { onOpen, enabled = true } = opts;
const [open, setOpen] = useState(false);
const openBar = useCallback(() => {
onOpen?.();
setOpen(true);
}, [onOpen]);
const closeBar = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!enabled) return;
const keys = new CommandSet(defineCommands({ "bar.open": openBar }));
// Capture phase: an attached xterm swallows every keystroke it is given, so
// the bar's binding has to be seen ahead of the terminal.
const onKeydown = (e: KeyboardEvent) => {
// The bar owns the keyboard while it is up; its own input handles the keys.
if (open) return;
// The pane's hidden textarea is not a typing target for the bar: ctrl-k
// is seen ahead of xterm and opens the bar. A real form field — the spawn
// form's inputs — keeps its own Ctrl+K.
if (isBarTypingTarget(e.target)) return;
keys.handle(e);
};
const onTouchEntry = () => {
if (!open) openBar();
};
window.addEventListener("keydown", onKeydown, true);
document.addEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
return () => {
window.removeEventListener("keydown", onKeydown, true);
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
};
}, [enabled, open, openBar]);
return { open, openBar, closeBar };
}
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "./ContextMenu.js";
afterEach(() => {
cleanup();
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
}
describe("ContextMenu (#125)", () => {
it("renders one item per entry, in order", () => {
render(
<ContextMenu
x={10}
y={20}
items={[
{ label: "Rename…", onSelect: () => {} },
{ label: "Kill session", onSelect: () => {}, danger: true },
]}
onClose={vi.fn()}
/>,
);
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();
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: chosen }]} onClose={onClose} />);
fireEvent.click(screen.getByRole("menuitem"));
expect(chosen).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("closes on Escape and on a press outside it", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent(document.body, new Event("pointerdown", { bubbles: true }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("stays open for a press inside itself", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
const menu = document.querySelector(".context-menu") as HTMLElement;
fireEvent(menu, new Event("pointerdown", { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
});
it("closes on scroll and on resize", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.scroll(window);
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.resize(window);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
+86
View File
@@ -0,0 +1,86 @@
// React port of ../../contextMenu.ts (#125), the general-purpose version —
// unlike the sidebar's own menu, this one also closes on scroll/resize, since
// a menu anchored to coordinates goes stale the moment the page moves under it.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
onSelect(): void;
/** Renders the entry in the destructive style. */
danger?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
// Nudge back inside the viewport once the menu has a measurable size.
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) });
}, [x, y]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onPointerDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKeyDown, true);
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onClose, true);
window.addEventListener("resize", onClose);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onClose, true);
window.removeEventListener("resize", onClose);
};
}, [onClose]);
useEffect(() => {
(ref.current?.firstElementChild as HTMLElement | null)?.focus();
}, []);
return createPortal(
<div
ref={ref}
role="menu"
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
style={{ left: pos.left, top: pos.top }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`context-menu-item block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "is-danger text-danger" : "text-ink"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
+1
View File
@@ -0,0 +1 @@
export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js";
@@ -1,8 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js";
import type { Scheduler } from "./events.js";
import type { DaemonInfo } from "./types.js";
import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.js";
import type { DaemonInfo } from "../../types.js";
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
return {
@@ -14,14 +13,18 @@ function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:0
};
}
/** Scheduler whose timers only fire when the test says so. */
function manual(): Scheduler & { run(): void; pending(): number } {
/** Scheduler whose timers only fire when the test says so, recording the
* delay each was scheduled with. */
function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } {
let next = 1;
const timers = new Map<number, () => void>();
const delays: number[] = [];
return {
setTimer(fn) {
delays,
setTimer(fn, ms) {
const h = next++;
timers.set(h, fn);
delays.push(ms);
return h;
},
clearTimer(h) {
@@ -73,21 +76,22 @@ describe("formatRelease", () => {
});
});
describe("DaemonLink", () => {
it("starts online once the first probe lands", async () => {
const link = new DaemonLink({
describe("daemon link store", () => {
it("starts in the connecting state until the first probe lands", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: manual(),
reload: () => {},
});
await link.start();
expect(link.status().state).toBe("online");
expect(link.status().build?.commit).toBe("aaa");
expect(store.getState().state).toBe("connecting");
await store.getState().probe();
expect(store.getState().state).toBe("online");
expect(store.getState().build?.commit).toBe("aaa");
});
it("holds the modal back during the grace period, then blocks", async () => {
const sched = manual();
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
@@ -95,19 +99,19 @@ describe("DaemonLink", () => {
reload: () => {},
graceMs: 1000,
});
link.noteDisconnected();
store.getState().noteDisconnected();
// Still inside the grace window: the dot goes amber, nothing blocks.
expect(link.status().state).toBe("reconnecting");
expect(store.getState().state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
expect(store.getState().state).toBe("down");
});
it("unlocks without reloading when the same build comes back", async () => {
const sched = manual();
const reload = vi.fn();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
@@ -115,70 +119,45 @@ describe("DaemonLink", () => {
scheduler: sched,
reload,
});
await link.start();
await store.getState().probe();
reply = null;
link.noteDisconnected();
store.getState().noteDisconnected();
sched.run(); // grace expires → blocked, poll scheduled
await Promise.resolve();
expect(link.status().state).toBe("down");
expect(store.getState().state).toBe("down");
// Same commit, new process: a restart, not an upgrade — the page stays.
reply = info("aaa", "i2");
await link.probe();
await store.getState().probe();
expect(reload).not.toHaveBeenCalled();
expect(link.status().state).toBe("online");
});
it("rechecks immediately when the browser returns from the background", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
reply = info("aaa", "i1");
link.recover();
await Promise.resolve();
expect(link.status().state).toBe("online");
expect(store.getState().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual();
const reload = vi.fn();
let reply = info("aaa", "i1");
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => reply,
scheduler: sched,
reload,
});
await link.start();
await store.getState().probe();
link.noteDisconnected();
store.getState().noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
await store.getState().probe();
expect(reload).toHaveBeenCalledTimes(1);
// Left blocked deliberately: the reload is what ends this page's life, and
// flashing an unlocked UI in between would let the user act on stale code.
expect(link.status().state).toBe("updating");
expect(store.getState().state).toBe("updating");
});
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
const sched = manual();
let attempts = 0;
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => {
attempts += 1;
throw new Error("down");
@@ -186,7 +165,7 @@ describe("DaemonLink", () => {
scheduler: sched,
reload: () => {},
});
link.noteDisconnected();
store.getState().noteDisconnected();
sched.run(); // grace → first poll
await Promise.resolve();
await Promise.resolve();
@@ -196,23 +175,41 @@ describe("DaemonLink", () => {
await Promise.resolve();
await Promise.resolve();
expect(attempts).toBeGreaterThan(first);
link.stop();
store.getState().stop();
});
it("notifies subscribers on every state change", async () => {
const sched = manual();
const seen: string[] = [];
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: sched,
reload: () => {},
});
link.subscribe((s) => seen.push(s.state));
await link.start();
link.noteDisconnected();
store.subscribe((s) => seen.push(s.state));
await store.getState().probe();
store.getState().noteDisconnected();
sched.run();
await Promise.resolve();
expect(seen).toContain("reconnecting");
expect(seen[0]).toBe("connecting");
});
it("schedules the grace timer and poll at the documented defaults", async () => {
const sched = manual();
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
expect(GRACE_MS).toBe(1200);
expect(POLL_MS).toBe(700);
store.getState().noteDisconnected();
expect(sched.delays).toEqual([GRACE_MS]);
sched.run(); // grace fires → schedules the first poll
await Promise.resolve();
await Promise.resolve();
expect(sched.delays).toEqual([GRACE_MS, POLL_MS]);
});
});
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore } from "./DaemonLink.js";
import { DaemonLink } from "./DaemonLink.js";
afterEach(() => cleanup());
describe("DaemonLink", () => {
it("probes once on mount, regardless of the initial connected value", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
render(<DaemonLink store={store} connected={false} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
});
it("does not treat the initial connected value as a transition", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
// Only the mount probe ran — no extra noteConnected() probe on top of it.
expect(store.getState().state).toBe("online");
});
it("forwards a later connected drop to noteDisconnected", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
});
it("forwards a later connected recovery to noteConnected", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
// The socket dropped: noteDisconnected() moves off "online" so the coming
// recovery isn't a no-op.
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
fetchInfo.mockClear();
rerender(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
expect(store.getState().state).toBe("online");
});
it("stops the store's polling on unmount", () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
});
const stop = vi.spyOn(store.getState(), "stop");
const { unmount } = render(<DaemonLink store={store} connected={false} />);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
});
});
+234
View File
@@ -0,0 +1,234 @@
// React port of ../../daemonLink.ts. The link to the daemon behind this page:
// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx)
// renders what this reports. See docs/RESTART.md for the restart contract.
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { getDaemonInfo } from "../../api.js";
import type { BuildInfo, DaemonInfo } from "../../types.js";
/**
* - `connecting` — first probe hasn't landed yet.
* - `online` — the daemon answered and is the same process, or a resolved new one.
* - `reconnecting` — the socket dropped, but within the grace window; a blip
* shouldn't throw a modal in the user's face.
* - `down` — unreachable past the grace window: the overlay blocks the UI and
* the poll runs until it answers.
* - `updating` — it answered on a different commit; the page is reloading and
* stays blocked until it does.
*/
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
/** What happened between two `/api/info` replies. */
export type RestartKind = "same" | "restarted" | "upgraded";
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
* to ride out a wifi hiccup, short enough that a real restart is visible. */
export const GRACE_MS = 1200;
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
export const POLL_MS = 700;
/**
* Compare two daemon replies.
*
* The commit decides first: a different one means new code, which for a browser
* means new assets. The per-process `instance` catches the rest — a restart of
* the same build, which needs no reload. A daemon built outside a git checkout
* reports an empty commit, so an empty-vs-empty pair never counts as an
* upgrade; the instance still reports the restart.
*/
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
const a = before.build?.commit ?? "";
const b = after.build?.commit ?? "";
if (a && b && a !== b) return "upgraded";
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
return "same";
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
* the build carried uncommitted changes. Empty builds say so rather than
* rendering a blank tooltip. */
export function formatRelease(build: BuildInfo | undefined): string {
if (!build?.commit) return "unknown release";
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
const parts = [build.commit];
if (day) parts.push(`· ${day}`);
if (build.dirty) parts.push("(dirty)");
return parts.join(" ");
}
/** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
export interface DaemonLinkState {
state: LinkState;
/** The release the daemon last reported. Undefined until the first probe,
* and on a daemon too old to carry one. */
build?: BuildInfo;
/** Hostname the daemon reports, for the indicator's tooltip. */
hostname?: string;
/** How many probes have failed in the current outage — the overlay counts
* attempts so a long restart doesn't look frozen. */
attempts: number;
}
export interface DaemonLinkActions {
/** One `/api/info` round-trip. Resolves `true` when the daemon answered. */
probe(): Promise<boolean>;
/** The events socket dropped. Starts the grace timer and the poll. */
noteDisconnected(): void;
/** The events socket came back. It carries no identity of its own, so the
* state only clears once a probe confirms *which* daemon answered — a
* reconnect to an upgraded daemon must reload, not resume. */
noteConnected(): void;
/** Stop polling. */
stop(): void;
}
export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions;
export interface DaemonLinkOptions {
/** Injectable for tests; defaults to `GET /api/info`. */
fetchInfo?: () => Promise<DaemonInfo>;
scheduler?: Scheduler;
/** What an upgrade does. Injectable so tests don't navigate. */
reload?: () => void;
graceMs?: number;
pollMs?: number;
}
/** Builds an isolated daemon-link store — same shape as
* {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook
* and a plain object (`getState`/`subscribe`), so it drives the overlay
* whether or not React is the one calling `noteConnected`/`noteDisconnected`. */
export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) {
const fetchInfo = opts.fetchInfo ?? getDaemonInfo;
const scheduler = opts.scheduler ?? browserScheduler;
const reload = opts.reload ?? (() => window.location.reload());
const graceMs = opts.graceMs ?? GRACE_MS;
const pollMs = opts.pollMs ?? POLL_MS;
let identity: DaemonInfo | null = null;
let graceTimer: number | null = null;
let pollTimer: number | null = null;
return create<DaemonLinkStore>((set, get) => {
function clearTimers(): void {
if (graceTimer !== null) {
scheduler.clearTimer(graceTimer);
graceTimer = null;
}
if (pollTimer !== null) {
scheduler.clearTimer(pollTimer);
pollTimer = null;
}
}
async function probe(): Promise<boolean> {
let info: DaemonInfo;
try {
info = await fetchInfo();
} catch {
set((s) => ({ attempts: s.attempts + 1 }));
return false;
}
const before = identity;
identity = info;
if (before && compareIdentity(before, info) === "upgraded") {
// Stay blocked through the reload: an unlocked frame here would let the
// user drive new server code from the bundle it just replaced.
set({ state: "updating", build: info.build, hostname: info.hostname });
reload();
return true;
}
clearTimers();
set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 });
return true;
}
async function poll(): Promise<void> {
const state = get().state;
if (state === "online" || state === "updating") return;
const ok = await probe();
if (ok || pollTimer !== null) return;
pollTimer = scheduler.setTimer(() => {
pollTimer = null;
void poll();
}, pollMs);
}
return {
state: "connecting",
build: undefined,
hostname: undefined,
attempts: 0,
probe,
noteDisconnected() {
const state = get().state;
if (state === "down" || state === "updating") return;
set({ state: "reconnecting", attempts: 0 });
if (graceTimer === null) {
graceTimer = scheduler.setTimer(() => {
graceTimer = null;
if (get().state === "reconnecting") set({ state: "down" });
void poll();
}, graceMs);
}
},
noteConnected() {
const state = get().state;
if (state === "online" || state === "updating") return;
void probe();
},
stop: clearTimers,
};
});
}
export type DaemonLinkStoreApi = ReturnType<typeof createDaemonLinkStore>;
export interface DaemonLinkProps {
/** The link store this instance drives — `RestartOverlay` (and, later, the
* sidebar's status indicator) read the same one. */
store: DaemonLinkStoreApi;
/** The catalog store's `connected` field (docs/RESTART.md): the events
* socket dying is the earliest signal a restart happened, ahead of any
* poll. Pass `useStore(catalogStore, (s) => s.connected)`. */
connected: boolean;
}
/**
* Renders nothing — the controller that wires a `DaemonLinkStore` to the
* catalog's socket state. Mount once near the app root; `RestartOverlay`
* reads the store it drives.
*/
export function DaemonLink({ store, connected }: DaemonLinkProps): null {
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
void store.getState().probe();
return;
}
if (connected) store.getState().noteConnected();
else store.getState().noteDisconnected();
}, [store, connected]);
useEffect(() => () => store.getState().stop(), [store]);
return null;
}
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js";
import { RestartOverlay } from "./RestartOverlay.js";
afterEach(() => cleanup());
function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi {
const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } });
store.setState({ state });
return store;
}
describe("RestartOverlay", () => {
it("renders nothing while connecting, online, or reconnecting", () => {
for (const state of ["connecting", "online", "reconnecting"] as const) {
const { unmount } = render(<RestartOverlay store={storeAt(state)} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
unmount();
}
});
it("blocks with a restarting message when down", () => {
render(<RestartOverlay store={storeAt("down")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro is restarting");
expect(dialog.textContent).toContain("Waiting for the daemon to come back…");
});
it("counts attempts once more than one probe has failed", () => {
const store = storeAt("down");
store.setState({ attempts: 3 });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)");
});
it("says it is reloading when updating", () => {
render(<RestartOverlay store={storeAt("updating")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro was updated");
expect(dialog.textContent).toContain("Reloading this page onto the new release…");
});
it("names the release from the store's build", () => {
const store = storeAt("down");
store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02");
});
it("clears the moment the store reports the link is back", () => {
const store = storeAt("down");
const { rerender } = render(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).not.toBeNull();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("swallows keyboard shortcuts while blocking, but not once cleared", () => {
const store = storeAt("down");
const handler = vi.fn();
window.addEventListener("keydown", handler);
const { rerender } = render(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).not.toHaveBeenCalled();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener("keydown", handler);
});
});
@@ -0,0 +1,62 @@
// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that
// went away — see docs/RESTART.md.
//
// Rendered while the link is `down` or `updating`, gone the moment it is
// back. It owns no polling of its own: `DaemonLink` polls, this only renders
// what the store reports.
import { useEffect } from "react";
import { useStore } from "zustand";
import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js";
export interface RestartOverlayProps {
/** The same store a sibling `DaemonLink` drives. */
store: DaemonLinkStoreApi;
}
/** The poll runs while this is up; saying how many tries have gone by is what
* separates "still restarting" from "wedged" for someone staring at it. */
function attemptText(attempts: number): string {
if (attempts <= 1) return "Waiting for the daemon to come back…";
return `Waiting for the daemon to come back… (${attempts} attempts)`;
}
function swallow(e: KeyboardEvent): void {
e.stopImmediatePropagation();
}
export function RestartOverlay({ store }: RestartOverlayProps) {
const state = useStore(store, (s) => s.state);
const build = useStore(store, (s) => s.build);
const attempts = useStore(store, (s) => s.attempts);
const blocking = state === "down" || state === "updating";
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
// does not stop them: `n` would still open a spawn form against a daemon
// that is not there. Swallow keys in the capture phase until the link is
// back, without `preventDefault` so the browser's own chords still work.
useEffect(() => {
if (!blocking) return;
window.addEventListener("keydown", swallow, true);
return () => window.removeEventListener("keydown", swallow, true);
}, [blocking]);
if (!blocking) return null;
const updating = state === "updating";
return (
<div className="modal-backdrop restart-backdrop">
<div className="modal restart-modal" role="alertdialog" aria-modal="true" aria-labelledby="restart-title">
<div className="restart-spinner" aria-hidden="true" />
<h2 id="restart-title">{updating ? "Maestro was updated" : "Maestro is restarting"}</h2>
<p className="restart-detail">
{updating ? "Reloading this page onto the new release…" : attemptText(attempts)}
</p>
<p className="restart-release">{formatRelease(build)}</p>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { keyCaps } from "../../commands.js";
import { groupedCommands } from "./model.js";
import { HelpOverlay } from "./HelpOverlay.js";
afterEach(() => {
cleanup();
});
function press(key: string, init: KeyboardEventInit = {}): void {
fireEvent.keyDown(window, { key, ...init });
}
describe("HelpOverlay", () => {
it("opens on ? and closes on a second ?", () => {
render(<HelpOverlay />);
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).not.toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
render(<HelpOverlay />);
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "?", shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { ctrlKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
input.remove();
});
it("closes on Escape, on the close button, and on a click outside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
press("Escape");
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("dialog"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("stays open for a click inside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
fireEvent.click(document.querySelector(".help-panel") as HTMLElement);
expect(screen.queryByRole("dialog")).not.toBeNull();
});
it("renders exactly the commands the registry has bound, grouped", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(
[...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
).toEqual(groups.flatMap((g) => g.items.map((c) => c.id)));
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
expect(
[...document.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
),
).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
});
});
@@ -0,0 +1,84 @@
// React port of ../../help.ts: the `?` overlay renders the command registry
// (commands.ts) directly — the same list that dispatches the keys — so a
// binding can never be documented as one thing and do another.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { TOGGLE_HELP_EVENT } from "../../uiEvents.js";
import { groupedCommands } from "./model.js";
export function HelpOverlay() {
const [open, setOpen] = useState(false);
useEffect(() => {
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => setOpen((o) => !o) }));
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => setOpen(false) }));
const onKeydown = (e: KeyboardEvent) => {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
};
const onToggle = () => setOpen((o) => !o);
window.addEventListener("keydown", onKeydown);
document.addEventListener(TOGGLE_HELP_EVENT, onToggle);
return () => {
window.removeEventListener("keydown", onKeydown);
document.removeEventListener(TOGGLE_HELP_EVENT, onToggle);
};
}, [open]);
if (!open) return null;
const close = () => setOpen(false);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
className="help-overlay fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div className="help-panel max-h-[80vh] w-full max-w-[560px] overflow-auto rounded-r-3 border border-border bg-surface p-sp-4 shadow-2">
<header className="help-head flex items-center justify-between">
<h2 className="help-title m-0 text-fs-15 font-semibold text-ink">Keyboard shortcuts</h2>
<button
type="button"
aria-label="Close"
className="help-close rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
onClick={close}
>
✕
</button>
</header>
{groupedCommands().map((group) => (
<section key={group.title} className="help-group mt-sp-3">
<h3 className="help-group-title text-fs-12 font-semibold text-muted">{group.title}</h3>
<dl className="help-list mt-sp-1">
{group.items.map((cmd) => (
<div
key={cmd.id}
className="help-row flex items-center justify-between gap-sp-3 py-sp-1"
data-command-id={cmd.id}
>
<dt className="help-keys m-0 flex gap-sp-1">
{keyCaps(cmd.keys[0]).map((cap, i) => (
<kbd key={i} className="rounded-r-1 border border-border px-sp-1 text-fs-11">
{cap}
</kbd>
))}
</dt>
<dd className="help-desc m-0 text-fs-12 text-ink">{cmd.label}</dd>
</div>
))}
</dl>
</section>
))}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { KillDialog } from "./KillDialog.js";
afterEach(() => {
cleanup();
});
describe("KillDialog", () => {
it("focuses the kill button, so Enter confirms", () => {
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" }));
});
it("focuses the force button too", () => {
render(
<KillDialog
name="alpha"
blockers={null}
force
reason="worktree is dirty."
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" }));
});
it("names the blockers in the confirm body", () => {
render(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 2, untracked_files: 0, unpushed_commits: 0 }}
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByText(/will be refused unless it is forced/)).not.toBeNull();
});
it("cancels on Cancel, on Escape and on a backdrop click", () => {
const onCancel = vi.fn();
const first = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
first.unmount();
const second = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(2);
second.unmount();
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("dialog"));
expect(onCancel).toHaveBeenCalledTimes(3);
});
it("disables the button while pending and shows a rejection inline, so the press can be retried", async () => {
let reject: (e: Error) => void = () => {};
const onConfirm = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
render(<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(onConfirm).toHaveBeenCalledOnce();
expect(kill.disabled).toBe(true);
reject(new Error("daemon unreachable"));
await waitFor(() => expect(kill.disabled).toBe(false));
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
});
it("resets pending when force changes, so the force button is clickable", async () => {
// Simulate the Sidebar flow: onConfirm resolves (parent switches to
// force mode via rerender), but pending was set to true before the call.
const onConfirm = vi.fn(() => Promise.resolve());
const { rerender } = render(
<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />,
);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(kill.disabled).toBe(true);
// Parent re-renders with force=true (the daemon refused the unforced kill).
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
rerender(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 1, untracked_files: 0, unpushed_commits: 0 }}
force
reason="worktree is dirty"
onConfirm={onConfirm}
onCancel={vi.fn()}
/>,
);
const forceBtn = screen.getByRole("button", { name: "Force kill" }) as HTMLButtonElement;
expect(forceBtn.disabled).toBe(false);
});
});
+100
View File
@@ -0,0 +1,100 @@
// React port of ../../killDialog.ts: the two kill prompts (#81, #160). The
// daemon refuses an unforced kill over a worktree holding unsaved work; the
// same component in `force` mode is the override that follows that refusal.
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { formatInTheWay, type InTheWay } from "../../types.js";
export interface KillDialogProps {
/** Session name, as the sidebar shows it. */
name: string;
/** What the card last saw in the way, when anything was. */
blockers: InTheWay | null;
/** Set once the daemon has refused an unforced kill: this pass forces it,
* and `reason` carries the daemon's refusal message. */
force?: boolean;
/** Message from the daemon's refusal, shown when there are no blockers to
* spell out. */
reason?: string;
/** Run the kill (or the forced kill). A rejection is rendered inline, and
* the dialog stays open so the press can be retried. */
onConfirm(): Promise<void>;
onCancel(): void;
}
export function KillDialog(props: KillDialogProps) {
const { name, blockers, force, reason, onConfirm, onCancel } = props;
const confirmRef = useRef<HTMLButtonElement>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
// The acting button takes the focus in both modes: the operator already
// asked for the kill, so Enter confirms it.
useEffect(() => confirmRef.current?.focus(), []);
// When the dialog transitions from normal → force mode, the previous
// onConfirm resolved without error (it mutated parent state and returned),
// so `pending` is still true. Reset it so the force button is clickable.
useEffect(() => setPending(false), [force]);
const handleConfirm = () => {
setPending(true);
setError(null);
void onConfirm().catch((e: unknown) => {
setPending(false);
setError(`Failed to kill: ${(e as Error).message}`);
});
};
const body = blockers
? force
? `Its worktree still has ${formatInTheWay(blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `Its worktree still has ${formatInTheWay(blockers)}. The kill will be refused unless it is forced.`
: force
? `${reason ?? ""} Forcing the kill removes the session and leaves the worktree on disk.`
: "The session and everything running in it go away. Its worktree stays on disk.";
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={`Kill ${name}`}
className="kill-dialog fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="kill-dialog-panel min-w-[280px] max-w-[420px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<h2 className="kill-dialog-title m-0 text-fs-15 font-semibold text-ink">Kill &quot;{name}&quot;?</h2>
<p className="kill-dialog-body mt-sp-2 text-fs-13 text-ink">{body}</p>
{error && <p className="kill-dialog-error mt-sp-2 text-fs-12 text-danger">{error}</p>}
<div className="kill-dialog-actions mt-sp-3 flex justify-end gap-sp-2">
<button
type="button"
className="kill-dialog-cancel rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={onCancel}
>
Cancel
</button>
<button
ref={confirmRef}
type="button"
disabled={pending}
className={`rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink disabled:opacity-60 ${
force ? "kill-dialog-force" : "kill-dialog-kill"
}`}
onClick={handleConfirm}
>
{force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}

Some files were not shown because too many files have changed in this diff Show More