Compare commits

...
361 Commits
Author SHA1 Message Date
Miguel Palhas b27dda6c6b Merge remote-tracking branch 'origin/main' into chorale
ci / web (push) Successful in 37s
ci / flake (push) Successful in 3m58s
ci / rust (push) Successful in 7m14s
2026-09-17 11:18:49 +01:00
Miguel PalhasandClaude Opus 4.6 5bdedc8d23 feat(maestro): add elicitation web UI and TUI stub
ElicitationView renders form-mode questions from the agent:
single-property string enums as a button group (the common
multiple-choice case), general schemas as a form with typed
inputs.

Wired into TranscriptRow, StructuredPane, and the model
helpers. TUI renders elicitation rows as plain text for now.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 08:32:11 +01:00
Miguel PalhasandClaude Opus 4.6 1e628789fb feat(maestro): add ACP elicitation support (backend)
Advertise `elicitation.form` in the ACP handshake and handle
`elicitation/create` requests end-to-end through the daemon.

Adds `Elicitation` transcript row kind, `pending_elicitation` to
ReducedState, the elicitation pump, persistence, and a new
`POST /api/sessions/:id/structured/elicitations/:nonce` endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 08:22:37 +01:00
Miguel Palhas 3f2473d910 feat(maestro): hide tab bar on mobile, add sidebar button to pane header
ci / web (push) Successful in 38s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m23s
On mobile only one session is focused at a time, so the tab bar is
redundant — the sidebar drawer is the canonical way to switch. A ☰
button in the pane header gives tap access without requiring a swipe.

Closes #273
2026-09-17 08:06:44 +01:00
Miguel PalhasandClaude Opus 4.6 b56a35f3ee fix(maestro): simplify tool-group test to match actual grouping
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m44s
ci / rust (push) Successful in 7m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-15 11:23:28 +01:00
Miguel Palhas b1f6014a6a Merge remote-tracking branch 'origin/main' into tritone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m3s
ci / rust (push) Successful in 7m55s
2026-09-14 11:07:43 +01:00
Miguel PalhasandClaude Opus 4.6 01db6459cb fix(maestro): block event pump until turn is settled
The adapter returns the session/prompt response before the agent
finishes working.  Late-arriving notifications raced past the turn
marker and leaked into the next turn, making each response appear
one prompt behind.

The event pump now sends a settled oneshot through the done payload
and awaits it before processing further messages.
finish_queued_acp_turn fires it after persisting the turn marker and
dispatching any queued prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:22:59 +01:00
Miguel PalhasandClaude Opus 4.6 d332c43a9f fix(maestro): handle 202 as no-body response in jsonOrError
ci / web (push) Failing after 26s
ci / flake (push) Successful in 4m8s
ci / rust (push) Successful in 7m57s
The daemon returns 202 Accepted when a structured prompt is queued.
jsonOrError only skipped res.json() for 204, so the empty 202 body
caused a JSON parse error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:13:35 +01:00
Miguel Palhas 1cc05af323 feat(maestro): add just apk recipe for the Android debug build
ci / web (push) Failing after 32s
ci / flake (push) Successful in 4m30s
ci / rust (push) Successful in 8m7s
Runs ./gradlew assembleDebug and copies app-debug.apk next to the
android/ project root.
2026-09-13 02:43:07 +01:00
Miguel Palhas 6eb048575c feat(maestro): match adaptive icon to web PWA mark
Replaces the default Android Studio placeholder icon with the same
concentric-circle mark as web/public/icon.svg, scaled into the
existing 108x108 adaptive-icon vectors.
2026-09-13 02:43:04 +01:00
Miguel Palhas 5cdc6135f5 feat(maestro): match activity rail blink/flash to web timings
Replaces the tween/Reverse pulse with keyframe animations mirroring
web/src/components/sidebar/sidebar.css exactly: a hard 1200ms on/off
blink for AwaitingInput (steps(1), no crossfade) and a 1400ms
ease-out double-flash for Error.
2026-09-13 02:43:00 +01:00
Miguel Palhas f6be34ae48 feat(maestro): bundle JetBrains Mono and match web's type scale
Bundles JetBrainsMono Regular/Medium/Bold (SIL OFL, see
android/licenses/) so terminal and code content render the same face
as the web SPA's --font-mono instead of falling back to the device's
system mono. Typography now steps through the same sizes as the web
--fs-* tokens, using the platform sans for UI chrome.
2026-09-13 02:42:55 +01:00
Miguel Palhas 4f06d1fc8f feat(maestro): translate web OKLCH tokens to Material 3 color scheme
Maps bg/surface/ink/muted/faint/accent/live/danger/warn from
web/src/styles.css to background/surface/onSurface/onSurfaceVariant/
outline/primary/tertiary/error/secondary, for both dark (default) and
light Material 3 schemes. Drops the redundant statusBarColor override
in themes.xml now that MainActivity's enableEdgeToEdge() owns it.
2026-09-13 02:42:51 +01:00
Miguel Palhas d1b8b0abc1 feat(maestro): add Android command bar (#270)
Bottom-sheet ctrl-k-style search over sessions, a `>` command
registry and `~` directories (docs/COMMAND-BAR.md), with a
long-press action menu for rename/kill/spawn-similar. v1 uses a
simple weighted substring/fuzzy matcher rather than the scored
matcher web/#146 and the TUI/#147 share. Wires an FAB and a
best-effort hardware Ctrl-K into MaestroApp.
2026-09-13 02:20:46 +01:00
Miguel PalhasandClaude Opus 4.6 d8030b59f9 feat(maestro): add app shell with navigation (#268)
MaestroApp root composable with ModalNavigationDrawer,
NavHost (Welcome/Terminal/Structured routes), WelcomeScreen,
connection status, back press handling, spawn form integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 01:58:44 +01:00
Miguel PalhasandClaude Opus 4.6 0ab4ac3e4b feat(maestro): add structured session pane (#267)
Transcript view for ACP structured sessions (Claude Code, Codex).
Renders all 8 row kinds, collapsible tool groups, approval cards,
composer with send/cancel, turn state strip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 01:47:16 +01:00
Miguel Palhas c90bdfbe42 feat(maestro): open terminal pane from a tapped sidebar session
Tapping a session in the sidebar now opens TerminalScreen full-screen
instead of just closing the drawer. windowSoftInputMode=adjustResize
lets WindowInsets.isImeVisible drive the key bar's visibility.
2026-09-13 01:07:32 +01:00
Miguel Palhas 27b6e647c1 feat(maestro): add Android terminal pane composable
TerminalWebView wraps a WebView around the bundled xterm.js page, with
a JS bridge (maestroKey/maestroToggleMod/maestroFocus) the native key
bar drives; onModifierConsumed reports back when the page's own JS
folds an armed Ctrl/Alt into a soft-keyboard keystroke, so the native
toggle stays in sync. TerminalScreen composes the WebView with the key
bar, showing the bar only while WindowInsets.isImeVisible.
2026-09-13 01:07:23 +01:00
Miguel Palhas 0631e69ef0 feat(maestro): add Android terminal extra key bar composable
Termux-style row for the soft keyboard (issue #266): Tab, Esc, one-shot
Ctrl/Alt modifiers, and the four cursor arrows, scrollable horizontally
for narrow screens.
2026-09-13 01:07:15 +01:00
Miguel Palhas f98c637cd9 feat(maestro): add Android terminal attach URL builder
TerminalPageUrl builds the file:///android_asset/terminal/index.html
URL for one session, with the WS /api/attach/:id URL Routes already
knows how to build passed through as an encoded query param.
2026-09-13 01:07:07 +01:00
Miguel Palhas 554182d956 feat(maestro): bundle xterm.js terminal page for Android WebView pane
Vendors @xterm/xterm 5.5.0 and @xterm/addon-fit 0.10.0 as static assets
plus a small HTML/JS page that owns the WS /api/attach/:id connection
directly, the same shape as the web SPA's TerminalPane.tsx. Bundled
locally so the pane only needs LAN reachability to the daemon, not
internet access to a CDN.
2026-09-13 01:06:59 +01:00
Miguel Palhas 391efc72d2 feat(maestro): add SpawnSheet bottom sheet composable (#269)
Preset chip row, searchable project list, debounced directory
autocomplete, and a branch/worktree picker matching the web spawn
modal, wired to SpawnViewModel and a self-contained snackbar for
submit errors.
2026-09-13 00:54:02 +01:00
Miguel Palhas cf582be306 feat(maestro): add spawn form model and view model (#269)
Pure request-building logic ported from web/src/components/spawn/model.ts,
plus a testable SpawnViewModel wiring presets, projects, debounced dir
autocomplete, branch loading, and session creation to MaestroClient.
2026-09-13 00:49:55 +01:00
Miguel Palhas 3403224dec feat(maestro): wire sidebar drawer into MainActivity
ModalNavigationDrawer opened by a hamburger button in the top bar,
holding SidebarContent bound to the app's DaemonLink.catalog.
2026-09-13 00:39:15 +01:00
Miguel Palhas a9c3cfe6d0 feat(maestro): add sidebar composables (#265)
ActivityRail (coloured gutter bar, blink on AwaitingInput, flash on
Error), SessionCard (two-line card with a long-press context menu for
rename/kill/spawn-similar), FolderNode (collapsible repo group), and
SidebarContent assembling the drawer's session list from the
CatalogStore built in #264.
2026-09-13 00:39:13 +01:00
Miguel Palhas 3ef1decbdd feat(maestro): add sidebar session formatting helpers
Pure Kotlin ports of the web/TUI SIDEBAR.md logic for the Android
sidebar: duration floor-format, program-name extraction (env/sudo/env
wrappers, npx/pnpm/yarn dlx, path), diff-stat notation, and
name/name_source/provisional-name reads. Covered by unit tests so they
run without an Android SDK.
2026-09-13 00:39:09 +01:00
Miguel Palhas b22539bd5b feat(maestro): wire DaemonLink into MainActivity, allow cleartext traffic
Reads the EXTRA_DAEMON_URL intent extra, starts DaemonLink, and shows its
LinkState in the placeholder screen so the connection indicator has
something to bind to. usesCleartextTraffic is required for the daemon's
plain-HTTP LAN endpoint; ACCESS_NETWORK_STATE is required for
ConnectivityManager.registerNetworkCallback.
2026-09-13 00:24:41 +01:00
Miguel Palhas 8f0d73d89c feat(maestro): add Android daemon connection state machine
Ports the DaemonLink state machine from web/src/components/daemon/DaemonLink.tsx
and crates/maestro-tui/src/daemon_link.rs to Android: LinkState (Connecting,
Online, Reconnecting, Down, Updating), build-stamp identity comparison, a
session catalog folded from the events WebSocket, and exponential backoff
(500ms-10s) in place of the flat poll the LAN clients use, since a phone's
network changes are bigger and longer-lived than a wifi blip.

DaemonLink reacts to ProcessLifecycleOwner (foreground resume) and
ConnectivityManager (network change) to re-probe and reconnect. DaemonPrefs
persists the daemon base URL via SharedPreferences and exposes an intent
extra key for overriding it at launch.
2026-09-13 00:24:36 +01:00
Miguel Palhas c3e2faa7ee feat(maestro): add Android REST and WebSocket API clients
MaestroClient covers the daemon's REST surface: sessions CRUD, rename,
kill (with force), focus reporting, dirs/recent-dirs/projects/gitroot/
names/branches lookups, and the structured-session endpoints (prompt,
attachment upload, cancel, config, approval).

MaestroWebSocket covers all three WS connections — /api/events
(catalog deltas), /api/attach/:id (binary PTY frames both directions
plus ClientControl text frames for resize/ping), and
/api/structured/:id (transcript/state projections) — each exposing a
Flow of decoded events behind a connection handle.

Adds kotlinx-coroutines-android explicitly rather than relying on it
being pulled in transitively via lifecycle-runtime-ktx.

Compiled and exercised standalone against the same library versions
(no Android SDK available in this environment to run the app module
itself) to verify wire-format handling before landing.
2026-09-13 00:07:30 +01:00
Miguel Palhas 6ccd79baa0 feat(maestro): add Android wire-format data types
Kotlin data classes mirroring maestro-protocol's wire types (Session,
CatalogEvent, ClientControl, structured-session projections, etc.),
@Serializable with kotlinx-serialization. ActivityState and NameSource
carry custom lenient deserializers matching the Rust side's forward-
compat behavior: an unknown variant degrades to the neutral default
instead of failing the whole payload.

Verified against fixtures mirrored from maestro-protocol's own test
suite (tag placement, snake_case renames, legacy-payload defaults).
2026-09-13 00:07:19 +01:00
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
Miguel Palhas 0543f66469 fix(web): keep copy labels hover-only
ci / web (push) Successful in 25s
ci / flake (push) Failing after 40s
ci / rust (push) Successful in 5m12s
2026-09-07 08:13:23 +01:00
Miguel Palhas 4a2f7e98b4 feat(web): render structured tool payloads 2026-09-07 07:51:42 +01:00
Miguel Palhas 8c151a5fb2 Merge remote-tracking branch 'origin/main' into blitz/codex-structured-sessions
ci / web (push) Failing after 8s
ci / flake (push) Failing after 2m18s
ci / rust (push) Successful in 8m53s
# Conflicts:
#	crates/maestro-daemon/tests/worktree.rs
#	crates/maestro-e2e/tests/e2e.rs
#	web/src/attach.ts
#	web/src/connections.test.ts
#	web/src/connections.ts
2026-09-06 23:16:27 +01:00
Miguel Palhas 8d0886f9b7 feat(web): animate structured session interactions 2026-09-06 22:49:10 +01:00
Miguel Palhas 9f82400f0a fix(web): reclaim mobile composer edges 2026-09-06 22:22:24 +01:00
Miguel Palhas 059316cbac fix(web): normalize structured thought rows 2026-09-06 22:21:19 +01:00
Miguel Palhas 0b1c9e15cf fix(web): quiet structured transcript metadata 2026-09-06 21:51:17 +01:00
Miguel Palhas e30084ea24 docs(maestro): document Codex structured installation 2026-09-06 18:48:24 +01:00
Miguel Palhas bb898a75f3 Merge issue #203: offer structured Codex TUI spawns 2026-09-06 18:43:27 +01:00
Miguel Palhas c8e90af91c feat(maestro): support Codex structured TUI 2026-09-06 18:43:09 +01:00
Miguel Palhas d0df60ac87 Merge issue #202: offer structured Codex web spawns 2026-09-06 18:39:16 +01:00
Miguel Palhas 25244803a9 feat(maestro): support structured Codex spawns 2026-09-06 18:38:58 +01:00
Miguel Palhas 92b41a8742 docs(maestro): clarify adapter PATH lookup 2026-09-06 18:33:06 +01:00
Miguel Palhas 508262bbac Merge issue #201: cover Codex ACP lifecycle 2026-09-06 18:32:47 +01:00
Miguel Palhas 002f1b466b test(maestro): cover Codex ACP lifecycle 2026-09-06 18:32:19 +01:00
Miguel Palhas bc27bffbae Merge issue #200: select structured ACP adapter 2026-09-06 18:15:09 +01:00
Miguel Palhas bbf622ed9b feat(maestro): select ACP adapter by command 2026-09-06 18:11:04 +01:00
Miguel Palhas 0c46fb993a fix: recover PWA after backgrounding 2026-09-06 18:02:11 +01:00
Miguel Palhas be554d0ede feat: render structured responses as markdown 2026-09-06 17:31:15 +01:00
Miguel Palhas 4fda393378 polish: refine structured composer 2026-09-06 17:06:40 +01:00
Miguel Palhas a1f3690aaf feat: show structured turn activity 2026-09-06 16:50:46 +01:00
Miguel Palhas 4ce432c227 fix: move session picker to composer 2026-09-06 16:38:50 +01:00
Miguel Palhas ba7fd68004 feat: add structured session picker 2026-09-06 16:18:33 +01:00
Miguel Palhas c3d008d07f fix: autocomplete at composer caret 2026-09-06 15:52:04 +01:00
Miguel Palhas c355f67133 feat: autocomplete structured commands 2026-09-06 15:37:49 +01:00
Miguel Palhas b5c9970e28 fix: extend transcript hover surfaces 2026-09-06 15:26:31 +01:00
Miguel Palhas 22d740a36b feat: streamline structured transcript 2026-09-06 15:17:40 +01:00
Miguel Palhas ccdfec8257 fix(daemon): persist structured user prompts 2026-09-06 14:19:45 +01:00
Miguel Palhas 30f91176ae fix(daemon): coalesce anonymous ACP text chunks 2026-09-06 14:10:11 +01:00
Miguel Palhas a771d29210 fix(web): separate transcript scrolling from composer 2026-09-06 14:01:43 +01:00
Miguel Palhas 913421e244 fix(web): anchor structured composer on mobile 2026-09-05 18:15:38 +01:00
Miguel Palhas 2e7c5f7f58 fix: recover PWA after backgrounding
ci / web (push) Successful in 48s
ci / flake (push) Successful in 2m56s
ci / rust (push) Successful in 6m4s
2026-09-05 18:10:21 +01:00
Miguel Palhas dba3d8c249 Merge issue #199: align structured command resolution 2026-09-05 14:28:12 +01:00
Miguel Palhas 4c8217385a fix(maestro): scope assignment-skipping to wrappers that accept it
`command FOO=1 claude` and `nohup FOO=1 claude` are invalid shells (they
exec `FOO=1` as the program, exit 127), but the resolver skipped the
assignment unconditionally after any wrapper. Assignment prefixes are
only real at the start of a simple command or after a wrapper that itself
interprets them as env vars (env, sudo) or a keyword that restarts the
assignment grammar (time) — not after command, nohup, or a package
manager's subcommand, which just exec their next argument as the program.

Attach an accepts_assignment flag to each wrapper transition in both the
Rust and TS resolvers and gate the assignment-skip loop on the flag from
the most recently consumed wrapper. Extends the shared fixture with the
two reported cases plus every other wrapper transition and several nested
combinations (sudo+time valid, sudo+command / command+nohup / time+command
invalid, nohup+env valid) to lock the per-wrapper policy in for both
languages.
2026-09-05 14:25:57 +01:00
Miguel Palhas 758bacf3fc fix(maestro): harden command resolution 2026-09-05 14:05:52 +01:00
Miguel PalhasandClaude Sonnet 5 0af91296df fix(maestro): close command-resolution gaps from #199 review
Fixes six issues in the shared command resolver (Rust display.rs and
its TS mirror toolIcon.ts), all locked in by shared fixture cases:

- An unescaped newline now ends resolution like `;` does, instead of
  being folded in as whitespace; a backslash-newline is a line
  continuation, removed entirely rather than embedded or splitting
  the word.
- Assignment recognition (`FOO=bar`) now requires the `=` to come from
  plain, unquoted/unescaped source text. `"FOO=1"` and `FOO\=1` are
  executable words named `FOO=1`, not assignments — quote removal was
  losing that provenance.
- `command -v`/`-V` (lookup only, no execution), `env -S` (embeds a
  command string) and `npx -c`/`--call` (same) no longer resolve
  through to a program they don't actually run. Restricted bash
  `time`'s options to `-p`, the only flag the keyword itself parses
  (`-o` etc. belong to external GNU time) — this corrects a
  previously-wrong fixture case.
- `env`, `sudo` and `command`'s options may now bundle (`env -iv`,
  `sudo -nE`) or take an attached value (`sudo -uroot`), the way real
  getopt-based tools do, with missing-operand forms safely rejected.
- pnpm/yarn `dlx` and `bun x`/`bunx` now consume their own trailing
  options (`--package foo`/`--package=foo`, `--bun`) before the
  executable, matching each tool's documented CLI syntax.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:51:43 +01:00
Miguel Palhas 370edfa763 fix(maestro): resolve command wrappers with a shell-aware tokenizer (#199)
command_program/command_tool whitespace-split and only skipped whole
wrapper words, so quoted paths broke, sudo -u/env -i/nohup/time/npx
--yes/pnpm|yarn --silent dlx/bun x were rejected, and pnpm claude /
yarn claude (a package.json script, not `dlx claude`) resolved as a
Claude launch. Replace it with a tokenizer that honours quoting and
escaping without evaluating any expansion, and per-wrapper option
tables (env, sudo, command, nohup, time, npx, pnpm, yarn, bun) that
require dlx/x before looking through a package-manager wrapper.
Ambiguous shell grammar (pipelines, list operators, redirections,
substitution, unterminated quotes) is rejected outright rather than
guessed through; command_program still degrades to the naive first
word for display, but command_tool returns None.

Mirror the same resolver in web/src/toolIcon.ts and add
fixtures/command-resolution.json as a parity fixture both test
suites assert against.
2026-09-05 13:19:28 +01:00
Miguel Palhas e7086512e7 test(maestro): lock in shared command resolution for the TUI structured gate (#199)
The TUI's structured-view gate already delegates to command_tool, the
same resolver web/spawnForm.ts uses via splitCommand, so paths, env
assignments, wrappers, and configured presets already resolve like
daemon dispatch. Add coverage for those forms plus stale-selection
clearing on both preset switches and custom-command edits, so a
regression back to a naive basename match would be caught.
2026-09-05 12:59:03 +01:00
Miguel Palhas aca8d39886 Merge issue #198: keep TUI structured viewport stable 2026-09-05 12:46:31 +01:00
Miguel Palhas 6ae7c3eb97 fix(maestro): anchor structured TUI viewport to row identity
follow_selection=false stored scroll as a raw rendered-line offset into
lines rebuilt fresh every frame. A ProjectionUpdate::Patch growing a
streaming row above the viewport shifted every line below it, so the
raw offset kept pointing at a numerically-same but visually different
line and the viewport drifted even though the operator had scrolled
away deliberately.

Cache each frame's row-id-per-line layout and, before applying scroll
next frame, translate the old raw offset into (row id, intra-row line)
against that cache, then relocate it in the freshly rebuilt lines. Rows
above or intersecting the viewport growing, shrinking, or being
patched now shift the offset instead of the visible content. Falls
back to the nearest surviving neighbor row when the anchor row itself
is removed, and to the clamped raw offset when nothing nearby
survived. follow_selection and bottom-follow are unaffected — they
already recompute scroll from the live selection/tail each frame.
2026-09-05 12:39:21 +01:00
Miguel Palhas a73eb6284f Merge issue #197: remove structured lock inversion 2026-09-05 12:22:55 +01:00
Miguel Palhas 57efb615f6 fix(maestro): key activity watermark by stream 2026-09-05 12:19:16 +01:00
Miguel Palhas fe26dab6a8 fix(maestro): remove structured lock inversion 2026-09-05 11:55:25 +01:00
Miguel Palhas 8eecd9961e Merge issue #196: allow concurrent structured handshakes 2026-09-05 11:39:21 +01:00
Miguel Palhas ac2eb01bda fix(maestro): allow concurrent ACP handshakes 2026-09-05 11:29:44 +01:00
Miguel Palhas 1576380080 Merge issue #195: keep structured transcript pinned 2026-09-05 11:12:02 +01:00
Miguel Palhas 85feb433ae fix(web): follow composer and rail layout 2026-09-05 11:09:42 +01:00
Miguel Palhas a11933b7bf fix(web): keep structured transcript pinned 2026-09-05 11:00:15 +01:00
Miguel Palhas fa40eb7378 Merge issue #194: omit plan transcript rows 2026-09-05 10:49:28 +01:00
Miguel Palhas b680266990 docs(maestro): fix Plan row rendering claim in STRUCTURED-VIEW
Plan rows are omitted from the transcript in both clients and rendered
in a separate plan rail/status line, not as a transcript checklist.
2026-09-05 10:48:15 +01:00
Miguel Palhas ded1f050b2 fix(web): omit plan transcript rows 2026-09-05 10:41:00 +01:00
Miguel Palhas a58ebfed93 Merge issue #193: preserve expanded cards on reconnect 2026-09-05 10:32:48 +01:00
Miguel Palhas 31b429a840 fix(web): preserve expanded structured cards 2026-09-05 10:29:40 +01:00
Miguel Palhas 12bd68a2af Merge issue #192: reject structured PTY operations 2026-09-05 10:24:34 +01:00
Miguel Palhas f765d415d6 fix(maestro): reject structured PTY access 2026-09-05 10:20:41 +01:00
Miguel Palhas d3e3010245 Merge issue #143: offer structured view in spawn forms 2026-09-05 09:53:21 +01:00
Miguel Palhas 7a0dbbf7f2 feat(maestro): offer structured spawn view 2026-09-05 09:48:42 +01:00
Miguel Palhas 84fa4a3076 Merge issue #191: render accurate structured diffs 2026-09-05 09:36:32 +01:00
Miguel Palhas eafeac5c05 fix(diff): preserve collapsed EOF marker 2026-09-05 09:33:58 +01:00
Miguel Palhas f96f94db8f fix(maestro): align structured diff hunks 2026-09-05 09:27:37 +01:00
Miguel Palhas efb54750ed fix(maestro): render accurate diffs 2026-09-05 09:18:05 +01:00
Miguel Palhas 9e59121eec Merge issue #190: preserve web tool patch state 2026-09-05 09:00:51 +01:00
Miguel Palhas 4a5c3b9982 fix(web): restore tool expansion directly 2026-09-05 08:59:20 +01:00
Miguel Palhas 443800bd79 fix(maestro): preserve web tool patch state 2026-09-05 08:53:22 +01:00
Miguel Palhas d55d92d58a Merge issue #189: ignore stale TUI approval failures 2026-09-05 08:45:17 +01:00
Miguel Palhas 41770a54a7 fix(maestro): ignore stale approval failures 2026-09-05 08:43:13 +01:00
Miguel Palhas 6cf0b200f8 Merge issue #188: generation-safe web structured submission 2026-09-05 08:33:55 +01:00
Miguel Palhas 25344abe4f fix(web): close stale structured sockets 2026-09-05 08:29:48 +01:00
Miguel Palhas b52a9085e9 fix(web): require post-acceptance state 2026-09-05 08:22:26 +01:00
Miguel Palhas 8bd943a8ca fix(web): release accepted reconnect submit 2026-09-05 08:16:14 +01:00
Miguel Palhas b86335ddf1 fix(maestro): guard structured web submits 2026-09-05 08:09:51 +01:00
Miguel Palhas c348381c5f Merge issue #187: fix TUI structured spawn attachment race 2026-09-05 07:59:55 +01:00
Miguel Palhas dabdf9fcba fix(tui): use spawn view for attach 2026-09-05 07:57:03 +01:00
Miguel Palhas b76dc06a09 Merge structured-view/141-tui-structured 2026-09-05 07:44:56 +01:00
Miguel Palhas 23fbfdc5b8 fix(maestro): guard reconnect generations 2026-09-05 07:43:08 +01:00
Miguel Palhas ff34727e3c fix(maestro): correct structured TUI state 2026-09-05 07:36:34 +01:00
Miguel Palhas e9ede49256 feat(maestro): render structured TUI sessions 2026-09-05 07:19:10 +01:00
Miguel Palhas 8c97ef0616 Merge structured-view/140-web-structured 2026-09-05 06:53:26 +01:00
Miguel Palhas 9533672e09 fix(maestro): require real DOM focus before reconciliation re-focuses (#140)
ConnectionPool.reconcileViews re-focused a repaired connection whenever
focusedId named it, but a same-tab sidebar preview moves the keyboard
into the sidebar without ever calling setFocus (multiview.ts's
syncFocus skips it while previewing) — so focusedId keeps naming the
pane the operator was in before the preview, even though the DOM
doesn't agree. Replacing that pane's connection during exactly that
window called onMount({focus:true}) and yanked the keyboard back out
of the sidebar mid-navigation.

Capture existing.el.contains(document.activeElement) before dispose()/
el.remove() detach it (afterward the browser has already moved
activeElement to <body>, too late to tell), and require both that and
focusedId===id to re-focus. document.body and a null activeElement
both naturally fail .contains(), so "nothing focused" and "focus
elsewhere" both correctly skip.

Regression tests: 3 new (real-focus requirement, same-tab preview
race, descendant focus, body-as-unfocused) — 498 tests total.
2026-09-05 06:49:48 +01:00
Miguel Palhas b11784e8e3 fix(maestro): mobile drawer access, focus preservation, stale rAF focus (#140)
Three more fixes from acceptance review:

- structured.ts's header omitted the .sessions-btn attach.ts's terminal
  header carries. The sidebar is off-canvas on mobile, so a structured
  session had no way to reach it — add the same button and
  requestToggleDrawer wiring.

- ConnectionPool.reconcileViews always remounted a repaired connection
  with focus:false. If the mistyped pane was the one actually focused,
  disposing it dropped DOM focus while `focusedId` still named it. Pass
  `focus: this.focusedId === id` instead — `focusedId` only ever names a
  real focus (multiview.ts skips setFocus while previewing), so a
  preview or a merely-visible split neighbour still comes back
  unfocused.

- onMount's deferred requestAnimationFrame focus had no way to tell it
  had been superseded by an unmount (or a remount elsewhere) before it
  fired, so a parked pane could steal focus a frame later. A token
  bumped on every mount/unmount lets the callback recognize it's stale
  and no-op.

Regression tests: 7 new (structured.test.ts, connections.test.ts). 495
tests total.
2026-09-05 06:41:40 +01:00
Miguel Palhas 42ffc1a352 fix(maestro): correct connection view race and approval decisions (#140)
Three fixes from acceptance review:

- ConnectionPool.mount cached a connection's kind (terminal/structured)
  permanently from whatever `info` it saw first. A deep link or restored
  layout mounts before the catalog snapshot names a session's view, so
  `info` is undefined and a structured session got an xterm connection
  forever. The pool now tracks the view each cached connection was built
  for, subscribes to the catalog itself, and replaces a mistyped
  connection in place the moment the real view resolves — without
  downgrading a resolved connection just because a later caller couldn't
  resolve the session, and without treating a session merely absent from
  a snapshot (mid-removal) as "Terminal".

- The approval card offered all three decisions unconditionally. Per
  the daemon's decision mapping (session.rs's permission_outcome), a
  decision with no matching PermissionOptionKind in the request's own
  options resolves Cancelled silently — so "Allow for session" could
  look like it worked while the daemon withdrew it. Buttons are now
  gated on the options actually offered.

- Clicking a decision disabled the buttons and never re-enabled them if
  the POST failed. The decision path is async now: a rejection restores
  the buttons and surfaces the error only if the approval is still the
  pending one — a superseded/resolved approval's stale rejection is a
  no-op, not a wrongly revived control.

Regression tests: 12 new (connections.test.ts, structured.test.ts,
structuredRender.test.ts). 488 tests total.
2026-09-05 06:32:08 +01:00
Miguel Palhas a50f098c8a feat(maestro): render the structured view in the web client (#140)
Adds a transcript surface for a Structured-view session alongside the
existing xterm attach view: a WS /api/structured/:id connection
(structured.ts) mounted by ConnectionPool whenever a session's `view`
is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts,
structuredDiff.ts) covering all eight transcript row kinds.

Hierarchy follows the design reference (artifact 88ecd690): the
approval is the only lifted, card-like element; a user turn is a
tinted block with an accent rail; agent prose is plain text; tool
calls are compact bordered rows expanding to output or a coloured,
horizontally-scrolling diff computed from ACP's full old/new text.
The client renders `reduced_state` and rows exactly as received, with
no raw-event folding or turn tracking of its own.

Extracted attach.ts's pane-header title building into paneTitle.ts and
its connection interface into pane.ts (PaneConnection) so both
connection kinds share one contract and one header.
2026-09-05 06:10:40 +01:00
Miguel Palhas 32d4058c96 Merge structured-view/186-unknown-update-doc 2026-09-05 05:38:04 +01:00
Miguel Palhas 74a423567f docs(maestro): fix disabled-unstable-variant claim in STRUCTURED-VIEW.md
A SessionUpdate variant gated behind an unstable ACP feature this build
doesn't enable is compiled out of the enum, not decoded into a Notice.
It fails deserialization the same as a genuinely unknown future tag.
2026-09-05 05:35:19 +01:00
Miguel Palhas b225fbdd69 docs(maestro): narrow the Notice forward-compat claim in STRUCTURED-VIEW.md
Notice only covers SessionUpdate variants the agent-client-protocol
dependency already decodes but the projection doesn't special-case.
SessionUpdate is an internally-tagged enum with no unknown-variant
fallback, so a genuinely new wire variant fails deserialization before
the projection ever sees it, rather than becoming a Notice. A new
variant needs the dependency upgraded first.

Adds a projection test asserting that an unrecognised sessionUpdate
tag is a deserialization error.

Refs #186
2026-09-05 05:28:11 +01:00
Miguel Palhas 244e8d2927 Merge structured-view/185-lock-order into blitz/structured-view 2026-09-05 05:20:20 +01:00
Miguel Palhas 4a1a10c49a fix(maestro): avoid nested session locks 2026-09-05 05:15:58 +01:00
Miguel Palhas d607740fea Merge structured-view/184-approval-persist into blitz/structured-view 2026-09-05 05:05:15 +01:00
Miguel PalhasandClaude Sonnet 5 0b34d1608f fix(maestro): make FaultyLogSink honor the Write contract on partial writes
write() reported Ok(partial) and Err from the same call, which Write
forbids. Defer the error to the following call so write_all observes
a genuine short write, then a failure — matching what a real fd does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 05:02:01 +01:00
Miguel Palhas 075dea509a fix(maestro): make EventLog::append transactional under partial write/flush failure
A write_all that errors mid-line, or a flush that errors after a full
write, could leave last_seq and the log's on-disk line count out of
sync: a well-formed but uncounted line, or a partial one, would corrupt
every later cursor once the next append landed beside it. append now
captures the pre-append length and truncates back to it on either
failure, so the same EventLog can be appended to again immediately. If
the rollback itself can't restore that length, append returns a Fatal
variant so callers kill the session instead of promising consistency
they can no longer keep — record_permission_request and its pump now
route this through the session-death path used elsewhere.

Adds deterministic fault injection (a seekable/truncatable LogSink
double) to prove the same EventLog recovers with valid JSONL and
live/replay agreement, rather than swapping in a healthy handle. Fixes
the row-index rollback test to actually shift a later row, and
tightens the persistence-failure wording in STRUCTURED-VIEW.md.
2026-09-05 04:55:26 +01:00
Miguel Palhas c067f48a24 fix(maestro): roll back phantom approval row on log append failure
request_approval() appended the Approval row to the live projection
before the daemon tried to persist it. On append failure the pending
nonce was cancelled but the row stayed in the in-memory transcript, so
a WebSocket snapshot taken afterward showed a row durable replay could
never reproduce.

Add Projection::rollback_row to undo the append and call it alongside
the existing cancel_pending_approvals on failure. Extract the pump's
per-request handling into record_permission_request so the failure
path is directly testable without a live ACP connection.

Fault-injection regression tests use a read-only file handle
(EventLog::for_test, cfg(test)) to force append() to fail
deterministically, then assert the live snapshot and durable replay
agree (both empty) and a subsequent request still persists and
replays correctly.

Fixes #184.
2026-09-05 04:34:47 +01:00
Miguel Palhas 51f96cfb98 Merge structured-view/142-structured-activity into blitz/structured-view 2026-09-05 04:15:46 +01:00
Miguel Palhas 8f5d96a95b feat(maestro): derive structured activity 2026-09-05 04:08:43 +01:00
Miguel Palhas 3e98b3ab24 Merge structured-view/137-structured-docs into blitz/structured-view 2026-09-05 03:51:34 +01:00
Miguel Palhas 24ed1ac002 docs(maestro): fix two more factual errors in structured docs
INSTALL.md conflated event-log replay with ACP session/load resume.
STRUCTURED-VIEW.md overstated death-on-append-failure: only notification
appends kill the session, approval-row append failures just cancel the
pending approval.
2026-09-05 03:49:59 +01:00
Miguel Palhas 3529947497 Merge branch 'fix/codex-attach-scroll'
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 3m3s
ci / rust (push) Successful in 7m35s
2026-09-05 03:49:35 +01:00
Miguel Palhas 4851ad9101 fix(maestro): follow initial attach replay 2026-09-05 03:48:35 +01:00
Miguel PalhasandClaude Sonnet 5 64741a027e docs(maestro): fix five more factual errors in structured-session docs
Corrects recovery-as-two-operations, tmux-only survival on restart,
replay's durable-vs-live-state gap, snapshot-vs-replay purpose, and the
three-file persistence count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:45:26 +01:00
Miguel Palhas cdd4213ff5 docs(maestro): fix three factual errors in STRUCTURED-VIEW.md
- replay: since=0 returns the first page (still limit-bounded), not the
  whole log; clients page forward from the last returned cursor
- PR_SET_PDEATHSIG is Linux-only; other platforms depend on the
  terminate() ladder running to completion
- approval nonce is minted before cancelling the prior pending approval,
  not after
2026-09-05 03:38:32 +01:00
Miguel Palhas f8973b3802 docs(maestro): write STRUCTURED-VIEW.md
Contracts the eight transcript row kinds, daemon-vs-client decisions, the
transcript/reduced-state projections and their cursor/replay semantics, the
ACP handshake and declined capabilities, the adapter install command, and
the approval nonce rules (generation, live-only placement, secrecy from the
agent and durable history, single-use/duplicate resolution) and bounded
prompt/cancel liveness.

Amends RESTART.md to state plainly that a structured session's agent is a
daemon child, not tmux-backed: a restart ends the active turn, the
transcript survives in the event log, and pending approvals/nonces do not.

Refs #137
2026-09-05 03:32:42 +01:00
Miguel Palhas 1e2c0f0a38 Merge structured-view/139-permission-requests into blitz/structured-view 2026-09-05 03:14:19 +01:00
Miguel Palhas 6fa7dc63e4 fix: serialize structured prompt dispatch 2026-09-05 03:11:16 +01:00
Miguel Palhas 5c0a4c4640 fix: bind approvals to prompt turns 2026-09-05 02:57:29 +01:00
Miguel Palhas 8773671afd fix(daemon): unify approval replay log 2026-09-05 02:44:59 +01:00
Miguel Palhas d9815d86c6 feat(daemon): resolve ACP approvals 2026-09-05 02:32:01 +01:00
Miguel Palhas 13009d0f0e Merge structured-view/138-structured-api into blitz/structured-view 2026-09-05 02:16:00 +01:00
Miguel Palhas bbae09bc48 fix(daemon): reject dead structured sessions 2026-09-05 02:13:55 +01:00
Miguel Palhas c7c294996e fix(daemon): remove unused turn token 2026-09-05 02:07:07 +01:00
Miguel Palhas 3c638b08de fix: clarify structured prompt failures 2026-09-05 02:05:06 +01:00
Miguel Palhas be23a2b539 fix: harden structured replay 2026-09-05 01:56:07 +01:00
Miguel Palhas b98f5cb3e5 feat(maestro): serve structured session API 2026-09-05 01:46:21 +01:00
Miguel Palhas 0f5576c77a Merge structured-view/136-transcript-projection into blitz/structured-view 2026-09-05 01:31:55 +01:00
Miguel Palhas 0292f373f7 test(daemon): cover projection lifecycle 2026-09-05 01:31:20 +01:00
Miguel Palhas eea337ce36 fix(daemon): scope active tools to turn 2026-09-05 01:30:16 +01:00
Miguel Palhas fd3ea64949 fix(daemon): reconcile transcript projections 2026-09-05 01:26:12 +01:00
Miguel Palhas 046c110460 feat(maestro): project ACP transcript state 2026-09-05 01:13:39 +01:00
Miguel Palhas 6eb65f1057 Merge structured-view/183-dismissal-race into blitz/structured-view 2026-09-05 00:57:46 +01:00
Miguel Palhas 3a68c1dd2f fix(maestro): preserve dismissed recovery cleanup 2026-09-05 00:57:36 +01:00
Miguel Palhas fde1954eac Merge structured-view/182-natural-exit-race into blitz/structured-view 2026-09-05 00:48:22 +01:00
Miguel Palhas 25435c34b2 fix(maestro): classify exited ACP children 2026-09-05 00:47:51 +01:00
Miguel Palhas 4a956e9c38 Merge structured-view/181-recovery-provenance into blitz/structured-view 2026-09-05 00:36:19 +01:00
Miguel Palhas 2a49814572 fix(maestro): mark graceful ACP recovery 2026-09-05 00:35:50 +01:00
Miguel Palhas d1aa56bab3 Merge structured-view/180-shutdown-create into blitz/structured-view 2026-09-05 00:26:07 +01:00
Miguel Palhas cf322746a7 fix(maestro): serialize structured shutdown 2026-09-05 00:22:59 +01:00
Miguel Palhas b25e733539 Merge structured-view/179-shutdown-drain into blitz/structured-view 2026-09-05 00:11:13 +01:00
Miguel Palhas 5a861976d7 test(maestro): cover signal shutdown drain 2026-09-05 00:07:19 +01:00
Miguel Palhas bfe4965081 fix(maestro): stop ACP before WS drain 2026-09-05 00:07:19 +01:00
Miguel Palhas 9d7ed8c071 fix(maestro): serialize shutdown finalization 2026-09-05 00:07:19 +01:00
Miguel Palhas 88a40c1ea7 Merge structured-view/177-restart-worktree into blitz/structured-view 2026-09-04 23:58:43 +01:00
Miguel Palhas 7049b5f1c0 docs(maestro): document structured restart recovery 2026-09-04 23:53:20 +01:00
Miguel Palhas c277ad630c fix(maestro): retain worktree on ACP restart 2026-09-04 23:53:20 +01:00
Miguel Palhas fc9c62d46b test(maestro): cover structured restart worktree 2026-09-04 23:53:20 +01:00
Miguel Palhas 3fe560d67e Merge structured-view/178-pid-safe into blitz/structured-view 2026-09-04 23:46:32 +01:00
Miguel Palhas b2b33535ae fix(maestro): own ACP child signals 2026-09-04 23:42:17 +01:00
Miguel Palhas 4ea6ddcaee Merge structured-view/135-event-log into blitz/structured-view 2026-09-04 23:30:15 +01:00
Miguel Palhas e9142bb027 fix(maestro): close ACP replay seams
Serialize replay reads with event appends, acquire the live receiver under that lock, and drain the event pump during shutdown. Recovery now removes its FIFO when opening the log fails.
2026-09-04 23:25:46 +01:00
Miguel Palhas d11098a478 feat(maestro): persist live ACP events
Own the ACP update receiver inside SessionManager, append each notification before broadcasting it, and recover structured sidecars by loading their stored ACP session id without duplicating replayed history.
2026-09-04 23:20:48 +01:00
Miguel Palhas 61776b4e0a fix(maestro): preserve post-load events
Switch replay suppression off inside the ordered session/load response callback, before the dispatcher can deliver the next live notification.
2026-09-04 23:18:59 +01:00
Miguel Palhas eabd21bccf feat(maestro): load existing ACP sessions
Resume a stored ACP session through session/load and discard the history replayed during the load handshake so the durable event log remains the single transcript source.
2026-09-04 23:13:45 +01:00
Miguel PalhasandClaude Fable 5.1 fd54d4f833 feat(maestro): add the per-session event log to SidecarStore (#135)
<sidecar_dir>/<id>.events.jsonl holds one session/update notification
per line; seq is the line's 1-based position. open_events returns an
append handle held for the session's lifetime, and terminates a
partial last line first so a hard kill mid-write never glues the next
event onto the fragment. read_events takes a Since (forward, exclusive)
or Before (backward, exclusive, None for the tail) cursor with a limit;
unparseable lines keep their seq and are skipped. delete removes the
log with the sidecar; load_all ignores .jsonl.

Each append is a single write_all to the kernel, no fsync: a killed
daemon loses nothing already written, and an fsync per streamed chunk
is the wrong trade for a log that is not meant to survive power loss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 22:14:33 +01:00
Miguel Palhas 78b5abb0cf Merge structured-view/134-acp-subprocess into blitz/structured-view
Closes #134
2026-09-04 22:06:46 +01:00
Miguel Palhas 6d8b49b75f docs(maestro): document the claude-code-acp install (#134) 2026-09-04 22:03:23 +01:00
Miguel Palhas 6c20a99d58 feat(maestro): end adapter children on daemon shutdown (#134)
SIGTERM now counts as a shutdown alongside ctrl-c: systemd stops the
unit with SIGTERM, and with KillMode=process nothing else would reach
the claude-code-acp children. After the listener drains the manager
terminates every adapter before the process exits.
2026-09-04 22:03:23 +01:00
Miguel Palhas b1718559df feat(maestro): spawn a claude-code-acp child per structured session (#134)
A Structured spawn gets no tmux pane: the daemon starts the adapter in
the session cwd with the session env (including MAESTRO_SESSION_ID and
MAESTRO_URL), runs the handshake, and records the ACP session id on the
sidecar. An adapter that cannot come up still yields a session, Dead
with activity Error and the reason as activity_detail, so a missing
adapter reads as the install command in the sidebar rather than as a
session that never appeared.

The liveness pass checks a structured session's child instead of the
tmux list. kill sends session/cancel, waits, then SIGTERMs through the
ACP handle. SessionManager::shutdown ends every adapter concurrently
and marks those sessions Dead on disk. take_acp_updates hands #135 the
raw session/update stream once, with earlier notifications still queued.
2026-09-04 22:02:39 +01:00
Miguel Palhas 540a3025b1 feat(maestro): add maestro-acp, the ACP client per structured session (#134)
New crate wrapping agent-client-protocol 2.1 in the client role. spawn()
resolves claude-code-acp on the PATH the child would run with, starts it
in the session cwd with the session env, runs initialize + session/new,
and returns an AcpSession holding the connection, the ACP session id,
and an unbounded channel of raw session/update notifications for #135.

initialize declares fs.readTextFile, fs.writeTextFile and terminal as
false so the adapter uses Claude Code's own tools. request_permission is
a stub that declines until #139.

terminate() sends session/cancel, waits, SIGTERMs, then SIGKILLs. The
child stays in the daemon's process group and gets SIGTERM on daemon
death via PR_SET_PDEATHSIG. CLAUDECODE is stripped from the child env:
the adapter refuses to start when it believes it is nested in another
Claude Code session, which is how a daemon launched from one behaves.

Tests run against tests/fake_acp_agent.py, a JSON-RPC-over-stdio stand-in
that logs what it receives; real_adapter_handshake is #[ignore]d.
2026-09-04 20:13:30 +01:00
Miguel Palhas 3cb4bebd1e fix(maestro): update second stale worktree test for branch suffixing
ci / web (push) Successful in 54s
ci / flake (push) Successful in 3m8s
ci / rust (push) Successful in 8m30s
second_spawn_on_same_branch_is_rejected asserted the same pre-#77
outright-rejection contract as the unit test fixed in 0e20baf, just at
the e2e/session level — also broken by 49ab7e9's branch-collision
suffixing and also already failing on main.

Renamed to second_spawn_on_same_branch_gets_suffixed and rewritten to
assert the actual behavior: the second spawn succeeds on a `-2`
suffixed branch and directory, and the first worktree is untouched.
2026-09-04 20:08:16 +01:00
Miguel Palhas c0da16658a feat(maestro): add acp_session_id to the session sidecar (#134)
A Structured session's adapter hands back an ACP session id from
session/new; a respawn will pass it to session/load (#135). This is a
new field rather than #161's agent_session_id: that one is the Claude
Code conversation id the activity hook reports and `claude --resume`
takes, minted by Claude Code itself. The ACP id is minted by the
adapter, only exists for Structured sessions, and is what the adapter
takes back. Overloading one field with two ids from two processes
would leave a respawn unable to tell which kind it holds.
2026-09-04 20:06:12 +01:00
Miguel Palhas eb4655a9d2 test(maestro): update collision tests for branch suffixing
49ab7e9 made a new-branch worktree add suffix the branch name instead of
failing, and documented it, but left two tests asserting the old hard
rejection. The rejection contract (#66) still holds for an existing
branch that is already checked out, so that test now exercises that
case.
2026-09-04 20:02:20 +01:00
Miguel Palhas 0e20baf7c2 fix(maestro): update stale worktree test for branch-collision suffixing
ci / web (push) Failing after 38s
ci / flake (push) Successful in 4m17s
ci / rust (push) Failing after 7m40s
add_fails_when_branch_already_exists asserted the pre-#77 behavior:
that a branch collision errors outright. Since 49ab7e9, add_new
sidesteps a branch collision with a numeric suffix instead, so the
test was failing CI on main regardless of any given change.

Replaces it with a test asserting the actual (and intended) suffixing
behavior.
2026-09-04 19:57:33 +01:00
Miguel Palhas c76059ec66 Merge structured-view/133-session-view into blitz/structured-view
Closes #133
2026-09-04 19:57:18 +01:00
Miguel Palhas af8acbfbf2 test(maestro): verify umbrella binary routes each subcommand to its crate
ci / web (push) Successful in 1m10s
ci / rust (push) Failing after 3m17s
ci / flake (push) Successful in 7m6s
Closes #10

The compile_error guard and per-feature build already had CI coverage
(the "feature-gate matrix" job). What was missing was proof that a
subcommand actually reaches its own crate's dispatch at runtime, not
just that clap parses it.

A malformed --config file makes cli/daemon/tui fail identically at
config load, before touching tmux, the network, or a terminal. That
turns "clap parsed it but the handler never ran" (a usage error, exit
2) into a distinguishable outcome from "it dispatched into the real
crate" (a `toml decode` runtime error via main's error printer, exit
1). The absence-side tests are feature-gated to only exist in a
single-feature build, so they run under CI's existing per-feature
matrix rather than the default all-features build.
2026-09-04 19:47:32 +01:00
Miguel Palhas f02168ffd5 feat(maestro): add SessionView to the protocol and sidecar
Adds `SessionView { Terminal, Structured }` to maestro-protocol, a
defaulted `view` field on `Session` and `CreateSessionRequest`, and
sidecar round-trip tests for both a legacy payload with no `view` key
and a `Structured` session. Nothing reads the field yet; every other
struct literal across the workspace picks up `Default::default()`
(Terminal) to keep compiling.

Gitea #133.
2026-09-04 19:41:29 +01:00
Miguel Palhas 1ad7845f51 fix(maestro): refresh attach header when a session is renamed
Header name/command were baked into innerHTML at connection creation
and never revisited. ConnectionPool.mount() reused the cached
connection on later calls but dropped the fresh info, so a header
never picked up haiku autoname's rename.
2026-09-04 19:38:09 +01:00
Miguel PalhasandClaude Sonnet 5 f0c272e0b1 feat(maestro): copy terminal selection to clipboard
ci / web (push) Successful in 28s
ci / rust (push) Failing after 2m6s
ci / flake (push) Successful in 2m14s
xterm.js has no built-in copy-on-select; mirror onSelectionChange to
navigator.clipboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:33:24 +01:00
Miguel Palhas 624f6f19c8 Merge remote-tracking branch 'origin/fermata'
ci / web (push) Failing after 13s
ci / flake (push) Successful in 3m12s
ci / rust (push) Failing after 3m26s
2026-09-04 13:32:05 +01:00
Miguel Palhas 08161ae042 Merge remote-tracking branch 'origin/main' into fermata 2026-09-04 13:30:19 +01:00
Miguel PalhasandClaude Sonnet 5 1e3c7b294d fix(maestro): send plain Escape to the pane, move focus.toggle behind the leader
Escape unfocusing the pane made it impossible to send a literal Esc into
the attached session. Removes pane.focusList and its Escape binding, and
rebinds focus.toggle from ctrl-shift-h (claimed by the browser's History
shortcut) to leader-h, consistent with the other leader escape hatches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:30:14 +01:00
Miguel PalhasandClaude Sonnet 5 49ab7e9c81 fix(maestro): suffix branch name on new-branch worktree collision
ci / rust (push) Failing after 3m26s
ci / web (push) Successful in 43s
ci / flake (push) Successful in 3m54s
git worktree add -b fails outright when the branch already exists, even
if the target directory is free. A random fast-forward handle commonly
collides with a branch an earlier, already-removed worktree left behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:49 +01:00
Miguel PalhasandClaude Sonnet 5 0cc25d7e1c fix(maestro): equalize split widths after spawning a session
ci / rust (push) Successful in 9m12s
ci / web (push) Successful in 33s
ci / flake (push) Successful in 2m37s
splitLeaf always halved just the target pane, so repeated splits left
one pane at 50% and the rest squeezed into shrinking fractions instead
of equal columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:03 +01:00
Miguel PalhasandClaude Opus 5 706942ddd7 feat(maestro): install activity hooks for codex too
ci / web (push) Successful in 51s
ci / flake (push) Successful in 3m52s
ci / rust (push) Successful in 6m47s
The hook installer only ever wrote ~/.claude/settings.json, so a codex
session reported no activity and never posted its first prompt — leaving
it on the daemon's generated handle with no haiku name.

Adds a Target enum (claude-code, codex) over the hook file's location and
event set. One script and one marker serve both; codex swaps Notification
for PermissionRequest and has no StopFailure, so it never reports Error.
Both agents spell the prompt event UserPromptSubmit and put the text in
the payload's `prompt`, so autoname needs no change.

Codex will not run a hook it has not been told to trust; `maestro hooks
install` says so, and the approval stays the operator's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 10:44:14 +01:00
Miguel Palhas 52877abe29 fix(maestro): focus the kill button in kill dialogs
ci / web (push) Successful in 37s
ci / flake (push) Successful in 2m50s
ci / rust (push) Successful in 5m46s
Enter now confirms the kill the operator just asked for; Escape and the scrim still cancel.
2026-09-04 08:24:32 +01:00
Miguel Palhas 43fc3a3735 Merge remote-tracking branch 'origin/main' into sarabande
ci / web (push) Successful in 41s
ci / flake (push) Successful in 4m45s
ci / rust (push) Successful in 8m58s
2026-09-04 08:18:02 +01:00
Miguel Palhas ff1ffb377c fix(maestro): leave fast-forward spawns unnamed in the web client
The web N path sent the generated handle as the session name as well as
the worktree branch, so the session was born named and never got its
Haiku name from its first prompt. The TUI already spawns unnamed (#131);
the handle now only seeds the branch.
2026-09-04 08:17:14 +01:00
Miguel Palhas ba74d3515b fix(maestro): keep sidebar accent on the picked session while previewing
ci / rust (push) Successful in 10m9s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 2m56s
A preview moved the active tab's focus, and the sidebar took that as the
focused session, so hovering another row dropped the accent from the row the
operator had actually opened. Report the pre-preview focus instead.
2026-09-04 08:15:35 +01:00
Miguel Palhas 8325bc6d9a Merge remote-tracking branch 'origin/main' into blitz/command-bar
ci / web (push) Successful in 37s
ci / flake (push) Successful in 2m52s
ci / rust (push) Successful in 5m44s
2026-09-03 22:58:41 +01:00
Miguel PalhasandClaude Opus 5 8f499dbee3 fix(maestro): arbitrate pane size across clients
ci / web (push) Successful in 52s
ci / flake (push) Successful in 2m35s
ci / rust (push) Successful in 5m57s
Every attached client pushed its own dimensions straight to
`tmux resize-window`, so two clients of different sizes resized the
shared window past each other on every report — 198 resizes in 90
seconds on a four-session daemon. Each flip repaints a full-screen agent
at a new height and orphans the rows it drew at the old one, leaving a
stale input box the operator types into and reads as a stuck pane.

The window now goes to the smallest attached client per axis, tmux's own
rule for multiple clients, and only when that size actually changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:28:40 +01:00
Miguel Palhas 511fed408c feat(maestro): confirm every kill in the web client
The TUI has always raised `KillConfirm` before `x` kills anything; the web
sent `DELETE /api/sessions/:id` on the first press and only prompted when the
daemon refused. The command bar puts both behind the same three keystrokes,
so the difference now reads as a bug (#160).

The web follows the TUI: a kill is destructive and unattended agents live in
these sessions. `killDialog.ts` grows a confirmation that shares the force
prompt's shell, names the blockers the card already knows about, and keeps the
focus on Cancel. The guarded refusal still opens its own dialog behind it.
2026-09-03 22:08:06 +01:00
Miguel Palhas ba02b11f13 feat(maestro): serve the configured leader to the web client
The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`,
a module constant. Rebinding one left the other on `ctrl-a`, so the two
escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`)
moved on one client and not the other.

`GET /api/info` now carries `leader`. The web binds that when it arrives and
rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command
bar render the configured chord; an absent or empty field — an older daemon,
or an unreachable `/api/info` — leaves it on `ctrl-a`.

Chose the existing `[tui].leader` served as-is over a new shared key: one
knob, no precedence rules between two spellings, and no migration for
operators who already set it. The name is a wart — it is the leader for every
client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md`
say so.
2026-09-03 18:33:01 +01:00
Miguel PalhasandClaude Opus 5 59ede15d2b feat(maestro): open the bar from the sidebar search field
There is no ctrl-k on a phone and the web UI is mobile-first, so the field
the operator already reaches for becomes the touch entry point: focusing or
tapping it dispatches OPEN_COMMAND_BAR_EVENT and the shell opens the bar with
an empty query. The field is readonly and blurs itself first, so no keyboard
rises over it and the bar can be closed without reopening it.

This replaces the inline substring filter — one search affordance in the
header, not two. buildTree loses its filter argument, and the tree's "No
matches" empty state goes with it; the bar's own no-results state took over.
The filter's name-and-directory matching is now covered against the bar.

The bar's phone-width sheet moves to the spawn modal's 599px break, with
thumb-sized rows and a field that clears the zoom-on-focus threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:21:31 +01:00
Miguel PalhasandClaude Sonnet 5 331039df11 fix(maestro): leader twice sends one literal leader byte
ctrl-a is consumed as the leader on every press, so it never reaches the
shell and readline's beginning-of-line was gone with no way back — unlike
Ctrl-K, which already has an escape hatch. Fix it the way tmux does:
pressing the leader twice sends the literal leader byte to the focused
pane and clears the pending chord, on both web and TUI, built from the
configured leader so a rebound leader carries it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:14:11 +01:00
Miguel Palhas 151438094e Merge remote-tracking branch 'origin/blitz/command-bar' into command-bar/158-literal-leader 2026-09-03 17:59:51 +01:00
Miguel Palhas c1c283c0f8 feat(maestro): give readline's kill-to-end-of-line back via the leader
ctrl-k now opens the command bar in both modes (#153), taking readline's
kill-to-end-of-line away from every pane. Leader then ctrl-k — ctrl-a
ctrl-k by default — sends the literal 0x0B byte to the focused pane
instead, built from the configured leader so rebinding it rebinds the
chord too.

Chording a second binding off the leader exposed a gap in ChordState:
a key bound both alone and as a chord prefix (ctrl-a alone, and ctrl-a
ctrl-k) would get stuck waiting on the next keystroke, discarding its
own meaning the moment that keystroke didn't continue the chord. Feed
now resolves the held prefix to its own binding when that happens and
hands the breaking key back via take_replay for the caller to process
fresh — otherwise, pressing leader then any other key would never
leave Insert mode again.
2026-09-03 17:55:50 +01:00
Miguel PalhasandClaude Opus 5 1b260b5c20 feat(maestro): drill into a session's actions with tab
`tab` on a highlighted session opens its action page inside the command
bar: kill, rename, spawn similar, still fuzzy-searchable, with the
overlay titled after the session. Escape or backspace at an empty query
returns to the result list with the previous query intact.

Each action raises the flow the TUI already has — `KillConfirm`, the
rename prompt pre-filled, and the spawn form, now seeded from that
session's directory and command rather than the focused one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:35:24 +01:00
Miguel Palhas 3eb1d7ddbe feat(maestro): drill into a session's action page with tab
Tab on a highlighted session in the web command bar opens that session's
action page in the same overlay: kill, rename, spawn similar, still fuzzy
searchable and headed by the session's name. Escape, or backspace at an
empty query, returns to the result list with the previous query intact.

Each action raises the flow the card already has. The two that lived
inside the sidebar get an event apiece — the confirm-and-blockers kill and
the inline rename prompt — so the bar picks the action and nothing more;
spawn similar reuses the existing SPAWN_SIMILAR_EVENT.
2026-09-03 17:27:13 +01:00
Miguel Palhas 9d6b69cc49 feat(maestro): add the TUI command bar's > and ~ sigils
`>` narrows to commands from the live keymap for the current mode, matched
on the action's help_label only — the chord is displayed, never matched, so
results cannot depend on a user's rebinds. Rows dedupe by action, not by
chord: the default map binds both `enter` and `tab` to AttachHighlighted,
and AttachHighlighted and ToggleAttachHighlighted share a label because they
are the same match arm.

`~` narrows to recent dirs plus the project list. Enter takes the
fast-forward path pinned to that directory, with no recency fallback, so a
directory outside the project scan opens the pre-filled form rather than
spawning elsewhere.

Both fall out of parsing the query line, as in web/src/commandBar.ts: a
sigil is only a sigil in first position, and backspacing past one is just
the query becoming mixed again.
2026-09-03 17:17:27 +01:00
Miguel PalhasandClaude Opus 5 b2cbb23fe2 feat(maestro): open the bar with ctrl-k over a focused pane
The global listener already ran in the capture phase, but deferred to
isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats
that textarea as not a typing target, while real form fields — the spawn
form's inputs — keep their own Ctrl-K.

Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused
pane through the leader chord the command set already resolves, so
readline's kill-to-end-of-line stays reachable. The pool grows a
sendToFocused for it, over a new AttachConnection.sendInput.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:03:19 +01:00
Miguel PalhasandClaude Opus 5 9fe9fe4628 feat(maestro): add the web command bar's > and ~ sigils
`>` narrows the bar to commands, matched on the label only with the chord
shown beside each row and deduped by action. `~` narrows to directories over
recent dirs and the project list, filled in from /api/dirs as a path is typed;
Enter fast-forward-spawns there, falling back to the pre-filled spawn form
when a default cannot resolve. Without a sigil the list stays mixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:56:41 +01:00
Miguel Palhas e6a254b734 chore(maestro): drop the worker task file committed with #153
TASK.md is a blitz worker's prompt, not repo content.
2026-09-03 16:47:34 +01:00
Miguel Palhas dc783778c8 feat(maestro): add the ctrl-k command bar to the TUI
Opens from Normal and Insert mode as an ordinary keymap entry, so it is
rebindable and the help overlay picks it up from the live keymap. An
empty query lists sessions most recently focused first, matching the web
bar's `orderSessionsForBar`; typing ranks through `matcher.rs` with the
matched ranges highlighted, grouped under kind headers.

Enter attaches through the same path the sidebar's attach uses, Escape
closes and leaves focus and mode where they were.
2026-09-03 16:42:21 +01:00
Miguel Palhas f8b434dd1d fix(maestro): keep accent fill when previewing active row
ci / web (push) Successful in 40s
ci / flake (push) Successful in 3m34s
ci / rust (push) Successful in 6m44s
Hovering the session already in the pane put the preview tint over its accent fill, so the row that is on screen looked unpicked. Tier 5 now skips `.is-active`.
2026-09-03 16:34:58 +01:00
Miguel Palhas 83741d7203 feat(maestro): add Rust match-and-rank matcher for command bar
Ports the docs/COMMAND-BAR.md scoring algorithm (dynamic program over
query/text positions, word-boundary bonuses, whole-field adjustment,
four-level tie-break) to crates/maestro-tui, matching web/src/matcher.ts.
Fixtures F1-F12 from the doc are ported as tests, asserting order,
scores, field and ranges.
2026-09-03 16:27:11 +01:00
Miguel Palhas b618b7dd27 merge origin/blitz/command-bar into command-bar/147-rust-matcher 2026-09-03 16:23:45 +01:00
Miguel Palhas f37ff681ea feat(maestro): fill the row for the session in the pane
ci / web (push) Successful in 34s
ci / flake (push) Successful in 2m51s
ci / rust (push) Successful in 6m54s
The web list still said "open in the focused pane" with a 1px ring, the last
edge left in it, and the TUI said it with the ● glyph alone. Both now use a
background.

Web: `--accent-fill`, the same 30% recipe `--preview-tint` uses, so the two
tiers differ by hue rather than by strength. The row rules are also rewritten
as one ordered run of equal-specificity `:where()` selectors, which is what
`docs/SIDEBAR.md` claims the ladder is — `.is-open` moves in from the bottom
of the multi-view block, where source order had been deciding its precedence
by accident.

TUI: `palette.pane_row_bg()`, blended partway from the card surface to the
selection surface, so the sidebar reads unfilled / half filled / filled. It
borrows the selection surface's foregrounds, since those are the ones picked
for a filled row, and the cursor still wins on its own row. Blended rather
than shipped as a third `Surface` — that would be eight more colours in each
of seventeen presets — with palette tests asserting it stays a visible step
from both surfaces and stays legible in every preset.
2026-09-03 15:31:44 +01:00
Miguel PalhasandClaude Opus 5 8dde63a2ab feat(maestro): add the web ctrl-k command bar
The overlay, the query line and the session half of the results
(docs/COMMAND-BAR.md): an empty query lists sessions most recently
focused first, typing ranks through `matchAndRank` with the winning
field's ranges highlighted, and results render under kind headers
ordered by their best-scoring member.

Enter dispatches `SESSION_SELECT_EVENT`, the path the sidebar's click
and the hash router already share, so a bar attach cannot leave the
layout in a state a click could not produce.

The global key listener moves to the capture phase, which #148 left
alone: an attached xterm swallows every keystroke it is given, so
ctrl-k has to be seen ahead of the terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:28:42 +01:00
Miguel Palhas 1fe2ae6575 Merge remote-tracking branch 'origin/blitz/command-bar' into command-bar/149-web-bar 2026-09-03 14:17:21 +01:00
Miguel Palhas 5df23b8714 fix(maestro): mirror last_focused_at in the web session type
The daemon ships the field (#145) but web/src/types.ts is a hand-kept
mirror of the protocol crate, so it did not know about it. #149 sorts the
command bar's empty query by it.
2026-09-03 14:16:12 +01:00
Miguel Palhas 46a000aa68 docs(maestro): correct the row-transition comment after the fill change
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m9s
ci / rust (push) Successful in 7m0s
2026-09-03 14:08:37 +01:00
Miguel Palhas b46ba9dd26 Merge remote-tracking branch 'origin/main' into dominant 2026-09-03 14:06:58 +01:00
Miguel Palhas 9d9aef119d feat(maestro): stamp last-focused time on the session (#145)
Add Session::last_focused_at, shipped on the wire like every other
field. Both POST /api/sessions/:id/focus (focused: true) and opening
/api/attach/:id stamp it and emit SessionUpdated, so a client that
only attaches sorts the same as one that reports focus explicitly.
Losing focus does not clear it, and a never-focused session has None,
which is what lets the command bar's empty-query ordering
(docs/COMMAND-BAR.md) sort it after every session that has focused.
2026-09-03 14:05:36 +01:00
Miguel Palhas 950a899b3c feat(maestro): add command bar matcher 2026-09-03 13:55:02 +01:00
Miguel PalhasandClaude Opus 5 328711cade refactor(maestro): route web keys through a command registry
One entry per command — stable id, plain-language label, key chord, run()
— replacing the ad-hoc keydown listeners in main.ts, catalog.ts, debug.ts
and attach.ts and the parallel SHORTCUTS constant in help.ts. The `?`
overlay now renders the registry, so a binding and its documentation
cannot disagree.

Keys are canonicalised the way the TUI's `InputKey::canonical` spells
them, and chords resolve like its `ChordState`: hold while the sequence
is a strict prefix, otherwise dispatch. That gives the web a leader,
`ctrl-a` by default like `[tui].leader`, held as a partial chord and
`preventDefault`ed so it stops being select-all — the escape hatch in
docs/COMMAND-BAR.md needs somewhere to land (#149). The leader stays out
of real form fields, where select-all is still what the operator wants.

Same keys, same effects: plain single-key bindings remain deaf inside
form fields and the terminal's hidden textarea, chorded ones do not.

Refs #148

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:47:35 +01:00
Miguel Palhas cd37ac5d6d fix(maestro): say preview with a fill, not a ring
The previewed row and the keyboard cursor both drew a 2px inset ring, which
read as a border on hover rather than as a colour. Rows now carry the preview
hue as a fill: a light wash under the pointer or the cursor, deepening to an
opaque `--preview-tint` once the preview lands.

That leaves `.is-active`'s ring as the only one in the session list, where it
now means exactly one thing — open in a pane. The pane's titlebar drops its
hairline for the same reason; the dashed edges that remain belong to the
canvas and the throwaway tab.
2026-09-03 13:46:49 +01:00
Miguel Palhas 7a3287f231 feat(maestro): colour the previewed pane and its sidebar row
A preview was distinguished only by the chip and a dashed ring, both easy to
miss, and the sidebar's hover reused the neutral surface — so a hovered row
said nothing about what the content pane was showing.

Adds a `--preview` token family on a hue of its own, deliberately not the
accent: the accent means "this is the one you picked", which a preview is not.
The previewed pane's titlebar, the chip, the throwaway tab, the previewed
sidebar row and hover all carry it. Palettes whose accent is already violet
(periwinkle, orchid) move the hue via `--preview-h`.
2026-09-03 13:35:55 +01:00
359 changed files with 51298 additions and 11006 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ on:
concurrency: concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }} group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} cancel-in-progress: true
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
+3
View File
@@ -15,3 +15,6 @@ worktrees/
# `nix run .#dev` scratch state (tmux socket) # `nix run .#dev` scratch state (tmux socket)
/.dev/ /.dev/
# Per-machine Claude Code tool permissions
.claude/settings.local.json
+6
View File
@@ -92,6 +92,12 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
the two matchers must agree on, and the fixture table they are tested against. the two matchers must agree on, and the fixture table they are tested against.
Read it before changing either bar or either matcher. Read it before changing either bar or either matcher.
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
run as a daemon child over ACP instead of in a tmux pane) — the eight
transcript row kinds, the two projections and their cursor/replay semantics,
the handshake and declined capabilities, and the approval nonce rules. Read
it before changing anything that renders a structured session.
`nix run .#dev` (or `just dev`) brings up the whole local stack under `nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
Generated
+750 -57
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -10,6 +10,7 @@ repository = "https://git.naps.pt/naps62/maestro"
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
axum = { version = "0.7", features = ["ws"] } axum = { version = "0.7", features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
@@ -17,6 +18,7 @@ crossterm = { version = "0.28", features = ["event-stream"] }
dirs = "5" dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2" libc = "0.2"
maestro-acp = { path = "crates/maestro-acp" }
maestro-config = { path = "crates/maestro-config" } maestro-config = { path = "crates/maestro-config" }
# `default-features = false` so each consumer opts into `haiku-autoname` # `default-features = false` so each consumer opts into `haiku-autoname`
# through its own feature of the same name (#131); a workspace entry that # through its own feature of the same name (#131); a workspace entry that
@@ -38,9 +40,11 @@ tempfile = "3"
thiserror = "1" thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
tokio-tungstenite = "0.21" tokio-tungstenite = "0.21"
tokio-util = { version = "0.7", features = ["compat"] }
toml = "0.8" toml = "0.8"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
unicode-width = "0.1"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
ratatui = "0.28" ratatui = "0.28"
vt100 = "0.15" vt100 = "0.15"
+5
View File
@@ -23,6 +23,11 @@ web-typecheck:
pnpm -C web install --frozen-lockfile pnpm -C web install --frozen-lockfile
pnpm -C web typecheck pnpm -C web typecheck
# Build the Android debug APK and copy it next to the android/ project root.
apk:
cd android && ./gradlew assembleDebug
cp android/app/build/outputs/apk/debug/app-debug.apk android/app-debug.apk
# Full workspace build + tests. # Full workspace build + tests.
test: test:
cargo fmt --all -- --check cargo fmt --all -- --check
+12
View File
@@ -0,0 +1,12 @@
*.iml
.gradle/
.kotlin/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
+85
View File
@@ -0,0 +1,85 @@
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.lifecycle.process)
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)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.turbine)
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()
}
}
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Maestro">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
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,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="stylesheet" href="vendor/xterm.css" />
<style>
html, body, #terminal { height: 100%; margin: 0; padding: 0; background: #121419; overflow: hidden; }
.xterm { padding: 4px; }
</style>
</head>
<body>
<div id="terminal"></div>
<script src="vendor/xterm.js"></script>
<script src="vendor/addon-fit.js"></script>
<script>
// Owns the `WS /api/attach/:id` connection directly, the same shape as
// web/src/components/terminal/TerminalPane.tsx — PTY bytes in binary
// frames both ways, `ClientControl` JSON in text frames. The native
// Android side (TerminalWebView.kt) never touches the socket; it only
// calls the maestro* bridge functions below for the extra key bar.
var params = new URLSearchParams(location.search);
var wsUrl = params.get("ws");
var term = new Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
scrollback: 2000,
theme: {
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
});
var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
var container = document.getElementById("terminal");
term.open(container);
fitAddon.fit();
var ws = null;
var awaitingReplay = true;
var armed = { ctrl: false, alt: false };
function sendControl(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function sendInput(data) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
}
function connect() {
awaitingReplay = true;
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = function () {
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
ws.onmessage = function (ev) {
if (typeof ev.data === "string") return; // control frame, nothing to render
var replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), function () {
if (replay) term.scrollToBottom();
});
};
ws.onclose = function (ev) {
if (ev.code === 1000) return;
term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
};
}
// Ctrl maps to the C0 control range (A-Z, [, \, ], ^, _); Alt prefixes ESC
// — the same encoding buildKeyBar/applyModsToChar use on the web client.
function applyModsToChar(ch, ctrl, alt) {
var out = ch;
if (ctrl) {
var code = ch.toUpperCase().charCodeAt(0);
if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64);
}
if (alt) out = "\x1b" + out;
return out;
}
// CSI cursor sequence with the modifier parameter xterm expects:
// `1;<mod>` where 3=alt, 5=ctrl, 7=ctrl+alt.
function cursorSeq(final, ctrl, alt) {
if (!ctrl && !alt) return "\x1b[" + final;
var mod = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
return "\x1b[1;" + mod + final;
}
term.onData(function (data) {
if ((armed.ctrl || armed.alt) && data.length === 1) {
var hadCtrl = armed.ctrl;
var hadAlt = armed.alt;
sendInput(applyModsToChar(data, hadCtrl, hadAlt));
armed.ctrl = false;
armed.alt = false;
if (window.AndroidBridge) {
if (hadCtrl) AndroidBridge.onModConsumed("ctrl");
if (hadAlt) AndroidBridge.onModConsumed("alt");
}
} else {
sendInput(data);
}
});
term.onResize(function (size) {
sendControl({ type: "Resize", cols: size.cols, rows: size.rows });
});
container.addEventListener("click", function () {
term.focus();
});
var ro = new ResizeObserver(function () {
try {
fitAddon.fit();
} catch (e) {
// container not measurable yet (e.g. mid-teardown) — next observation retries
}
});
ro.observe(container);
// Bridge called from TerminalWebViewController via evaluateJavascript —
// the native extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles.
window.maestroKey = function (key) {
var ctrl = armed.ctrl;
var alt = armed.alt;
var seq;
switch (key) {
case "Tab": seq = "\t"; break;
case "Esc": seq = "\x1b"; break;
case "Up": seq = cursorSeq("A", ctrl, alt); break;
case "Down": seq = cursorSeq("B", ctrl, alt); break;
case "Right": seq = cursorSeq("C", ctrl, alt); break;
case "Left": seq = cursorSeq("D", ctrl, alt); break;
default: return;
}
sendInput(seq);
armed.ctrl = false;
armed.alt = false;
};
window.maestroToggleMod = function (name) {
if (name === "ctrl" || name === "alt") armed[name] = !armed[name];
};
window.maestroFocus = function () {
term.focus();
};
connect();
</script>
</body>
</html>
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
package pt.naps.maestro
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import pt.naps.maestro.connection.DaemonLink
import pt.naps.maestro.connection.DaemonPrefs
import pt.naps.maestro.ui.MaestroApp
import pt.naps.maestro.ui.theme.MaestroTheme
/**
* Single activity for the whole app (#268): owns the [DaemonLink] for its
* lifetime and hosts [MaestroApp], which carries the drawer, navigation and
* every pane.
*/
class MainActivity : ComponentActivity() {
private lateinit var daemonLink: DaemonLink
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val prefs = DaemonPrefs(applicationContext)
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
setContent {
MaestroTheme {
MaestroApp(daemonLink = daemonLink, prefs = prefs)
}
}
}
override fun onDestroy() {
daemonLink.close()
super.onDestroy()
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.api
import pt.naps.maestro.data.InTheWay
/** A `/api` failure with the daemon's own error envelope kept intact, so a
* caller can branch on [code] and render [unsavedWork] — the kill guard —
* rather than re-parsing [detail]. Mirrors the web client's
* `ApiRequestError`. */
class MaestroApiException(
val status: Int,
val code: String,
val detail: String,
val unsavedWork: InTheWay? = null,
) : Exception(buildMessage(status, code, detail)) {
private companion object {
fun buildMessage(status: Int, code: String, detail: String): String {
val info = if (code.isNotEmpty()) "$code: $detail" else detail
return if (info.isNotEmpty()) "$status — $info" else "$status"
}
}
}
@@ -0,0 +1,252 @@
package pt.naps.maestro.api
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import pt.naps.maestro.data.ApiError
import pt.naps.maestro.data.BranchInfo
import pt.naps.maestro.data.CreateSessionRequest
import pt.naps.maestro.data.DaemonInfo
import pt.naps.maestro.data.DirListing
import pt.naps.maestro.data.GitRoot
import pt.naps.maestro.data.NameBatch
import pt.naps.maestro.data.Project
import pt.naps.maestro.data.RecentDirs
import pt.naps.maestro.data.RenameSessionRequest
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SetFocusRequest
import pt.naps.maestro.data.StructuredAttachmentResponse
import pt.naps.maestro.data.StructuredApprovalDecision
import pt.naps.maestro.data.StructuredConfigRequest
import pt.naps.maestro.data.StructuredPromptRequest
import pt.naps.maestro.data.Uuid
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType()
/**
* Typed client for the daemon's `/api` surface (REST half). Mirrors
* `maestro_protocol::client::DaemonClient` / `web/src/api.ts`.
*
* There is no authentication layer: reaching the daemon's bind address is
* the authorization, same trust model as tmux itself.
*/
class MaestroClient(
private val baseUrl: String,
private val httpClient: OkHttpClient = OkHttpClient(),
) {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
suspend fun listSessions(): List<Session> = get(Routes.SESSIONS)
suspend fun createSession(request: CreateSessionRequest): Session =
post(Routes.SESSIONS, request)
/** `PATCH /api/sessions/:id`: rename a live session. An empty [name]
* clears the custom one, so the card falls back to the command. */
suspend fun renameSession(id: Uuid, name: String): Session =
patch(Routes.session(id), RenameSessionRequest(name))
/** `DELETE /api/sessions/:id`. With [force], the daemon overrides the
* kill refusal over a worktree holding unsaved work and leaves that
* worktree on disk. */
suspend fun killSession(id: Uuid, force: Boolean = false) {
val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList()
executeNoContent(newRequest("DELETE", Routes.session(id), query = query))
}
/** `POST /api/sessions/:id/focus`: report whether this client's pane is
* the one currently showing [id] — not merely highlighted in a
* sidebar. */
suspend fun setSessionFocus(id: Uuid, focused: Boolean) {
executeNoContent(
newRequest(
"POST",
Routes.sessionFocus(id),
body = json.encodeToString(SetFocusRequest(focused)),
),
)
}
/** `GET /api/dirs?path=`: one directory's immediate subdirectories.
* [path] may be empty, `~`, relative, or absolute — the daemon resolves
* it against its home dir. */
suspend fun listDir(path: String): DirListing =
get(Routes.DIRS, listOf(Routes.PATH_QUERY to path))
/** `GET /api/info`: identity + spawn presets of the connected daemon. */
suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO)
/** `GET /api/recent-dirs`: recently-used working dirs, newest-first and
* deduped by dir. */
suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS)
/** `GET /api/projects`: every discovered git repository/worktree,
* newest-activity-first. */
suspend fun listProjects(): List<Project> = get(Routes.PROJECTS)
/** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent
* when [path] isn't inside a repo. */
suspend fun gitRoot(path: String): GitRoot =
get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path))
/** `GET /api/names?count=`: a batch of fresh session handles. */
suspend fun fetchNames(count: Int): NameBatch =
get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString()))
/** `GET /api/branches?path=`: one repository's local branches, with
* worktree occupancy. */
suspend fun listBranches(path: String): List<BranchInfo> =
get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path))
/** Send a prompt to a structured session's ACP turn. `409
* turn_in_flight` when one is already active. */
suspend fun sendStructuredPrompt(id: Uuid, prompt: String) {
executeNoContent(
newRequest(
"POST",
Routes.structuredPrompt(id),
body = json.encodeToString(StructuredPromptRequest(prompt)),
),
)
}
/** Upload a file attachment for a structured session's next prompt. */
suspend fun uploadStructuredAttachment(
id: Uuid,
fileName: String,
contents: ByteArray,
): StructuredAttachmentResponse {
val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl()
.newBuilder()
.addQueryParameter("name", fileName)
.build()
val request = Request.Builder()
.url(url)
.post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE))
.build()
return decode(execute(request))
}
/** Cancel a structured session's active ACP turn. */
suspend fun cancelStructuredTurn(id: Uuid) {
executeNoContent(newRequest("POST", Routes.structuredCancel(id)))
}
/** Change one option advertised by a live ACP session. */
suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) {
executeNoContent(
newRequest(
"POST",
Routes.structuredConfig(id),
body = json.encodeToString(StructuredConfigRequest(configId, value)),
),
)
}
/** Answer a pending ACP permission request. Always resolves once the
* session itself is a live structured session — a nonce that is unknown
* or already consumed still answers success. */
suspend fun resolveStructuredApproval(
id: Uuid,
nonce: String,
decision: StructuredApprovalDecision,
) {
executeNoContent(
newRequest(
"POST",
Routes.structuredApproval(id, nonce),
body = json.encodeToString(decision),
),
)
}
// -- plumbing ------------------------------------------------------
private suspend inline fun <reified T> get(
path: String,
query: List<Pair<String, String>> = emptyList(),
): T = decode(execute(newRequest("GET", path, query = query)))
private suspend inline fun <reified B, reified T> post(path: String, body: B): T =
decode(execute(newRequest("POST", path, body = json.encodeToString(body))))
private suspend inline fun <reified B, reified T> patch(path: String, body: B): T =
decode(execute(newRequest("PATCH", path, body = json.encodeToString(body))))
private fun newRequest(
method: String,
path: String,
query: List<Pair<String, String>> = emptyList(),
body: String? = null,
): Request {
val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder()
for ((key, value) in query) urlBuilder.addQueryParameter(key, value)
// OkHttp refuses a null body on POST/PUT/PATCH even when the daemon
// expects none (e.g. the structured-turn cancel) — an empty body
// satisfies it without implying a JSON payload.
val requestBody = when {
body != null -> body.toRequestBody(JSON_MEDIA_TYPE)
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null)
else -> null
}
return Request.Builder()
.url(urlBuilder.build())
.method(method, requestBody)
.build()
}
private suspend fun execute(request: Request): Response =
suspendCancellableCoroutine { cont ->
val call = httpClient.newCall(request)
cont.invokeOnCancellation { call.cancel() }
call.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (!cont.isCancelled) cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
},
)
}
private suspend fun executeNoContent(request: Request) {
execute(request).use { if (!it.isSuccessful) throw toApiException(it) }
}
private inline fun <reified T> decode(response: Response): T =
response.use {
if (!it.isSuccessful) throw toApiException(it)
json.decodeFromString<T>(it.body?.string().orEmpty())
}
private fun toApiException(response: Response): MaestroApiException {
val text = response.body?.string().orEmpty()
val info = runCatching { json.decodeFromString<ApiError>(text) }.getOrNull()?.error
return MaestroApiException(
status = response.code,
code = info?.code ?: "",
detail = info?.message ?: text,
unsavedWork = info?.unsaved_work,
)
}
}
@@ -0,0 +1,175 @@
package pt.naps.maestro.api
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.ClientControl
import pt.naps.maestro.data.StructuredEvent
import pt.naps.maestro.data.Uuid
private val wsJson = Json {
ignoreUnknownKeys = true
isLenient = true
}
/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded
* [ClientControl] text message. */
sealed class AttachFrame {
data class Pty(val bytes: ByteArray) : AttachFrame()
data class Control(val message: ClientControl) : AttachFrame()
}
/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames
* both directions; control messages ([ClientControl]) travel in text
* frames. */
class AttachConnection internal constructor(
private val socket: WebSocket,
val frames: Flow<AttachFrame>,
) {
/** Type raw bytes into the session's pane, as if typed on an attached
* terminal. */
fun sendInput(bytes: ByteArray) {
socket.send(ByteString.of(*bytes))
}
fun resize(cols: Int, rows: Int) {
sendControl(ClientControl.Resize(cols, rows))
}
fun ping() {
sendControl(ClientControl.Ping)
}
private fun sendControl(message: ClientControl) {
socket.send(wsJson.encodeToString(message))
}
fun close() {
socket.close(1000, null)
}
}
/** A live `WS /api/structured/:id` connection: transcript/state snapshots
* and live deltas for one structured session. */
class StructuredConnection internal constructor(
private val socket: WebSocket,
val events: Flow<StructuredEvent>,
) {
fun close() {
socket.close(1000, null)
}
}
/** A live `WS /api/events` connection: catalog snapshot + session deltas. */
class EventsConnection internal constructor(
private val socket: WebSocket,
val events: Flow<CatalogEvent>,
) {
fun close() {
socket.close(1000, null)
}
}
/**
* WebSocket half of the daemon's `/api` surface. Mirrors the `*_url`
* helpers in `crates/maestro-protocol/src/routes.rs`.
*
* Each `connect*` call opens the socket immediately and returns a handle
* whose `Flow` starts buffering frames right away — a slow collector never
* misses a frame, it only sees it later. Call `close()` on the returned
* connection when done; there is no automatic reconnect.
*/
class MaestroWebSocket(
private val baseUrl: String,
private val httpClient: OkHttpClient = OkHttpClient(),
) {
/** `WS /api/events`: catalog snapshot + session deltas. */
fun connectEvents(): EventsConnection {
val channel = Channel<CatalogEvent>(Channel.BUFFERED)
val socket = open(
Routes.eventsWsUrl(baseUrl),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<CatalogEvent>(text) }
.onSuccess { channel.trySend(it) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return EventsConnection(socket, channel.receiveAsFlow())
}
/** `WS /api/attach/:id`: raw PTY bytes both directions, control messages
* as text JSON. */
fun connectAttach(id: Uuid): AttachConnection {
val channel = Channel<AttachFrame>(Channel.BUFFERED)
val socket = open(
Routes.attachWsUrl(baseUrl, id),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<ClientControl>(text) }
.onSuccess { channel.trySend(AttachFrame.Control(it)) }
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
channel.trySend(AttachFrame.Pty(bytes.toByteArray()))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return AttachConnection(socket, channel.receiveAsFlow())
}
/** `WS /api/structured/:id`: transcript/state snapshots and live deltas
* for one structured session. */
fun connectStructured(id: Uuid): StructuredConnection {
val channel = Channel<StructuredEvent>(Channel.BUFFERED)
val socket = open(
Routes.structuredWsUrl(baseUrl, id),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<StructuredEvent>(text) }
.onSuccess { channel.trySend(it) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return StructuredConnection(socket, channel.receiveAsFlow())
}
private fun open(url: String, listener: WebSocketListener): WebSocket {
val request = Request.Builder().url(url).build()
return httpClient.newWebSocket(request, listener)
}
}
@@ -0,0 +1,84 @@
package pt.naps.maestro.api
import pt.naps.maestro.data.Uuid
/** The daemon's `/api` surface: paths, and the scheme swap needed to reach
* the WebSocket endpoints. Mirrors `maestro-protocol::routes` /
* `web/src/routes.ts`. */
object Routes {
const val API_PREFIX = "/api"
const val SESSIONS = "/api/sessions"
const val INFO = "/api/info"
const val DIRS = "/api/dirs"
const val RECENT_DIRS = "/api/recent-dirs"
const val PROJECTS = "/api/projects"
const val GITROOT = "/api/gitroot"
const val NAMES = "/api/names"
const val BRANCHES = "/api/branches"
const val EVENTS = "/api/events"
/** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */
const val PATH_QUERY = "path"
/** Name of the query parameter `DELETE /api/sessions/:id` takes:
* `force=true` overrides the kill refusal over a guarded worktree. */
const val FORCE_QUERY = "force"
/** Name of the query parameter [NAMES] takes. */
const val COUNT_QUERY = "count"
fun session(id: Uuid): String = "$SESSIONS/$id"
fun sessionFocus(id: Uuid): String = "${session(id)}/focus"
fun attach(id: Uuid): String = "$API_PREFIX/attach/$id"
/** `WS /api/structured/:id` — transcript/state snapshots and live
* deltas. */
fun structured(id: Uuid): String = "$API_PREFIX/structured/$id"
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt"
fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments"
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel"
/** `POST /api/sessions/:id/structured/config` — change an ACP session
* option. */
fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config"
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a
* permission request. */
fun structuredApproval(id: Uuid, nonce: String): String =
"${session(id)}/structured/approvals/$nonce"
/** Strip a trailing slash so `{base}{path}` never doubles up. */
fun normalizeBase(base: String): String = base.trimEnd('/')
/** Join a daemon base URL and one of this object's paths. */
fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path"
/** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes
* `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes
* through. */
fun wsBase(httpBase: String): String {
val base = normalizeBase(httpBase)
return when {
base.startsWith("https://") -> "wss://${base.removePrefix("https://")}"
base.startsWith("http://") -> "ws://${base.removePrefix("http://")}"
base.startsWith("wss://") || base.startsWith("ws://") -> base
else -> throw IllegalArgumentException(
"daemon url has no supported scheme (want http/https/ws/wss): $httpBase",
)
}
}
fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS"
fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}"
fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}"
}
@@ -0,0 +1,32 @@
package pt.naps.maestro.connection
/**
* Exponential backoff for daemon reconnect attempts: doubles from
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
* change than the LAN blips the web/TUI clients ride out with a flat poll,
* so this backs off instead of hammering the daemon while nothing changed.
*/
class Backoff(
private val initialMs: Long = INITIAL_MS,
private val maxMs: Long = MAX_MS,
) {
private var currentMs = initialMs
/** Delay before the next attempt; advances the sequence for the one after. */
fun nextDelayMs(): Long {
val delay = currentMs
currentMs = (currentMs * 2).coerceAtMost(maxMs)
return delay
}
/** Back to [initialMs] after a successful connection. */
fun reset() {
currentMs = initialMs
}
companion object {
const val INITIAL_MS = 500L
const val MAX_MS = 10_000L
}
}
@@ -0,0 +1,49 @@
package pt.naps.maestro.connection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.Uuid
/**
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
* replaces the map outright; deltas mutate one entry.
*/
class CatalogStore {
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
* the caller re-fetches a full snapshot over REST and calls [replace],
* mirroring how the web client reacts to a dropped broadcast frame. */
fun apply(event: CatalogEvent): Long? =
when (event) {
is CatalogEvent.Snapshot -> {
_sessions.value = event.sessions.associateBy { it.id }
null
}
is CatalogEvent.SessionAdded -> {
_sessions.value = _sessions.value + (event.session.id to event.session)
null
}
is CatalogEvent.SessionUpdated -> {
_sessions.value = _sessions.value + (event.session.id to event.session)
null
}
is CatalogEvent.SessionRemoved -> {
_sessions.value = _sessions.value - event.id
null
}
is CatalogEvent.Lagged -> event.skipped
}
fun replace(sessions: List<Session>) {
_sessions.value = sessions.associateBy { it.id }
}
fun clear() {
_sessions.value = emptyMap()
}
}
@@ -0,0 +1,51 @@
package pt.naps.maestro.connection
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
/**
* Notifies [onAvailable] whenever a usable network appears — including the
* wifi/cellular handoff, which Android reports as one network disappearing
* and another becoming available rather than a single "changed" event.
* [DaemonLink] treats every call as "worth reconnecting for" rather than
* inspecting which network it is.
*/
class ConnectivityObserver(
context: Context,
private val onAvailable: () -> Unit,
) {
private val connectivityManager =
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable.invoke()
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
) {
onAvailable.invoke()
}
}
}
private var registered = false
fun start() {
if (registered) return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
registered = true
}
fun stop() {
if (!registered) return
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
registered = false
}
}
@@ -0,0 +1,37 @@
package pt.naps.maestro.connection
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
/** What happened between two `/api/info` replies. */
enum class RestartKind { SAME, RESTARTED, UPGRADED }
/**
* Compare two daemon replies. Mirrors `compareIdentity` in
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
* `crates/maestro-tui/src/daemon_link.rs`.
*
* The commit decides first: a different one is new server code. `instance`
* catches everything else — a restart of the same build — and is the only
* signal for a daemon built outside a git checkout, where the commit is
* always empty and an empty-vs-empty pair must never read as an upgrade.
*/
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
val a = before.build.commit
val b = after.build.commit
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
return RestartKind.RESTARTED
}
return RestartKind.SAME
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
fun formatRelease(build: BuildInfo): String {
if (build.commit.isEmpty()) return "unknown release"
val parts = mutableListOf(build.commit)
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
if (build.dirty) parts.add("(dirty)")
return parts.joinToString(" ")
}
@@ -0,0 +1,287 @@
package pt.naps.maestro.connection
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import pt.naps.maestro.api.EventsConnection
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.api.MaestroWebSocket
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
private const val GRACE_MS = 1_200L
/**
* Owns the connection to one daemon for the app's whole life: liveness,
* identity, and the session catalog. A Kotlin port of the state machine in
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
* — see `docs/RESTART.md` for the contract both mirror — with exponential
* [Backoff] standing in for their flat poll, since a phone's network changes
* are bigger and longer-lived than the LAN blips those clients ride out.
*
* Everything that can change the link — a probe landing, the events socket
* dropping, the app resuming, the network coming back, a new daemon URL —
* goes through one [signals] channel consumed by one coroutine, so state
* transitions never race each other.
*/
class DaemonLink(
context: Context,
private val prefs: DaemonPrefs,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val httpClient: OkHttpClient = OkHttpClient(),
) : DefaultLifecycleObserver {
private val appContext = context.applicationContext
private val _state = MutableStateFlow(LinkState.CONNECTING)
val state: StateFlow<LinkState> = _state.asStateFlow()
private val _build = MutableStateFlow<BuildInfo?>(null)
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
private val _attempts = MutableStateFlow(0)
val attempts: StateFlow<Int> = _attempts.asStateFlow()
val catalog = CatalogStore()
private val backoff = Backoff()
private val signals = Channel<Signal>(Channel.UNLIMITED)
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
private var generation = 0
private var currentBaseUrl: String = prefs.baseUrl.value
private var client = MaestroClient(currentBaseUrl, httpClient)
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
private var identity: DaemonInfo? = null
private var eventsJob: Job? = null
private var eventsConnection: EventsConnection? = null
private var retryJob: Job? = null
private var graceJob: Job? = null
private var loopJob: Job? = null
private var started = false
private sealed class Signal {
object Start : Signal()
object Resume : Signal()
object NetworkAvailable : Signal()
object SocketDropped : Signal()
data class BaseUrlChanged(val url: String) : Signal()
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
data class Lagged(val skipped: Long) : Signal()
}
/** Starts the link: subscribes to process lifecycle and connectivity, and
* kicks off the first probe. Call once. */
fun start() {
if (started) return
started = true
loopJob = scope.launch { runLoop() }
connectivity.start()
scope.launch(Dispatchers.Main.immediate) {
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
}
scope.launch {
prefs.baseUrl.collect { url ->
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
}
}
signals.trySend(Signal.Start)
}
/** Tears everything down. Only needed by tests and by an owner that is
* itself going away for good (this link is meant to outlive an Activity). */
fun close() {
connectivity.stop()
cancelRetry()
cancelGrace()
closeEvents()
loopJob?.cancel()
signals.close()
scope.launch(Dispatchers.Main.immediate) {
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
}
}
override fun onStart(owner: LifecycleOwner) {
signals.trySend(Signal.Resume)
}
private suspend fun runLoop() {
for (signal in signals) {
when (signal) {
is Signal.Start -> probeNow()
is Signal.Resume -> {
probeNow()
ensureEventsConnected()
}
is Signal.NetworkAvailable -> {
cancelRetry()
probeNow()
}
is Signal.SocketDropped -> handleSocketDropped()
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
is Signal.ProbeResult -> {
if (signal.generation == generation) handleProbeResult(signal.info)
}
is Signal.Lagged -> handleLagged()
}
}
}
private fun probeNow() {
val gen = generation
val currentClient = client
scope.launch {
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
signals.trySend(Signal.ProbeResult(gen, info))
}
}
private fun handleProbeResult(info: DaemonInfo?) {
if (info == null) {
onProbeFailed()
return
}
val before = identity
identity = info
_build.value = info.build
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
_state.value = LinkState.UPDATING
fullReconnect()
return
}
backoff.reset()
cancelRetry()
cancelGrace()
_attempts.value = 0
_state.value = LinkState.ONLINE
ensureEventsConnected()
}
private fun onProbeFailed() {
_attempts.value += 1
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
_state.value = LinkState.RECONNECTING
armGrace()
}
scheduleRetry()
}
private fun armGrace() {
if (graceJob?.isActive == true) return
graceJob = scope.launch {
delay(GRACE_MS)
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
}
}
private fun cancelGrace() {
graceJob?.cancel()
graceJob = null
}
private fun scheduleRetry() {
if (retryJob?.isActive == true) return
val delayMs = backoff.nextDelayMs()
retryJob = scope.launch {
delay(delayMs)
retryJob = null
probeNow()
}
}
private fun cancelRetry() {
retryJob?.cancel()
retryJob = null
backoff.reset()
}
/** A build-stamp change: drop the events socket and the catalog it fed,
* then probe again shortly. The re-probe sees the same identity it just
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
* the socket — this *is* the full reconnect. */
private fun fullReconnect() {
closeEvents()
catalog.clear()
backoff.reset()
scope.launch {
delay(200)
probeNow()
}
}
private fun handleSocketDropped() {
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
_state.value = LinkState.RECONNECTING
armGrace()
probeNow()
}
private fun handleBaseUrlChanged(url: String) {
currentBaseUrl = url
generation += 1
client = MaestroClient(url, httpClient)
socket = MaestroWebSocket(url, httpClient)
identity = null
_build.value = null
_attempts.value = 0
backoff.reset()
cancelRetry()
cancelGrace()
closeEvents()
catalog.clear()
_state.value = LinkState.CONNECTING
probeNow()
}
/** The broadcast channel dropped frames: re-fetch the full list over REST
* rather than trusting the map to still be complete. */
private fun handleLagged() {
val currentClient = client
scope.launch {
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
}
}
private fun ensureEventsConnected() {
if (eventsJob?.isActive == true) return
val connection = socket.connectEvents()
eventsConnection = connection
eventsJob = scope.launch {
try {
connection.events.collect { event ->
val skipped = catalog.apply(event)
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
}
signals.trySend(Signal.SocketDropped)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
signals.trySend(Signal.SocketDropped)
}
}
}
private fun closeEvents() {
eventsJob?.cancel()
eventsJob = null
eventsConnection?.close()
eventsConnection = null
}
}
@@ -0,0 +1,40 @@
package pt.naps.maestro.connection
import android.content.Context
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Where the daemon base URL lives between launches. A settings screen and
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
* reads [baseUrl] and reconnects on change.
*/
class DaemonPrefs(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private val _baseUrl =
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
fun setBaseUrl(url: String) {
val trimmed = url.trim()
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
prefs.edit { putString(KEY_BASE_URL, trimmed) }
_baseUrl.value = trimmed
}
companion object {
private const val FILE = "daemon_prefs"
private const val KEY_BASE_URL = "base_url"
/** Shown until the user points the app at their own daemon. */
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
/** Intent extra a launcher may set to point this launch at a specific
* daemon without going through settings. Persisted on read, so it
* sticks after the launch that carried it. */
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
}
}
@@ -0,0 +1,14 @@
package pt.naps.maestro.connection
/**
* Connection lifecycle to the daemon. Mirrors the states in
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
*
* - [CONNECTING] — first probe hasn't landed yet.
* - [ONLINE] — the daemon answered and identity is settled.
* - [RECONNECTING] — the link dropped, still inside the grace window.
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
*/
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
@@ -0,0 +1,43 @@
package pt.naps.maestro.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Session activity, orthogonal to [SessionStatus]. Mirrors
* `maestro_protocol::ActivityState`.
*
* Deserializes leniently: an unknown variant from a newer daemon decodes as
* [Idle] instead of failing the whole [Session].
*/
@Serializable(with = ActivityStateSerializer::class)
enum class ActivityState {
Active,
Idle,
AwaitingInput,
Error,
Starting,
}
object ActivityStateSerializer : KSerializer<ActivityState> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: ActivityState) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): ActivityState =
when (decoder.decodeString()) {
"Active" -> ActivityState.Active
"AwaitingInput" -> ActivityState.AwaitingInput
"Error" -> ActivityState.Error
"Starting" -> ActivityState.Starting
else -> ActivityState.Idle
}
}
@@ -0,0 +1,19 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** Structured failure body inside an [ApiError] envelope. Mirrors
* `maestro_protocol::ErrorInfo`. */
@Serializable
data class ErrorInfo(
val code: String,
val message: String,
/** Set only by the kill guard: what blocked an unforced
* `DELETE /api/sessions/:id`. */
val unsaved_work: InTheWay? = null,
)
/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`.
* Mirrors `maestro_protocol::ApiError`. */
@Serializable
data class ApiError(val error: ErrorInfo)
@@ -0,0 +1,17 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** One local branch of a repository, from `GET /api/branches`. A client
* disables the entry when [is_head] is set or [worktree] is present — git
* refuses to check the same branch out in two worktrees at once. Mirrors
* `maestro_protocol::BranchInfo`. */
@Serializable
data class BranchInfo(
/** Short branch name (`main`, `feature/foo`). */
val name: String,
/** Whether this branch is the main checkout's current HEAD. */
val is_head: Boolean,
/** Toplevel path of the linked worktree holding this branch, if any. */
val worktree: String? = null,
)
@@ -0,0 +1,37 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors
* `maestro_protocol::CatalogEvent`; tagged by a `type` field.
*
* A subscriber receives a single [Snapshot] immediately on connect, then a
* stream of deltas as sessions are created, updated, and killed. Skipped
* events (broadcast channel lag) surface as [Lagged].
*/
@Serializable
sealed class CatalogEvent {
@Serializable
@SerialName("Snapshot")
data class Snapshot(val sessions: List<Session>) : CatalogEvent()
@Serializable
@SerialName("SessionAdded")
data class SessionAdded(val session: Session) : CatalogEvent()
@Serializable
@SerialName("SessionRemoved")
data class SessionRemoved(val id: Uuid) : CatalogEvent()
@Serializable
@SerialName("SessionUpdated")
data class SessionUpdated(val session: Session) : CatalogEvent()
/** Server-issued notification that the broadcast queue dropped frames.
* Clients should re-fetch a full snapshot. */
@Serializable
@SerialName("Lagged")
data class Lagged(val skipped: Long) : CatalogEvent()
}
@@ -0,0 +1,30 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY
* bytes travel in binary frames and are not modeled here. Mirrors
* `maestro_protocol::ClientControl`; tagged by a `type` field. */
@Serializable
sealed class ClientControl {
@Serializable
@SerialName("Resize")
data class Resize(val cols: Int, val rows: Int) : ClientControl()
@Serializable
@SerialName("Ping")
object Ping : ClientControl()
@Serializable
@SerialName("Pong")
object Pong : ClientControl()
@Serializable
@SerialName("Error")
data class Error(
val code: String,
val message: String,
val unsaved_work: InTheWay? = null,
) : ClientControl()
}
@@ -0,0 +1,41 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors
* `maestro_protocol::DaemonInfo`. */
@Serializable
data class DaemonInfo(
val hostname: String = "",
/** The protocol version the daemon implements. */
val version: String = "",
/** Home dir of the user the daemon runs as; empty for older daemons. */
val home_dir: String = "",
val presets: List<SpawnPreset> = emptyList(),
val build: BuildInfo = BuildInfo(),
/** Identity of the daemon *process*, regenerated on every start — a
* changed value across a reconnect means the daemon restarted. */
val instance: String = "",
/** Canonical leader key the operator configured (`[tui].leader`), so a
* client binds the same chord the TUI does. Empty means "use your
* default". */
val leader: String = "",
)
/** A spawn-form command preset advertised by the daemon. */
@Serializable
data class SpawnPreset(
val id: String,
val label: String,
val command: String,
val auto_approve: Boolean = false,
)
/** Build provenance of the running daemon. Every field is empty when the
* binary was built outside a git checkout. */
@Serializable
data class BuildInfo(
val commit: String = "",
val commit_date: String = "",
val dirty: Boolean = false,
)
@@ -0,0 +1,33 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors
* `maestro_protocol::DirListing`. */
@Serializable
data class DirListing(
/** Resolved absolute directory that was listed (after ~/relative
* expansion). */
val path: String,
/** Immediate subdirectory names, sorted case-insensitively. */
val entries: List<String> = emptyList(),
/** True when the listing was capped server-side. */
val truncated: Boolean = false,
)
/** One deduplicated recent working dir for the spawn form's recents picker.
* Mirrors `maestro_protocol::RecentDir`. */
@Serializable
data class RecentDir(
val cwd: String,
val last_command: String,
val last_used: IsoTimestamp,
)
/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first
* and deduped by `cwd`. */
@Serializable
data class RecentDirs(
val dirs: List<RecentDir> = emptyList(),
val truncated: Boolean = false,
)
@@ -0,0 +1,9 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or
* `null` when the path isn't inside a repo. Mirrors
* `maestro_protocol::GitRoot`. */
@Serializable
data class GitRoot(val root: String? = null)
@@ -0,0 +1,7 @@
package pt.naps.maestro.data
/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */
typealias Uuid = String
/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime<Utc>`. */
typealias IsoTimestamp = String
@@ -0,0 +1,17 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** What currently blocks an unforced kill of a session's worktree,
* recomputed by the daemon on every read. Mirrors
* `maestro_protocol::InTheWay`. */
@Serializable
data class InTheWay(
val uncommitted_files: Int = 0,
val untracked_files: Int = 0,
val unpushed_commits: Int = 0,
/** Lines added across the tracked changes, staged and unstaged together. */
val insertions: Int = 0,
/** Lines removed across the same tracked changes. */
val deletions: Int = 0,
)
@@ -0,0 +1,8 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors
* `maestro_protocol::NameBatch`. */
@Serializable
data class NameBatch(val names: List<String>)
@@ -0,0 +1,40 @@
package pt.naps.maestro.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Where a session's stored name (`metadata["name"]`) came from. Mirrors
* `maestro_protocol::NameSource`.
*
* Deserializes leniently: an unknown spelling decodes as [User], the settled
* variant — a name wrongly painted as settled is only a missed dimming; one
* wrongly painted as provisional invites overwriting a name a person chose.
*/
@Serializable(with = NameSourceSerializer::class)
enum class NameSource {
User,
Generated,
Haiku,
}
object NameSourceSerializer : KSerializer<NameSource> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: NameSource) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): NameSource =
when (decoder.decodeString()) {
"Generated" -> NameSource.Generated
"Haiku" -> NameSource.Haiku
else -> NameSource.User
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** One discovered git repository (or linked worktree) for `GET
* /api/projects`. Mirrors `maestro_protocol::Project`. */
@Serializable
data class Project(
/** Absolute path of the repository/worktree toplevel. */
val path: String,
/** Basename of [path]. */
val name: String,
val is_worktree: Boolean = false,
/** The main checkout this worktree belongs to; `null` on plain repos. */
val base_repo: String? = null,
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
* any directory inside it; `null` when neither is known. */
val last_activity: IsoTimestamp? = null,
/** Command last spawned in this project, from spawn history. */
val last_command: String? = null,
)
@@ -0,0 +1,71 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Default for [Session.last_activity_at] / [Session.activity_since] when an
* older payload omits them — mirrors the daemon's Unix-epoch default. */
const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z"
/** A live or recently-dead tmux session managed by the daemon. Mirrors
* `maestro_protocol::Session`. */
@Serializable
data class Session(
val id: Uuid,
val cwd: String,
val command: String,
val env: Map<String, String> = emptyMap(),
val metadata: Map<String, JsonElement> = emptyMap(),
/** The worktree this session owns, when it was spawned with one. */
val worktree: SessionWorktree? = null,
val status: SessionStatus,
val created_at: IsoTimestamp,
/** Current foreground command in the pane (e.g. `claude`, `bash`), as the
* daemon's activity watcher observed it. `null` until observed. */
val foreground: String? = null,
/** Inferred activity dimension, orthogonal to [status]. */
val activity: ActivityState = ActivityState.Idle,
/** Short human string an agent reported alongside its activity — e.g. why
* it is in [ActivityState.Error]. `null` unless an agent reported one. */
val activity_detail: String? = null,
val last_activity_at: IsoTimestamp = EPOCH,
val activity_since: IsoTimestamp = EPOCH,
/** The pane's *current* working directory, distinct from [cwd] (the spawn
* dir). `null` until first observed. */
val current_cwd: String? = null,
/** What currently blocks an unforced kill of this session, as the
* daemon's watcher last probed it. `null` while clear or not yet probed. */
val blocked_work: InTheWay? = null,
/** When this session last became the pane a client was showing. `null`
* until the session has been focused at least once. */
val last_focused_at: IsoTimestamp? = null,
/** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and
* immutable for the session's life. */
val view: SessionView = SessionView.Terminal,
/** The ACP session id the selected provider adapter returned from
* `session/new`. Only a [SessionView.Structured] session whose handshake
* succeeded has one. */
val acp_session_id: String? = null,
/** When `true`, the daemon auto-approves every ACP permission request for
* this session instead of surfacing them to the client. */
val auto_approve: Boolean = false,
/** Prompts queued on the daemon while a turn was in flight or the adapter
* was still booting. */
val queued_prompts: List<String> = emptyList(),
)
@Serializable
enum class SessionStatus { Running, Dead }
/** A session's process shape: mirrors `maestro_protocol::SessionView`.
* `Terminal` is the default. */
@Serializable
enum class SessionView { Terminal, Structured }
/** A session's daemon-created worktree. `base_repo` is the repository's main
* checkout, which is what the session tree groups by. */
@Serializable
data class SessionWorktree(
val path: String,
val base_repo: String,
)
@@ -0,0 +1,43 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** `POST /api/sessions` body. Mirrors
* `maestro_protocol::CreateSessionRequest`. */
@Serializable
data class CreateSessionRequest(
val cwd: String,
val command: String,
val env: Map<String, String> = emptyMap(),
val metadata: Map<String, JsonElement> = emptyMap(),
val worktree: WorktreeSpec? = null,
/** The process shape to spawn. Defaults to a `Terminal` session. */
val view: SessionView = SessionView.Terminal,
/** When `true`, the daemon auto-approves every ACP permission request for
* this structured session. */
val auto_approve: Boolean = false,
)
/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors
* `maestro_protocol::WorktreeSpec`. */
@Serializable
data class WorktreeSpec(
val base_repo: String,
val branch: String,
/** When `false`, [branch] already exists and is checked out as-is.
* Defaults to `true` (create a new branch). */
val create_branch: Boolean = true,
)
/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name]
* clears the custom one, so the card falls back to the command. Mirrors
* `maestro_protocol::RenameSessionRequest`. */
@Serializable
data class RenameSessionRequest(val name: String)
/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is
* the one currently showing this session. Mirrors
* `maestro_protocol::SetFocusRequest`. */
@Serializable
data class SetFocusRequest(val focused: Boolean)
@@ -0,0 +1,136 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** The semantic shape of a [TranscriptRow]. Mirrors
* `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
@Serializable
enum class TranscriptRowKind {
@SerialName("user") User,
@SerialName("agent_text") AgentText,
@SerialName("agent_thought") AgentThought,
@SerialName("tool_call") ToolCall,
@SerialName("tool_diff") ToolDiff,
@SerialName("approval") Approval,
@SerialName("plan") Plan,
@SerialName("notice") Notice,
}
/** One client-neutral item in a structured session transcript. [data]
* preserves the ACP payload verbatim without committing to an ACP
* dependency. Mirrors `maestro_protocol::TranscriptRow`. */
@Serializable
data class TranscriptRow(
/** Stable within one session, so later deltas can address this row. */
val id: String,
val kind: TranscriptRowKind,
val data: JsonElement,
)
/** The complete transcript at a durable event-log position — a structured
* session's first `WS /api/structured/:id` message. Mirrors
* `maestro_protocol::TranscriptSnapshot`. */
@Serializable
data class TranscriptSnapshot(
val cursor: Long,
val rows: List<TranscriptRow> = emptyList(),
)
/** A change to one transcript row, addressed by [id]. Mirrors
* `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */
@Serializable
sealed class TranscriptDelta {
@Serializable
@SerialName("append")
data class Append(val row: TranscriptRow) : TranscriptDelta()
@Serializable
@SerialName("patch")
data class Patch(val row: TranscriptRow) : TranscriptDelta()
@Serializable
@SerialName("remove")
data class Remove(val id: String) : TranscriptDelta()
}
/** Per-turn indicators derived from the ACP stream — read verbatim, never
* re-derived client-side. Mirrors `maestro_protocol::TurnState`. */
@Serializable
data class TurnState(
val responding: Boolean = false,
val thinking: Boolean = false,
val active_tool_calls: List<String> = emptyList(),
/** Wall-clock start of the active prompt, so a reconnecting client keeps
* one continuous elapsed-time display instead of restarting at zero. */
val started_at: IsoTimestamp? = null,
val started_context_tokens: Long? = null,
)
/**
* A whole-session view derived from ACP notifications. Mirrors
* `maestro_protocol::ReducedState`.
*
* [pending_approval], [plan], [usage], [context] and [config_options] carry
* opaque ACP JSON — their shape is ACP's, not covered by this crate's own
* compatibility guarantee, so they are kept as [JsonElement] rather than
* modeled strongly.
*/
@Serializable
data class ReducedState(
val turn: TurnState = TurnState(),
val pending_approval: JsonElement? = null,
val plan: JsonElement? = null,
val usage: JsonElement? = null,
val context: JsonElement? = null,
val model: String? = null,
val mode: String? = null,
val config_options: JsonElement? = null,
/** Prompts queued on the daemon while a turn is in flight. */
val queued_prompts: List<String> = emptyList(),
)
/** Server messages on `WS /api/structured/:id`. Mirrors
* `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */
@Serializable
sealed class StructuredEvent {
@Serializable
@SerialName("transcript_snapshot")
data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent()
@Serializable
@SerialName("reduced_state")
data class ReducedStateEvent(val state: ReducedState) : StructuredEvent()
@Serializable
@SerialName("projection_update")
data class ProjectionUpdate(
val transcript: TranscriptDelta? = null,
val state: ReducedState,
) : StructuredEvent()
}
/** `POST /api/sessions/:id/structured/prompt` body. */
@Serializable
data class StructuredPromptRequest(val prompt: String)
/** `POST /api/sessions/:id/structured/config` body: change one option
* advertised by a live ACP session. */
@Serializable
data class StructuredConfigRequest(val config_id: String, val value: JsonElement)
/** Operator decision for a pending ACP permission request
* (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON
* string on the wire, not `{decision: ...}`. Mirrors
* `maestro_protocol::StructuredApprovalDecision`. */
@Serializable
enum class StructuredApprovalDecision {
@SerialName("allow_once") AllowOnce,
@SerialName("allow_for_session") AllowForSession,
@SerialName("deny") Deny,
}
/** Response of `POST /api/sessions/:id/structured/attachments`. */
@Serializable
data class StructuredAttachmentResponse(val path: String)
@@ -0,0 +1,298 @@
package pt.naps.maestro.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import kotlinx.coroutines.launch
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.connection.DaemonLink
import pt.naps.maestro.connection.DaemonPrefs
import pt.naps.maestro.connection.LinkState
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionView
import pt.naps.maestro.ui.commandbar.CommandBarSheet
import pt.naps.maestro.ui.commandbar.CommandBarViewModel
import pt.naps.maestro.ui.sidebar.SidebarContent
import pt.naps.maestro.ui.sidebar.sessionName
import pt.naps.maestro.ui.spawn.SpawnSheet
import pt.naps.maestro.ui.spawn.SpawnViewModel
import pt.naps.maestro.ui.structured.StructuredScreen
import pt.naps.maestro.ui.terminal.TerminalScreen
/** Route names for the single-activity nav graph (#268). */
private object Routes {
const val WELCOME = "welcome"
private const val SESSION_ID_ARG = "sessionId"
const val TERMINAL = "terminal/{$SESSION_ID_ARG}"
const val STRUCTURED = "structured/{$SESSION_ID_ARG}"
fun terminal(id: String) = "terminal/$id"
fun structured(id: String) = "structured/$id"
}
/** What to pre-fill the spawn form with when it was opened from the command
* bar (#270) rather than from a blank "Spawn session" tap. */
private sealed interface SpawnSeed {
data class Similar(val session: Session) : SpawnSeed
data class Directory(val path: String) : SpawnSeed
}
/**
* Top-level app structure (#268): a persistent [ModalNavigationDrawer] around
* a single-back-stack [NavHost], tying together the sidebar (#264/#265), the
* terminal pane (#266), the structured pane (#267), and the spawn form
* (#269) that were each built standalone.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val sessions by daemonLink.catalog.sessions.collectAsState()
val linkState by daemonLink.state.collectAsState()
val baseUrl by prefs.baseUrl.collectAsState()
var showSpawnSheet by remember { mutableStateOf(false) }
var spawnSeed by remember { mutableStateOf<SpawnSeed?>(null) }
var showCommandBar by remember { mutableStateOf(false) }
val infoClient = remember(baseUrl) { MaestroClient(baseUrl) }
var hostname by remember(baseUrl) { mutableStateOf("") }
LaunchedEffect(linkState, infoClient) {
if (linkState == LinkState.ONLINE) {
hostname = runCatching { infoClient.getDaemonInfo().hostname }.getOrDefault(hostname)
}
}
fun openSession(session: Session) {
val route = if (session.view == SessionView.Structured) {
Routes.structured(session.id)
} else {
Routes.terminal(session.id)
}
navController.navigate(route) {
popUpTo(Routes.WELCOME)
launchSingleTop = true
}
scope.launch { drawerState.close() }
}
fun spawnNew() {
spawnSeed = null
showSpawnSheet = true
}
fun spawnInDirectory(path: String) {
spawnSeed = SpawnSeed.Directory(path)
showSpawnSheet = true
}
fun spawnSimilar(session: Session) {
spawnSeed = SpawnSeed.Similar(session)
showSpawnSheet = true
}
// Ctrl-K opens the bar from anywhere (docs/COMMAND-BAR.md); a focusable
// root keeps the shortcut live except while something else (the bar's own
// search field, a text input) holds focus instead.
val rootFocusRequester = remember { FocusRequester() }
LaunchedEffect(showCommandBar) {
if (!showCommandBar) rootFocusRequester.requestFocus()
}
Box(
modifier = Modifier
.fillMaxSize()
.focusRequester(rootFocusRequester)
.focusTarget()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.isCtrlPressed && event.key == Key.K) {
showCommandBar = true
true
} else {
false
}
},
) {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
SidebarContent(
catalogStore = daemonLink.catalog,
onOpenSession = ::openSession,
)
}
},
) {
Column(modifier = Modifier.fillMaxWidth()) {
ConnectionStatusBar(linkState = linkState, hostname = hostname)
NavHost(navController = navController, startDestination = Routes.WELCOME) {
composable(Routes.WELCOME) {
WelcomeScreen(
onOpenSessions = { scope.launch { drawerState.open() } },
onSpawnSession = { spawnNew() },
)
}
composable(
Routes.TERMINAL,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
TerminalScreen(
daemonBaseUrl = baseUrl,
sessionId = id,
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
)
}
composable(
Routes.STRUCTURED,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
StructuredScreen(
daemonBaseUrl = baseUrl,
sessionId = id,
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
)
}
}
}
}
FloatingActionButton(
onClick = { showCommandBar = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
.semantics { contentDescription = "Search sessions and commands" },
) {
Text("⌕")
}
}
if (showSpawnSheet) {
val seed = spawnSeed
val viewModel = remember(infoClient, seed) {
SpawnViewModel(infoClient).also {
when (seed) {
is SpawnSeed.Similar -> {
it.setCwd(seed.session.cwd)
it.setCustomCommand(seed.session.command)
}
is SpawnSeed.Directory -> it.setCwd(seed.path)
null -> {}
}
}
}
SpawnSheet(
viewModel = viewModel,
onDismiss = {
showSpawnSheet = false
spawnSeed = null
},
onSpawned = { session ->
showSpawnSheet = false
spawnSeed = null
openSession(session)
},
)
}
if (showCommandBar) {
val commandBarViewModel = remember(daemonLink, infoClient) {
CommandBarViewModel(daemonLink.catalog, infoClient)
}
CommandBarSheet(
viewModel = commandBarViewModel,
onDismiss = { showCommandBar = false },
onOpenSession = ::openSession,
onSpawnNew = ::spawnNew,
onSpawnDirectory = ::spawnInDirectory,
onSpawnSimilar = ::spawnSimilar,
)
}
// Declared last so it sits above the NavHost's own back handling on the
// dispatcher stack: a drawer open over a session must close first, not
// pop the session back to Welcome.
BackHandler(enabled = drawerState.isOpen) {
scope.launch { drawerState.close() }
}
}
/** Connection indicator (green online / red down) plus the daemon hostname
* from `/api/info`, visible above every route. */
@Composable
private fun ConnectionStatusBar(linkState: LinkState, hostname: String) {
val online = linkState == LinkState.ONLINE
val dotColor = if (online) Color(0xFF2E7D32) else Color(0xFFC62828)
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor),
)
Text(
text = hostname.ifEmpty { linkState.name.lowercase() },
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,62 @@
package pt.naps.maestro.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import pt.naps.maestro.ui.theme.MaestroTheme
/**
* Shown when no session is open (#268): the app's resting state, reached at
* launch and every time a session closes or the operator backs out of one.
*/
@Composable
fun WelcomeScreen(
onOpenSessions: () -> Unit,
onSpawnSession: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "Maestro", style = MaterialTheme.typography.displaySmall)
Spacer(Modifier.height(8.dp))
Text(
text = "No session open",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
Button(onClick = onOpenSessions, modifier = Modifier.fillMaxWidth(0.7f)) {
Text("Sessions")
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onSpawnSession, modifier = Modifier.fillMaxWidth(0.7f)) {
Text("Spawn session")
}
}
}
@Preview(showBackground = true)
@Composable
private fun WelcomeScreenPreview() {
MaestroTheme {
WelcomeScreen(onOpenSessions = {}, onSpawnSession = {})
}
}
@@ -0,0 +1,190 @@
package pt.naps.maestro.ui.commandbar
import java.time.Instant
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
import pt.naps.maestro.ui.sidebar.programName
import pt.naps.maestro.ui.sidebar.sessionName
/** The command bar's pure model (docs/COMMAND-BAR.md): candidates, the sigil
* grammar, and the ranking. Android's v1 matcher is deliberately a simple
* weighted substring/fuzzy scorer, not the scored dynamic-program matcher
* `web`/#146 and the TUI/#147 share — see the issue for why a lighter
* matcher was chosen for the first cut. */
sealed interface BarCandidate {
data class SessionItem(val session: Session) : BarCandidate
data class CommandItem(val command: BarCommand) : BarCandidate
data class DirectoryItem(val path: String, val display: String) : BarCandidate
}
data class BarCommand(val id: String, val label: String)
/** The bar's fixed `>` registry for v1: the app has no keymap/command palette
* of its own yet, so spawning is the one action worth reaching from anywhere. */
val BAR_COMMANDS: List<BarCommand> = listOf(BarCommand(id = "spawn", label = "Spawn session"))
sealed interface BarSigil {
object Commands : BarSigil
object Directories : BarSigil
}
data class ParsedBarQuery(val sigil: BarSigil?, val query: String)
/** A sigil is only a sigil in first position (docs/COMMAND-BAR.md) — typed
* anywhere else, or backspaced away, it is an ordinary query character. */
fun parseBarQuery(raw: String): ParsedBarQuery = when (raw.firstOrNull()) {
'>' -> ParsedBarQuery(BarSigil.Commands, raw.substring(1))
'~' -> ParsedBarQuery(BarSigil.Directories, raw.substring(1))
else -> ParsedBarQuery(null, raw)
}
private fun instantMs(iso: String?): Long? {
if (iso.isNullOrEmpty()) return null
return runCatching { Instant.parse(iso).toEpochMilli() }.getOrNull()
}
/** Empty-query session order (docs/COMMAND-BAR.md, "Empty query"): most
* recently focused first; a session never focused sorts after every session
* that has been, most recently created first among those; a dead session
* sorts last regardless of recency. */
fun orderSessionsForBar(sessions: List<Session>): List<Session> = sessions.sortedWith(
Comparator { a, b ->
val deadA = if (a.status == SessionStatus.Dead) 1 else 0
val deadB = if (b.status == SessionStatus.Dead) 1 else 0
if (deadA != deadB) return@Comparator deadA - deadB
val focusA = instantMs(a.last_focused_at)
val focusB = instantMs(b.last_focused_at)
if ((focusA == null) != (focusB == null)) return@Comparator if (focusA == null) 1 else -1
if (focusA != null && focusB != null && focusA != focusB) return@Comparator focusB.compareTo(focusA)
(instantMs(b.created_at) ?: 0L).compareTo(instantMs(a.created_at) ?: 0L)
},
)
/** `~/dev/api`, not `/home/me/dev/api` — the abbreviation the sidebar and the
* spawn form both use. */
fun abbreviateCwd(path: String, homeDir: String): String = when {
homeDir.isEmpty() -> path
path == homeDir -> "~"
path.startsWith("$homeDir/") -> "~" + path.removePrefix(homeDir)
else -> path
}
/** The inverse of [abbreviateCwd], for a path the operator typed. */
fun expandHome(path: String, homeDir: String): String = when {
homeDir.isEmpty() -> path
path == "~" -> homeDir
path.startsWith("~/") -> homeDir + path.removePrefix("~")
else -> path
}
/** `~`'s candidates, deduped by the directory they spawn in and keeping the
* first mention of each. */
fun directoryCandidates(paths: List<String>, homeDir: String): List<BarCandidate.DirectoryItem> {
val seen = LinkedHashSet<String>()
val out = mutableListOf<BarCandidate.DirectoryItem>()
for (path in paths) {
if (path.isEmpty() || !seen.add(path)) continue
out += BarCandidate.DirectoryItem(path = path, display = abbreviateCwd(path, homeDir))
}
return out
}
private data class BarField(val name: String, val text: String, val weight: Int)
/** Field weights, ported from docs/COMMAND-BAR.md's candidate table — kept so
* an exact `tool` match can still outrank a weaker `name` match, even though
* the per-character scoring underneath is simplified. */
private fun fieldsFor(candidate: BarCandidate, homeDir: String): List<BarField> = when (candidate) {
is BarCandidate.SessionItem -> {
val session = candidate.session
val directory = session.worktree?.base_repo ?: session.cwd
listOfNotNull(
BarField("name", sessionName(session), 0),
BarField("directory", abbreviateCwd(directory, homeDir), -24),
session.command.takeIf { it.isNotEmpty() }?.let { BarField("command", it, -32) },
BarField("tool", programName(session.command), -40),
)
}
is BarCandidate.CommandItem -> listOf(BarField("label", candidate.command.label, 0))
is BarCandidate.DirectoryItem -> listOf(BarField("path", candidate.display, 0))
}
private const val MATCH_EXACT = 100
private const val MATCH_PREFIX = 70
private const val MATCH_CONTAINS = 50
private const val MATCH_FUZZY = 20
/** Case-insensitive subsequence: every character of [query] appears in [text]
* in order, not necessarily adjacent. */
private fun isSubsequence(query: String, text: String): Boolean {
var i = 0
for (c in text) {
if (i >= query.length) break
if (c == query[i]) i++
}
return i == query.length
}
private fun fieldMatchScore(query: String, text: String): Int? {
val q = query.lowercase()
val t = text.lowercase()
return when {
t == q -> MATCH_EXACT
t.startsWith(q) -> MATCH_PREFIX
t.contains(q) -> MATCH_CONTAINS
isSubsequence(q, t) -> MATCH_FUZZY
else -> null
}
}
private fun kindOrder(candidate: BarCandidate): Int = when (candidate) {
is BarCandidate.SessionItem -> 0
is BarCandidate.CommandItem -> 1
is BarCandidate.DirectoryItem -> 2
}
data class BarResult(val candidate: BarCandidate, val score: Int, val matchedField: String?)
/** Ranks [candidates] against [query]. An empty query matches everything at
* score 0 in input order (docs/COMMAND-BAR.md, "Empty query"); the bar's own
* rules decide what is in the candidate list at that point. */
fun rankCandidates(query: String, candidates: List<BarCandidate>, homeDir: String): List<BarResult> {
val trimmed = query.trim()
if (trimmed.isEmpty()) return candidates.map { BarResult(it, 0, null) }
val results = candidates.mapNotNull { candidate ->
var best: BarResult? = null
for (field in fieldsFor(candidate, homeDir)) {
val matchScore = fieldMatchScore(trimmed, field.text) ?: continue
val total = matchScore + field.weight
if (best == null || total > best.score) best = BarResult(candidate, total, field.name)
}
best
}
// Stable sort: ties keep the caller's order (sessions most-recently-focused
// first, commands in registry order), matching the shared matcher's last
// tie-break without reproducing its length/codepoint rules.
return results.sortedWith(compareByDescending<BarResult> { it.score }.thenBy { kindOrder(it.candidate) })
}
data class BarGroup(val title: String, val results: List<BarResult>)
private fun titleFor(candidate: BarCandidate): String = when (candidate) {
is BarCandidate.SessionItem -> "Sessions"
is BarCandidate.CommandItem -> "Commands"
is BarCandidate.DirectoryItem -> "Directories"
}
/** Buckets a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: groups appear in the order their
* best-scoring member does, because [results] is already rank-ordered and a
* `LinkedHashMap` keeps first-seen order. */
fun groupResults(results: List<BarResult>): List<BarGroup> {
val groups = LinkedHashMap<String, MutableList<BarResult>>()
for (result in results) {
groups.getOrPut(titleFor(result.candidate)) { mutableListOf() }.add(result)
}
return groups.map { (title, group) -> BarGroup(title, group) }
}
@@ -0,0 +1,307 @@
package pt.naps.maestro.ui.commandbar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.Session
import pt.naps.maestro.ui.sidebar.ActivityRail
import pt.naps.maestro.ui.sidebar.MetaLine
import pt.naps.maestro.ui.sidebar.customSessionName
import pt.naps.maestro.ui.sidebar.formatInTheWay
import pt.naps.maestro.ui.sidebar.rememberTickingNowMs
import pt.naps.maestro.ui.sidebar.sessionBlockers
import pt.naps.maestro.ui.sidebar.sessionName
/** #270's `ctrl-k` command bar (docs/COMMAND-BAR.md): a search field over a
* bottom sheet, sessions and the `>` registry mixed by default, `>` and `~`
* narrowing to one kind. Tap opens a result; long-press on a session opens
* its action menu (rename, kill, spawn similar) — the bar picks the action
* and hands off to the flow that already implements it, it never
* reimplements one. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CommandBarSheet(
viewModel: CommandBarViewModel,
onDismiss: () -> Unit,
onOpenSession: (Session) -> Unit,
onSpawnNew: () -> Unit,
onSpawnDirectory: (String) -> Unit,
onSpawnSimilar: (Session) -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
val state by viewModel.state.collectAsState()
val nowMs = rememberTickingNowMs()
val focusRequester = remember { FocusRequester() }
var actionSession by remember { mutableStateOf<Session?>(null) }
var renaming by remember { mutableStateOf<Session?>(null) }
var confirmingKill by remember { mutableStateOf<Session?>(null) }
fun activate(candidate: BarCandidate) {
when (candidate) {
is BarCandidate.SessionItem -> onOpenSession(candidate.session)
is BarCandidate.CommandItem -> onSpawnNew()
is BarCandidate.DirectoryItem -> onSpawnDirectory(candidate.path)
}
onDismiss()
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
Column(Modifier.fillMaxWidth().heightIn(max = 560.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::onQueryChange,
placeholder = { Text(placeholderFor(state.sigil)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.focusRequester(focusRequester),
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
if (state.groups.isEmpty()) {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
Text(
"No matches",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
val showHeaders = state.sigil == null
LazyColumn(Modifier.fillMaxWidth()) {
for (group in state.groups) {
if (showHeaders) {
item(key = "header:${group.title}") {
Text(
text = group.title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
}
items(group.results, key = { rowKey(it.candidate) }) { result ->
BarRow(
candidate = result.candidate,
nowMs = nowMs,
onClick = { activate(result.candidate) },
onLongClick = {
val session = (result.candidate as? BarCandidate.SessionItem)?.session
if (session != null) actionSession = session
},
)
}
}
}
}
}
}
actionSession?.let { session ->
SessionActionMenu(
session = session,
onDismiss = { actionSession = null },
onRename = {
actionSession = null
renaming = session
},
onKill = {
actionSession = null
confirmingKill = session
},
onSpawnSimilar = {
actionSession = null
onDismiss()
onSpawnSimilar(session)
},
)
}
renaming?.let { session ->
RenameDialog(
initial = customSessionName(session) ?: "",
onCommit = {
renaming = null
viewModel.rename(session, it)
},
onCancel = { renaming = null },
)
}
confirmingKill?.let { session ->
KillConfirmDialog(
session = session,
onConfirm = {
confirmingKill = null
viewModel.kill(session)
},
onCancel = { confirmingKill = null },
)
}
}
private fun placeholderFor(sigil: BarSigil?): String = when (sigil) {
BarSigil.Commands -> "Run a command…"
BarSigil.Directories -> "Spawn in a directory…"
null -> "Go to a session…"
}
private fun rowKey(candidate: BarCandidate): String = when (candidate) {
is BarCandidate.SessionItem -> "session:${candidate.session.id}"
is BarCandidate.CommandItem -> "command:${candidate.command.id}"
is BarCandidate.DirectoryItem -> "directory:${candidate.path}"
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun BarRow(
candidate: BarCandidate,
nowMs: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (candidate) {
is BarCandidate.SessionItem -> {
ActivityRail(activity = candidate.session.activity, modifier = Modifier.height(36.dp))
Spacer(Modifier.width(8.dp))
Column(Modifier.fillMaxWidth()) {
Text(
text = sessionName(candidate.session),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
MetaLine(session = candidate.session, nowMs = nowMs)
}
}
is BarCandidate.CommandItem -> {
Text(
text = candidate.command.label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
}
is BarCandidate.DirectoryItem -> {
Text(
text = candidate.display,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
/** Long-press's action menu (docs/COMMAND-BAR.md, "Drill-in"), collapsed from
* the desktop clients' fuzzy-searchable action page to a plain dialog — a
* touch long-press already named the one session being acted on, so there is
* nothing left to search. */
@Composable
private fun SessionActionMenu(
session: Session,
onDismiss: () -> Unit,
onRename: () -> Unit,
onKill: () -> Unit,
onSpawnSimilar: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(sessionName(session)) },
text = {
Column {
TextButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("Rename") }
TextButton(onClick = onSpawnSimilar, modifier = Modifier.fillMaxWidth()) { Text("Spawn similar") }
TextButton(onClick = onKill, modifier = Modifier.fillMaxWidth()) { Text("Kill") }
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Rename session") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
},
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@Composable
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
val blockers = sessionBlockers(session)
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Kill “${sessionName(session)}”?") },
text = {
Text(
if (blockers != null) {
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
} else {
"This ends the session's process. This can't be undone."
},
)
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@@ -0,0 +1,135 @@
package pt.naps.maestro.ui.commandbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.connection.CatalogStore
import pt.naps.maestro.data.Session
/** Quiet period before a typed `~` path is probed over `GET /api/dirs`,
* mirroring the spawn form's own autocomplete debounce. */
private const val PROBE_DEBOUNCE_MS = 250L
data class CommandBarUiState(
val query: String = "",
val sigil: BarSigil? = null,
val groups: List<BarGroup> = emptyList(),
)
/**
* Search logic behind [pt.naps.maestro.ui.commandbar.CommandBarSheet]
* (#270): folds the live session catalog, the fixed `>` command registry and
* the `~` directory list into one ranked, grouped result set as the query
* changes. A plain class, not an `androidx.lifecycle.ViewModel` — same shape
* as [pt.naps.maestro.ui.spawn.SpawnViewModel], which this otherwise mirrors.
*/
class CommandBarViewModel(
private val catalogStore: CatalogStore,
private val client: MaestroClient,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val _state = MutableStateFlow(CommandBarUiState())
val state: StateFlow<CommandBarUiState> = _state.asStateFlow()
private var homeDir: String = ""
private var recentDirectories: List<BarCandidate.DirectoryItem> = emptyList()
private var probedDirectories: List<BarCandidate.DirectoryItem> = emptyList()
private var recentDirectoriesLoaded = false
private var probeJob: Job? = null
init {
scope.launch {
homeDir = runCatching { client.getDaemonInfo().home_dir }.getOrDefault("")
recompute()
}
scope.launch {
catalogStore.sessions.collect { recompute() }
}
}
fun onQueryChange(raw: String) {
_state.update { it.copy(query = raw, sigil = parseBarQuery(raw).sigil) }
val parsed = parseBarQuery(raw)
if (parsed.sigil is BarSigil.Directories) {
ensureRecentDirectoriesLoaded()
probeTypedDirectory(parsed.query)
}
recompute()
}
/** `PATCH /api/sessions/:id`. Fire-and-forget: the catalog reflects the
* rename over the events socket like any other client's edit. */
fun rename(session: Session, name: String) {
scope.launch { runCatching { client.renameSession(session.id, name) } }
}
/** `DELETE /api/sessions/:id`, unforced — the same default the sidebar's
* own kill confirmation uses. */
fun kill(session: Session) {
scope.launch { runCatching { client.killSession(session.id) } }
}
private fun ensureRecentDirectoriesLoaded() {
if (recentDirectoriesLoaded) return
recentDirectoriesLoaded = true
scope.launch {
val recents = runCatching { client.recentDirs().dirs.map { it.cwd } }.getOrDefault(emptyList())
val projects = runCatching { client.listProjects().map { it.path } }.getOrDefault(emptyList())
recentDirectories = directoryCandidates(recents + projects, homeDir)
recompute()
}
}
/** Fills the `~` list in as a path is typed, the way the spawn form's own
* autocomplete does: the directory named so far is listed and its
* children join the candidates. */
private fun probeTypedDirectory(query: String) {
val slash = query.lastIndexOf('/')
if (slash < 0) {
probedDirectories = emptyList()
probeJob?.cancel()
return
}
val dir = expandHome(query.substring(0, slash + 1), homeDir)
probeJob?.cancel()
probeJob = scope.launch {
delay(PROBE_DEBOUNCE_MS)
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
probedDirectories = directoryCandidates(entries.map { "$dir$it" }, homeDir)
recompute()
}
}
private fun directoryListCandidates(): List<BarCandidate> {
val seen = LinkedHashSet<String>()
val out = mutableListOf<BarCandidate.DirectoryItem>()
for (candidate in recentDirectories + probedDirectories) {
if (seen.add(candidate.path)) out += candidate
}
return out
}
private fun recompute() {
val raw = _state.value.query
val parsed = parseBarQuery(raw)
val candidates: List<BarCandidate> = when (parsed.sigil) {
is BarSigil.Commands -> BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
is BarSigil.Directories -> directoryListCandidates()
null -> {
val sessions = orderSessionsForBar(catalogStore.sessions.value.values.toList())
.map { BarCandidate.SessionItem(it) }
if (parsed.query.isEmpty()) sessions else sessions + BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
}
}
val ranked = rankCandidates(parsed.query, candidates, homeDir)
_state.update { it.copy(groups = groupResults(ranked)) }
}
}
@@ -0,0 +1,124 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.InfiniteTransition
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.ActivityState
/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in
* density-independent pixels rather than raw pixels. */
val ACTIVITY_RAIL_WIDTH = 7.dp
private val RAIL_ACTIVE = Color(0xFF3DDC84)
private val RAIL_IDLE = Color(0xFF9AA0A6)
private val RAIL_AWAITING = Color(0xFFF5A623)
private val RAIL_ERROR = Color(0xFFE5484D)
/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting`
* shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so
* rendering never fails (Precedence). */
fun activityColor(activity: ActivityState): Color =
when (activity) {
ActivityState.Active -> RAIL_ACTIVE
ActivityState.AwaitingInput -> RAIL_AWAITING
ActivityState.Error -> RAIL_ERROR
ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE
}
/** The accessible name the rail carries, since colour is never the only
* signal (docs/SIDEBAR.md). */
fun activityStateWord(activity: ActivityState): String =
when (activity) {
ActivityState.Active -> "Active"
ActivityState.Idle -> "Idle"
ActivityState.Starting -> "Starting"
ActivityState.AwaitingInput -> "Awaiting input"
ActivityState.Error -> "Error"
}
/** A hard on/off blink, one cycle per 1200ms — mirrors the web rail's
* `maestro-rail-blink` keyframes (docs/SIDEBAR.md, web/src/components/sidebar/sidebar.css):
* held at full alpha for the first half of the cycle, then snapped to 0 for
* the second half, with no crossfade. */
@Composable
private fun InfiniteTransition.blinkAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1200
1f at 0
1f at 599
0f at 600
0f at 1199
},
repeatMode = RepeatMode.Restart,
),
label = "activity-rail-blink",
)
return alpha
}
/** A fast double-flash every 1400ms — mirrors the web rail's
* `maestro-rail-flash` keyframes: two dips to quarter alpha early in the
* cycle, then held at full alpha for the remainder. */
@Composable
private fun InfiniteTransition.flashAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
1f at 0 using LinearOutSlowInEasing
0.25f at 210 using LinearOutSlowInEasing
1f at 420 using LinearOutSlowInEasing
0.25f at 630 using LinearOutSlowInEasing
1f at 840 using LinearOutSlowInEasing
1f at 1400
},
repeatMode = RepeatMode.Restart,
),
label = "activity-rail-flash",
)
return alpha
}
/** The card's rail: a coloured bar in the gutter, full card height. It paints
* for every card, `Idle` included, and carries motion only for states that
* need attention — a hard blink for `AwaitingInput`, a fast flash for
* `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */
@Composable
fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
val alpha = when (activity) {
ActivityState.AwaitingInput -> infinite.blinkAlpha()
ActivityState.Error -> infinite.flashAlpha()
else -> 1f
}
Box(
modifier = modifier
.fillMaxHeight()
.width(ACTIVITY_RAIL_WIDTH)
.semantics { contentDescription = activityStateWord(activity) }
.background(color.copy(alpha = alpha)),
)
}
@@ -0,0 +1,58 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded
* folder's cards are one row away, so it carries no highlight of its own —
* only a collapsed header is ever a keyboard-walk stop, but that distinction
* belongs to the caller driving the walk, not this composable. */
@Composable
fun FolderNode(
label: String,
collapsed: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
depthPadding: Int = 0,
content: @Composable () -> Unit,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (collapsed) "▸" else "▾",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
AnimatedVisibility(visible = !collapsed) {
Column { content() }
}
}
}
@@ -0,0 +1,242 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
/** docs/SIDEBAR.md: a card is always two lines behind an activity rail —
* line 1 the name, line 2 the adaptive meta line. Long-press opens a context
* menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill
* only through the callbacks the caller supplies. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SessionCard(
session: Session,
nowMs: Long,
modifier: Modifier = Modifier,
depthPadding: Int = 0,
onOpen: (Session) -> Unit = {},
onRename: (Session, String) -> Unit = { _, _ -> },
onKill: (Session) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
var menuExpanded by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf(false) }
var confirmingKill by remember { mutableStateOf(false) }
val running = session.status == SessionStatus.Running
val name = sessionName(session)
val muted = isProvisionalName(session)
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onOpen(session) },
onLongClick = { menuExpanded = true },
)
.padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp))
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = when {
!running -> MaterialTheme.colorScheme.onSurfaceVariant
muted -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
},
style = MaterialTheme.typography.bodyMedium,
)
MetaLine(session = session, nowMs = nowMs)
}
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
menuExpanded = false
renaming = true
},
)
DropdownMenuItem(
text = { Text("Spawn similar") },
onClick = {
menuExpanded = false
onSpawnSimilar(session)
},
)
DropdownMenuItem(
text = { Text("Kill") },
onClick = {
menuExpanded = false
confirmingKill = true
},
)
}
if (renaming) {
RenameDialog(
initial = customSessionName(session) ?: "",
onCommit = {
renaming = false
onRename(session, it)
},
onCancel = { renaming = false },
)
}
if (confirmingKill) {
KillConfirmDialog(
session = session,
onConfirm = {
confirmingKill = false
onKill(session)
},
onCancel = { confirmingKill = false },
)
}
}
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`
* — the line itself is unconditional, only its fragments are adaptive. */
@Composable
fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) {
val heldMs = activityHeldMs(session.activity_since, nowMs)
val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) {
activityDurationLabel(heldMs)
} else {
null
}
val blockers = sessionBlockers(session)
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (duration != null) {
Text(
text = duration,
color = activityColor(session.activity),
style = MaterialTheme.typography.labelSmall,
)
Spacer(Modifier.width(6.dp))
}
if (session.activity_detail != null) {
Text(
text = session.activity_detail,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(6.dp))
}
Text(
text = programName(session.command),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
)
if (blockers != null) {
Spacer(Modifier.width(6.dp))
BlockedFragment(blockers)
}
if (session.status == SessionStatus.Dead) {
Spacer(Modifier.width(6.dp))
Text(
text = "exited",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun BlockedFragment(blockers: InTheWay) {
Row(verticalAlignment = Alignment.CenterVertically) {
blockerStats(blockers).forEachIndexed { index, stat ->
if (index > 0) Spacer(Modifier.width(4.dp))
Text(
text = stat.text,
style = MaterialTheme.typography.labelSmall,
color = when (stat.kind) {
"add" -> MaterialTheme.colorScheme.primary
"del" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
@Composable
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Rename session") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
},
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@Composable
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
val blockers = sessionBlockers(session)
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Kill “${sessionName(session)}”?") },
text = {
Text(
if (blockers != null) {
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
} else {
"This ends the session's process. This can't be undone."
},
)
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@@ -0,0 +1,161 @@
package pt.naps.maestro.ui.sidebar
import java.time.Instant
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.NameSource
import pt.naps.maestro.data.Session
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
/** Payloads predating #102 default `activity_since` to the Unix epoch;
* anything implausibly old is "never observed" rather than a huge duration. */
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
/** Milliseconds held in the current activity state, or `null` when `since` is
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
fun activityHeldMs(since: String?, nowMs: Long): Long? {
if (since.isNullOrEmpty()) return null
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
if (t < PLAUSIBLE_EPOCH_MS) return null
return (nowMs - t).coerceAtLeast(0L)
}
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
* everywhere so the counter never shows a duration the state hasn't reached. */
fun activityDurationLabel(heldMs: Long): String {
val seconds = heldMs / 1000
if (seconds < 60) return "${seconds}s"
val minutes = seconds / 60
if (minutes < 60) return "${minutes}m"
return "${minutes / 60}h"
}
/** The session's own name (#125), without the command fallback: `null` for a
* session that was never given one. */
fun customSessionName(session: Session): String? {
val element = session.metadata["name"] ?: return null
val primitive = element as? JsonPrimitive ?: return null
if (!primitive.isString) return null
return primitive.content.takeIf { it.isNotEmpty() }
}
/** Line 1's name: `metadata.name` if present, else the command line. */
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
/** Where the session's name came from (#131), read leniently: anything
* unrecognised — including a session named before the field existed — reads
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
fun sessionNameSource(session: Session): NameSource {
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
return when (element.jsonPrimitive.content) {
"Generated" -> NameSource.Generated
"Haiku" -> NameSource.Haiku
else -> NameSource.User
}
}
/** Whether line 1 renders the name muted (#131): a handle the daemon
* generated at spawn and may still replace with one derived from the first
* prompt. */
fun isProvisionalName(session: Session): Boolean =
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
* picks the colour; `text` is what the card prints. */
data class BlockerStat(val kind: String, val text: String)
/** #83: this session's blocked-work breakdown when something actually blocks
* an unforced kill; `null` while clear or not yet probed. */
fun sessionBlockers(session: Session): InTheWay? {
val w = session.blocked_work ?: return null
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
return if (blocked) w else null
}
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
* only tracked change carries no line delta falls back to the file count
* (`~2`) rather than claiming `+0 -0`. */
fun blockerStats(w: InTheWay): List<BlockerStat> {
val stats = mutableListOf<BlockerStat>()
if (w.insertions > 0 || w.deletions > 0) {
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
} else if (w.uncommitted_files > 0) {
stats += BlockerStat("files", "~${w.uncommitted_files}")
}
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
return stats
}
/** The spelled-out phrasing shared by the kill refusal and every client's
* tooltip, so no two describe the same tree differently. */
fun formatInTheWay(w: InTheWay): String {
val parts = mutableListOf<String>()
fun push(n: Int, unit: String) {
if (n <= 0) return
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
}
push(w.uncommitted_files, "uncommitted file")
push(w.untracked_files, "untracked file")
push(w.unpushed_commits, "unpushed commit")
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
}
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
private val PLAIN_WRAPPERS = setOf("sudo", "env")
private val DLX_CAPABLE = setOf("pnpm", "yarn")
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
* dlx`), then basenames what is left. A command that is nothing but wrappers
* is returned as written rather than as an empty string. */
fun programName(command: String): String {
val trimmed = command.trim()
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (words.isEmpty()) return trimmed
var i = 0
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
i++
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
}
if (i >= words.size) return basename(words.last())
when {
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
words[i] == "npx" || words[i] == "bunx" -> i += 1
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
}
val program = words.getOrNull(i) ?: return basename(words.last())
return basename(program)
}
private fun basename(path: String): String = path.substringAfterLast('/')
/** Sessions are grouped by the repository they belong to (#265): a
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
* sorted by name. */
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
/** Group sessions by [sessionGroupKey], each group sorted by name and the
* groups themselves sorted by label. */
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
sessions
.groupBy(::sessionGroupKey)
.map { (key, members) ->
SessionGroup(
key = key,
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
sessions = members.sortedBy { sessionName(it).lowercase() },
)
}
.sortedBy { it.label.lowercase() }
@@ -0,0 +1,123 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import pt.naps.maestro.connection.CatalogStore
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.Uuid
/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked
* faster than that so a duration's cutover lands within one visible frame. */
@Composable
fun rememberTickingNowMs(periodMs: Long = 5_000L): Long {
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(periodMs) {
while (true) {
delay(periodMs)
now = System.currentTimeMillis()
}
}
return now
}
/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions
* grouped into collapsible folders by repository, each rendered as a
* two-line [SessionCard] behind its [ActivityRail]. */
@Composable
fun SidebarContent(
catalogStore: CatalogStore,
onOpenSession: (Session) -> Unit,
modifier: Modifier = Modifier,
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
onKillSession: (Uuid) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
val sessions by catalogStore.sessions.collectAsState()
SidebarContent(
sessions = sessions.values.toList(),
onOpenSession = onOpenSession,
modifier = modifier,
onRenameSession = onRenameSession,
onKillSession = onKillSession,
onSpawnSimilar = onSpawnSimilar,
)
}
/** Same as the [CatalogStore]-driven overload, but takes the session list
* directly — the shape used by tests and previews. */
@Composable
fun SidebarContent(
sessions: List<Session>,
onOpenSession: (Session) -> Unit,
modifier: Modifier = Modifier,
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
onKillSession: (Uuid) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
val nowMs = rememberTickingNowMs()
val groups = remember(sessions) { groupSessions(sessions) }
val collapsed = remember { mutableStateMapOf<String, Boolean>() }
Column(modifier = modifier.fillMaxSize()) {
Text(
text = "Sessions",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
if (groups.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(
text = "No sessions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
for (group in groups) {
val isCollapsed = collapsed[group.key] == true
item(key = "folder:${group.key}") {
FolderNode(
label = group.label,
collapsed = isCollapsed,
onToggle = { collapsed[group.key] = !isCollapsed },
depthPadding = 8,
) {
Column {
for (session in group.sessions) {
SessionCard(
session = session,
nowMs = nowMs,
depthPadding = 16,
onOpen = onOpenSession,
onRename = { s, name -> onRenameSession(s.id, name) },
onKill = { s -> onKillSession(s.id) },
onSpawnSimilar = onSpawnSimilar,
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,117 @@
package pt.naps.maestro.ui.spawn
import kotlinx.serialization.json.JsonPrimitive
import pt.naps.maestro.data.CreateSessionRequest
import pt.naps.maestro.data.SessionView
import pt.naps.maestro.data.SpawnPreset
import pt.naps.maestro.data.WorktreeSpec
/** Tools whose sessions can render as a structured (ACP) transcript instead
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
* `web/src/components/spawn/model.ts`. */
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
/** Commands that just launch another program, looked through to find the
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
/** Program a command line would run, stripped of leading `FOO=bar`
* assignments, known wrappers, its own arguments, and any path prefix. A
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
* client uses — good enough for the preset commands and the common
* `env FOO=1 claude` shapes the spawn form actually produces. */
internal fun resolveProgram(command: String): String {
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
var i = 0
while (i < words.size) {
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
if (i >= words.size) return ""
val word = words[i].substringAfterLast('/').lowercase()
if (word !in WRAPPER_PROGRAMS) return word
i++
while (i < words.size && words[i].startsWith("-")) i++
}
return ""
}
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
* `web/src/components/spawn/model.ts`. */
enum class WorktreeBranchMode { New, Existing }
/** The spawn form's current values, gathered at submit time. Mirrors
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
data class SpawnFormValues(
val command: String,
val cwd: String,
val name: String,
val structuredView: Boolean,
val autoApprove: Boolean,
val worktreeEnabled: Boolean,
val worktreeBranch: String,
val worktreeBranchMode: WorktreeBranchMode,
)
sealed interface SpawnBuildResult {
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
data class Error(val message: String) : SpawnBuildResult
}
/** Turns validated form values into a `POST /api/sessions` body, or an error
* message when something required is missing. Mirrors `buildSpawnRequest` in
* `web/src/components/spawn/model.ts`. */
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
val name = values.name.trim()
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
var worktree: WorktreeSpec? = null
if (values.worktreeEnabled) {
val branch = values.worktreeBranch.trim()
if (baseRepo.isNullOrBlank()) {
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
}
if (branch.isEmpty()) {
return SpawnBuildResult.Error(
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
else "Worktree requires a branch name.",
)
}
worktree = WorktreeSpec(
base_repo = baseRepo,
branch = branch,
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
)
}
return SpawnBuildResult.Ok(
CreateSessionRequest(
cwd = values.cwd,
command = values.command,
metadata = metadata,
worktree = worktree,
view = if (values.structuredView && supportsStructuredView(values.command)) {
SessionView.Structured
} else {
SessionView.Terminal
},
auto_approve = values.autoApprove,
),
)
}
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
SpawnPreset(id = "custom", label = "Custom…", command = ""),
)
@@ -0,0 +1,432 @@
package pt.naps.maestro.ui.spawn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.BranchInfo
import pt.naps.maestro.data.Project
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SpawnPreset
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
* a preset chip row, a searchable project list, a raw-path autocomplete
* fallback, a branch/worktree picker, a session name, and the
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
* [SpawnSheetContent] is the pure, state-driven body used by previews and
* tests. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpawnSheet(
viewModel: SpawnViewModel,
onDismiss: () -> Unit,
onSpawned: (Session) -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
val state by viewModel.state.collectAsState()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
SpawnSheetContent(
state = state,
onSelectPreset = viewModel::selectPreset,
onCustomCommandChange = viewModel::setCustomCommand,
onStructuredViewChange = viewModel::setStructuredView,
onProjectQueryChange = viewModel::setProjectQuery,
onSelectProject = viewModel::selectProject,
onCwdChange = viewModel::setCwd,
onPickDirSuggestion = viewModel::pickDirSuggestion,
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
onBranchModeChange = viewModel::setBranchMode,
onNewBranchChange = viewModel::setNewBranch,
onExistingBranchChange = viewModel::setExistingBranch,
onNameChange = viewModel::setName,
onReroll = viewModel::rerollName,
onDismissError = viewModel::dismissError,
onSubmit = { viewModel.submit(onSpawned) },
)
}
}
@Composable
fun SpawnSheetContent(
state: SpawnUiState,
onSelectPreset: (String) -> Unit,
onCustomCommandChange: (String) -> Unit,
onStructuredViewChange: (Boolean) -> Unit,
onProjectQueryChange: (String) -> Unit,
onSelectProject: (Project) -> Unit,
onCwdChange: (String) -> Unit,
onPickDirSuggestion: (String) -> Unit,
onWorktreeEnabledChange: (Boolean) -> Unit,
onBranchModeChange: (WorktreeBranchMode) -> Unit,
onNewBranchChange: (String) -> Unit,
onExistingBranchChange: (String) -> Unit,
onNameChange: (String) -> Unit,
onReroll: () -> Unit,
onDismissError: () -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.error) {
val message = state.error ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
onDismissError()
}
Box(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
PresetRow(
presets = state.presets,
selectedPresetId = state.selectedPresetId,
onSelectPreset = onSelectPreset,
)
if (state.isCustomSelected) {
OutlinedTextField(
value = state.customCommand,
onValueChange = onCustomCommandChange,
label = { Text("Command") },
placeholder = { Text("bash -l") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
ProjectPicker(
query = state.projectQuery,
onQueryChange = onProjectQueryChange,
projects = state.visibleProjects,
loading = state.projectsLoading,
selectedPath = state.selectedProjectPath,
homeDir = state.homeDir,
onSelectProject = onSelectProject,
)
DirAutocompleteField(
cwd = state.cwd,
suggestions = state.dirSuggestions,
onCwdChange = onCwdChange,
onPickSuggestion = onPickDirSuggestion,
)
BranchSection(
enabled = state.worktreeEnabled,
onEnabledChange = onWorktreeEnabledChange,
baseRepo = state.baseRepo,
mode = state.branchMode,
onModeChange = onBranchModeChange,
newBranch = state.newBranch,
onNewBranchChange = onNewBranchChange,
branches = state.branches,
branchesLoading = state.branchesLoading,
existingBranch = state.effectiveExistingBranch,
onExistingBranchChange = onExistingBranchChange,
)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = state.name,
onValueChange = onNameChange,
label = { Text("Session name") },
placeholder = { Text("named after your first prompt") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
TextButton(onClick = onReroll) { Text("Reroll") }
}
if (supportsStructuredView(state.command)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
Text("Structured view")
}
}
Button(
onClick = onSubmit,
enabled = !state.submitting,
modifier = Modifier.fillMaxWidth(),
) {
if (state.submitting) {
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
} else {
Text("Spawn")
}
}
}
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
Snackbar(snackbarData = it)
}
}
}
/** A row is its own small touch target: tap anywhere in it, not just the
* leading control, to select it. */
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
this.clickable(enabled = enabled, onClick = onClick)
@Composable
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Harness", style = MaterialTheme.typography.labelLarge)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
for (preset in presets) {
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
FilterChip(
selected = preset.id == selectedPresetId,
onClick = { onSelectPreset(preset.id) },
label = { Text(label) },
)
}
}
}
}
@Composable
private fun ProjectPicker(
query: String,
onQueryChange: (String) -> Unit,
projects: List<Project>,
loading: Boolean,
selectedPath: String?,
homeDir: String,
onSelectProject: (Project) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Project", style = MaterialTheme.typography.labelLarge)
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Filter projects…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
when {
loading -> Text(
"Loading projects…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
projects.isEmpty() -> Text(
"No projects match.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
items(projects, key = { it.path }) { project ->
ProjectRow(
project = project,
active = project.path == selectedPath,
homeDir = homeDir,
onClick = { onSelectProject(project) },
)
}
}
}
}
}
@Composable
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(background)
.clickableRow(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
abbreviateHome(parentDir(project.path), homeDir),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun abbreviateHome(path: String, homeDir: String): String =
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
@Composable
private fun DirAutocompleteField(
cwd: String,
suggestions: List<String>,
onCwdChange: (String) -> Unit,
onPickSuggestion: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedTextField(
value = cwd,
onValueChange = onCwdChange,
label = { Text("Or type a directory") },
placeholder = { Text("/home/user/project") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
items(suggestions) { entry ->
Text(
text = "$entry/",
modifier = Modifier
.fillMaxWidth()
.clickableRow { onPickSuggestion(entry) }
.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
@Composable
private fun BranchSection(
enabled: Boolean,
onEnabledChange: (Boolean) -> Unit,
baseRepo: String?,
mode: WorktreeBranchMode,
onModeChange: (WorktreeBranchMode) -> Unit,
newBranch: String,
onNewBranchChange: (String) -> Unit,
branches: List<BranchInfo>,
branchesLoading: Boolean,
existingBranch: String,
onExistingBranchChange: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
Text("Create git worktree")
}
if (!enabled) return@Column
if (baseRepo == null) {
Text(
"Choose a directory inside a git repository first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
Text("New branch")
Spacer(Modifier.width(16.dp))
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
Text("Existing branch")
}
if (mode == WorktreeBranchMode.New) {
OutlinedTextField(
value = newBranch,
onValueChange = onNewBranchChange,
label = { Text("New branch name") },
placeholder = { Text("feat/foo") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
} else {
when {
branchesLoading -> Text(
"Loading branches…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
branches.isEmpty() -> Text(
"No local branches found.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
items(branches, key = { it.name }) { branch ->
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
RadioButton(
selected = branch.name == existingBranch,
onClick = { onExistingBranchChange(branch.name) },
enabled = held == null,
)
Column {
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
if (held != null) {
Text(
"held by $held",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,283 @@
package pt.naps.maestro.ui.spawn
import java.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import pt.naps.maestro.api.MaestroApiException
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.data.BranchInfo
import pt.naps.maestro.data.Project
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SpawnPreset
/** Quiet period after the operator types a raw path before `/api/dirs` and
* `/api/gitroot` fire (#269). */
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
* `parentDir` in `web/src/paths.ts`. */
internal fun parentDir(path: String): String {
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
val slash = trimmed.lastIndexOf('/')
return when {
slash < 0 -> ""
slash == 0 -> "/"
else -> trimmed.substring(0, slash)
}
}
/** Splits a path into its directory and trailing (possibly partial) segment.
* Mirrors `splitPath` in `web/src/paths.ts`. */
internal fun splitPath(value: String): Pair<String, String> {
val slash = value.lastIndexOf('/')
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
}
data class SpawnUiState(
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
val selectedPresetId: String = "",
val customCommand: String = "",
val commandTouched: Boolean = false,
val structuredView: Boolean = false,
val structuredViewTouched: Boolean = false,
val homeDir: String = "",
val projects: List<Project> = emptyList(),
val projectsLoading: Boolean = true,
val projectQuery: String = "",
val selectedProjectPath: String? = null,
val cwd: String = "",
val dirSuggestions: List<String> = emptyList(),
val baseRepo: String? = null,
val worktreeEnabled: Boolean = false,
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
val newBranch: String = "",
val existingBranch: String = "",
val branches: List<BranchInfo> = emptyList(),
val branchesLoading: Boolean = false,
val name: String = "",
val submitting: Boolean = false,
val error: String? = null,
) {
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
val visibleProjects: List<Project>
get() {
val query = projectQuery.trim().lowercase()
val filtered = if (query.isEmpty()) {
projects
} else {
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
}
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
}
val effectiveExistingBranch: String
get() = existingBranch.ifEmpty {
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
}
}
/**
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
* for a short-lived form, and this keeps the class trivially testable with a
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
*/
class SpawnViewModel(
private val client: MaestroClient,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val _state = MutableStateFlow(SpawnUiState())
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
private var dirJob: Job? = null
private var branchesJob: Job? = null
init {
scope.launch {
val info = runCatching { client.getDaemonInfo() }.getOrNull()
_state.update { s ->
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
} else {
s.selectedPresetId
}
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
.withSyncedStructuredView()
}
}
scope.launch {
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
_state.update { it.copy(projects = projects, projectsLoading = false) }
}
}
fun selectPreset(id: String) {
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
}
fun setCustomCommand(text: String) {
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
}
fun setStructuredView(enabled: Boolean) {
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
}
/** Re-derives the structured-view checkbox from the command this state
* carries, unless the operator has already ticked/unticked it by hand. */
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
fun setProjectQuery(query: String) {
_state.update { it.copy(projectQuery = query) }
}
fun selectProject(project: Project) {
dirJob?.cancel()
_state.update {
it.copy(
selectedProjectPath = project.path,
cwd = project.path,
dirSuggestions = emptyList(),
baseRepo = project.base_repo ?: project.path,
branches = emptyList(),
existingBranch = "",
)
}
}
/** The operator typed a path by hand: clears any project pick, and
* schedules a debounced refresh of both the directory-suggestion
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
fun setCwd(raw: String) {
_state.update {
it.copy(
cwd = raw,
selectedProjectPath = null,
baseRepo = null,
branches = emptyList(),
existingBranch = "",
)
}
dirJob?.cancel()
dirJob = scope.launch {
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
refreshDirSuggestions(raw)
refreshBaseRepo(raw)
}
}
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
* drill-in gesture the web `DirAutocomplete` uses. */
fun pickDirSuggestion(entry: String) {
val (dir, _) = splitPath(_state.value.cwd)
setCwd("$dir$entry/")
}
private suspend fun refreshDirSuggestions(raw: String) {
val (dir, prefix) = splitPath(raw)
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
if (_state.value.cwd != raw) return // a newer edit superseded this one
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
_state.update { it.copy(dirSuggestions = filtered) }
}
private suspend fun refreshBaseRepo(raw: String) {
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
if (_state.value.cwd != raw) return // a newer edit superseded this one
_state.update { it.copy(baseRepo = root) }
}
fun setWorktreeEnabled(enabled: Boolean) {
_state.update { it.copy(worktreeEnabled = enabled) }
}
fun setBranchMode(mode: WorktreeBranchMode) {
_state.update { it.copy(branchMode = mode) }
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
}
fun setNewBranch(text: String) {
_state.update { it.copy(newBranch = text) }
}
fun setExistingBranch(text: String) {
_state.update { it.copy(existingBranch = text) }
}
private fun loadBranchesIfNeeded() {
val cwd = _state.value.cwd
if (cwd.isEmpty() || _state.value.branchesLoading) return
branchesJob?.cancel()
_state.update { it.copy(branchesLoading = true) }
branchesJob = scope.launch {
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
_state.update { it.copy(branches = branches, branchesLoading = false) }
}
}
fun setName(text: String) {
_state.update { it.copy(name = text) }
}
/** `GET /api/names?count=1`: replaces the session-name field with a
* freshly generated handle. */
fun rerollName() {
scope.launch {
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
if (handle != null) _state.update { it.copy(name = handle) }
}
}
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
* session. Any failure — validation or API — lands in
* [SpawnUiState.error] instead. */
fun submit(onSuccess: (Session) -> Unit) {
val s = _state.value
val values = SpawnFormValues(
command = s.command,
cwd = s.cwd,
name = s.name,
structuredView = s.structuredView,
autoApprove = s.activePreset?.auto_approve ?: false,
worktreeEnabled = s.worktreeEnabled,
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
worktreeBranchMode = s.branchMode,
)
when (val built = buildSpawnRequest(values, s.baseRepo)) {
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
is SpawnBuildResult.Ok -> {
_state.update { it.copy(submitting = true, error = null) }
scope.launch {
try {
val session = client.createSession(built.request)
_state.update { it.copy(submitting = false) }
onSuccess(session)
} catch (e: MaestroApiException) {
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
} catch (e: Exception) {
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
}
}
}
}
}
fun dismissError() {
_state.update { it.copy(error = null) }
}
}
@@ -0,0 +1,173 @@
package pt.naps.maestro.ui.structured
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.longOrNull
import pt.naps.maestro.data.ReducedState
import pt.naps.maestro.data.TranscriptRow
import pt.naps.maestro.data.TranscriptRowKind
/**
* Loose parsing of the opaque ACP JSON a [TranscriptRow.data] /
* [ReducedState] field carries (docs/STRUCTURED-VIEW.md: "shaped to
* `claude-code-acp`'s ACP v1 schema, not covered by maestro-protocol's own
* compatibility guarantee"). Every accessor degrades to a default rather
* than throwing on an unexpected shape — ported from `web/src/types.ts`'s
* `Acp*` interfaces and `model.ts`'s readers.
*/
internal fun JsonObject.stringField(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
internal fun JsonObject.longField(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
sealed class AcpContentBlock {
data class Text(val text: String) : AcpContentBlock()
data class Image(val mimeType: String?, val data: String?, val uri: String?) : AcpContentBlock()
data class Audio(val mimeType: String?, val data: String?) : AcpContentBlock()
data class ResourceLink(val name: String?, val uri: String?) : AcpContentBlock()
data class Resource(val uri: String?, val text: String?) : AcpContentBlock()
object Unknown : AcpContentBlock()
}
sealed class AcpToolCallContent {
data class Content(val block: AcpContentBlock) : AcpToolCallContent()
data class Diff(val path: String, val oldText: String?, val newText: String) : AcpToolCallContent()
data class Terminal(val terminalId: String?) : AcpToolCallContent()
}
data class AcpToolCallLike(
val title: String? = null,
val name: String? = null,
val kind: String? = null,
val status: String? = null,
val content: List<AcpToolCallContent> = emptyList(),
val locations: List<String> = emptyList(),
val rawInput: JsonElement? = null,
val rawOutput: JsonElement? = null,
)
data class AcpPermissionOption(val optionId: String, val name: String, val kind: String)
data class AcpApprovalRequest(val toolCall: AcpToolCallLike, val options: List<AcpPermissionOption>)
/** `maestroTurnMeta` — the daemon-owned durable footer for a settled turn. */
data class TurnMetaPayload(
val elapsedMs: Long,
val outcome: String,
val model: String? = null,
val contextTokens: Long? = null,
val contextDelta: Long? = null,
)
fun parseContentBlock(element: JsonElement?): AcpContentBlock {
val obj = element as? JsonObject ?: return AcpContentBlock.Unknown
return when (obj.stringField("type")) {
"text" -> AcpContentBlock.Text(obj.stringField("text").orEmpty())
"image" -> AcpContentBlock.Image(obj.stringField("mimeType"), obj.stringField("data"), obj.stringField("uri"))
"audio" -> AcpContentBlock.Audio(obj.stringField("mimeType"), obj.stringField("data"))
"resource_link" -> AcpContentBlock.ResourceLink(obj.stringField("name"), obj.stringField("uri"))
"resource" -> {
val resource = obj["resource"] as? JsonObject
AcpContentBlock.Resource(resource?.stringField("uri"), resource?.stringField("text"))
}
else -> AcpContentBlock.Unknown
}
}
/** Content-block text, or a labelled placeholder for anything that isn't
* text — never dropped silently. Mirrors `model.ts`'s `contentBlockText`. */
fun contentBlockText(block: AcpContentBlock): String = when (block) {
is AcpContentBlock.Text -> block.text
is AcpContentBlock.Image -> "[image]"
is AcpContentBlock.Audio -> "[audio]"
is AcpContentBlock.ResourceLink -> "[resource: ${block.name ?: block.uri ?: "linked"}]"
is AcpContentBlock.Resource -> "[resource]"
AcpContentBlock.Unknown -> ""
}
/** A `User`/`AgentText`/`AgentThought` row's `data` is a `ContentChunk`:
* `{content: ContentBlock, messageId?}`. */
fun contentChunkText(data: JsonElement): String {
val obj = data as? JsonObject ?: return ""
return contentBlockText(parseContentBlock(obj["content"]))
}
private fun parseToolCallContent(element: JsonElement): AcpToolCallContent {
val obj = element as? JsonObject ?: return AcpToolCallContent.Terminal(null)
return when (obj.stringField("type")) {
"content" -> AcpToolCallContent.Content(parseContentBlock(obj["content"]))
"diff" -> AcpToolCallContent.Diff(
path = obj.stringField("path").orEmpty(),
oldText = obj.stringField("oldText"),
newText = obj.stringField("newText").orEmpty(),
)
else -> AcpToolCallContent.Terminal(obj.stringField("terminalId"))
}
}
/** A `ToolCall`/`ToolDiff` row's `data`, or an `Approval` row's
* `data.toolCall` (`AcpToolCallLike` — both share this shape). */
fun parseToolCall(data: JsonElement): AcpToolCallLike {
val obj = data as? JsonObject ?: return AcpToolCallLike()
return AcpToolCallLike(
title = obj.stringField("title"),
name = obj.stringField("name"),
kind = obj.stringField("kind"),
status = obj.stringField("status"),
content = (obj["content"] as? JsonArray)?.map(::parseToolCallContent) ?: emptyList(),
locations = (obj["locations"] as? JsonArray)
?.mapNotNull { (it as? JsonObject)?.stringField("path") }
?: emptyList(),
rawInput = obj["rawInput"],
rawOutput = obj["rawOutput"],
)
}
/** An `Approval` row's `data` — a `RequestPermissionRequest` serialized
* without its nonce (docs/STRUCTURED-VIEW.md's "Approvals"). */
fun parseApprovalRequest(data: JsonElement): AcpApprovalRequest {
val obj = data as? JsonObject ?: return AcpApprovalRequest(AcpToolCallLike(), emptyList())
val toolCall = (obj["toolCall"] as? JsonObject)?.let(::parseToolCall) ?: AcpToolCallLike()
val options = (obj["options"] as? JsonArray)?.mapNotNull { option ->
val o = option as? JsonObject ?: return@mapNotNull null
val optionId = o.stringField("optionId") ?: return@mapNotNull null
val name = o.stringField("name") ?: return@mapNotNull null
val kind = o.stringField("kind") ?: return@mapNotNull null
AcpPermissionOption(optionId, name, kind)
} ?: emptyList()
return AcpApprovalRequest(toolCall, options)
}
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a
* completed, cancelled or failed turn's durable footer — `null` for every
* other notice. */
fun parseTurnMeta(row: TranscriptRow): TurnMetaPayload? {
if (row.kind != TranscriptRowKind.Notice) return null
val obj = row.data as? JsonObject ?: return null
val meta = obj["maestroTurnMeta"] as? JsonObject ?: return null
val elapsedMs = meta.longField("elapsedMs") ?: return null
val outcome = meta.stringField("outcome") ?: return null
return TurnMetaPayload(
elapsedMs = elapsedMs,
outcome = outcome,
model = meta.stringField("model"),
contextTokens = meta.longField("contextTokens"),
contextDelta = meta.longField("contextDelta"),
)
}
/** Notices the client consumes as state rather than showing in the
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
fun isStateOnlyNotice(row: TranscriptRow): Boolean {
if (row.kind != TranscriptRowKind.Notice) return false
val update = (row.data as? JsonObject)?.stringField("sessionUpdate")
return update == "available_commands_update" || update == "session_info_update"
}
/** `ReducedState.pending_approval` is `{nonce, request}` while a permission
* request is live, absent otherwise. */
fun pendingApprovalNonce(state: ReducedState): String? =
(state.pending_approval as? JsonObject)?.stringField("nonce")
@@ -0,0 +1,92 @@
package pt.naps.maestro.ui.structured
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.StructuredApprovalDecision
private val DECISION_LABELS = listOf(
StructuredApprovalDecision.AllowOnce to "Allow once",
StructuredApprovalDecision.AllowForSession to "Allow for session",
StructuredApprovalDecision.Deny to "Deny",
)
/**
* An `Approval` row. The daemon exposes exactly three decisions
* (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
* them. A decision the request offers no matching ACP option for is not
* shown — its button would "allow" something that silently resolves
* `Cancelled`.
*/
@Composable
fun ApprovalCard(
request: AcpApprovalRequest,
pending: Boolean,
busy: Boolean,
onDecide: (StructuredApprovalDecision) -> Unit,
modifier: Modifier = Modifier,
) {
val what = request.toolCall.title ?: StructuredModel.toolKindLabel(request.toolCall.kind)
if (!pending) {
Text(
text = "$what — approved",
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
val offered = StructuredModel.offeredDecisions(request.options)
val path = request.toolCall.locations.firstOrNull()
Column(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f), RoundedCornerShape(8.dp))
.padding(12.dp),
) {
Text("⚠ Permission requested", style = MaterialTheme.typography.labelMedium)
Text(what, style = MaterialTheme.typography.bodyMedium)
if (path != null) {
Text(path, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (offered.isEmpty()) {
Text(
"no decision offered",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for ((decision, label) in DECISION_LABELS) {
if (decision !in offered) continue
val isDeny = decision == StructuredApprovalDecision.Deny
Button(
onClick = { onDecide(decision) },
enabled = !busy,
colors = if (isDeny) {
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
} else {
ButtonDefaults.buttonColors()
},
) {
Text(label)
}
}
}
}
}
}
@@ -0,0 +1,53 @@
package pt.naps.maestro.ui.structured
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Multi-line draft input plus send/cancel (#267). Sending is the pane's
* job — this only hands back the current draft text.
*/
@Composable
fun Composer(
draft: String,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
onCancel: () -> Unit,
turnActive: Boolean,
sendEnabled: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.Bottom,
) {
OutlinedTextField(
value = draft,
onValueChange = onDraftChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Reply…") },
minLines = 1,
maxLines = 6,
)
Spacer(Modifier.width(4.dp))
if (turnActive) {
IconButton(onClick = onCancel) {
Text(text = "■", color = MaterialTheme.colorScheme.error)
}
}
IconButton(onClick = onSend, enabled = sendEnabled && draft.isNotBlank()) {
Text(text = "➤")
}
}
}
@@ -0,0 +1,155 @@
package pt.naps.maestro.ui.structured
/** One rendered line of a [ToolDiff][pt.naps.maestro.data.TranscriptRowKind]
* content block. A `Hunk` line is a human summary, not source text; an `Eof`
* line marks a missing trailing newline. Ported from `web/src/components/
* structured/diff.ts`. */
data class DiffLine(val kind: Kind, val text: String) {
enum class Kind { Add, Del, Ctx, Hunk, Eof }
}
/**
* Line-level diff for a `Diff` content block (#140). ACP hands over whole
* `oldText`/`newText`, not a unified-diff patch, so the coloured hunks a
* diff view needs are computed here rather than parsed out of a patch.
*/
object DiffModel {
/** Interior unchanged runs longer than this collapse to a summary line
* flanked by this many lines of real context on each side. */
private const val CONTEXT = 3
/** Bounds the O(n·m) LCS table: above this cell count, the changed
* middle is shown as one coarse del-then-add block instead of a
* fine-grained diff — still correct, just not minimal. */
private const val LCS_CELL_LIMIT = 250_000
private data class SourceLine(val text: String, val terminated: Boolean)
private fun splitLines(text: String): List<SourceLine> {
if (text.isEmpty()) return emptyList()
val hasFinalNewline = text.endsWith("\n")
val parts = text.split("\n").let { if (hasFinalNewline) it.dropLast(1) else it }
return parts.mapIndexed { index, line -> SourceLine(line, index < parts.size - 1 || hasFinalNewline) }
}
private data class Op(val kind: DiffLine.Kind, val line: SourceLine)
private fun sameLine(a: SourceLine, b: SourceLine) = a.text == b.text && a.terminated == b.terminated
/** Longest-common-subsequence diff of two line arrays, already trimmed
* of any shared prefix/suffix by the caller. */
private fun lcsDiff(a: List<SourceLine>, b: List<SourceLine>): List<Op> {
val n = a.size
val m = b.size
if (n == 0) return b.map { Op(DiffLine.Kind.Add, it) }
if (m == 0) return a.map { Op(DiffLine.Kind.Del, it) }
if (n.toLong() * m.toLong() > LCS_CELL_LIMIT) {
return a.map { Op(DiffLine.Kind.Del, it) } + b.map { Op(DiffLine.Kind.Add, it) }
}
val columns = m + 1
val dp = IntArray((n + 1) * columns)
for (i in n - 1 downTo 0) {
for (j in m - 1 downTo 0) {
dp[i * columns + j] = if (sameLine(a[i], b[j])) {
dp[(i + 1) * columns + j + 1] + 1
} else {
maxOf(dp[(i + 1) * columns + j], dp[i * columns + j + 1])
}
}
}
val ops = mutableListOf<Op>()
var i = 0
var j = 0
while (i < n && j < m) {
when {
sameLine(a[i], b[j]) -> {
ops.add(Op(DiffLine.Kind.Ctx, a[i]))
i++
j++
}
dp[(i + 1) * columns + j] >= dp[i * columns + j + 1] -> {
ops.add(Op(DiffLine.Kind.Del, a[i]))
i++
}
else -> {
ops.add(Op(DiffLine.Kind.Add, b[j]))
j++
}
}
}
while (i < n) ops.add(Op(DiffLine.Kind.Del, a[i++]))
while (j < m) ops.add(Op(DiffLine.Kind.Add, b[j++]))
return ops
}
private fun diffLines(a: List<SourceLine>, b: List<SourceLine>): List<Op> {
var pre = 0
while (pre < a.size && pre < b.size && sameLine(a[pre], b[pre])) pre++
var suf = 0
while (suf < a.size - pre && suf < b.size - pre && sameLine(a[a.size - 1 - suf], b[b.size - 1 - suf])) suf++
val prefixCtx = a.subList(0, pre).map { Op(DiffLine.Kind.Ctx, it) }
val suffixCtx = a.subList(a.size - suf, a.size).map { Op(DiffLine.Kind.Ctx, it) }
val middle = lcsDiff(a.subList(pre, a.size - suf), b.subList(pre, b.size - suf))
return prefixCtx + middle + suffixCtx
}
/** Collapse a long run of unchanged lines to a summary, keeping up to
* [CONTEXT] real lines adjacent to each neighbouring change. */
private fun hunk(ops: List<Op>): List<DiffLine> {
val out = mutableListOf<DiffLine>()
fun push(op: Op) {
out.add(DiffLine(op.kind, op.line.text))
if (!op.line.terminated) out.add(DiffLine(DiffLine.Kind.Eof, "\\ No newline at end of file"))
}
var i = 0
while (i < ops.size) {
if (ops[i].kind != DiffLine.Kind.Ctx) {
push(ops[i])
i++
continue
}
var j = i
while (j < ops.size && ops[j].kind == DiffLine.Kind.Ctx) j++
val run = ops.subList(i, j)
val wholeDiff = i == 0 && j == ops.size
val keepHead = if (wholeDiff || i > 0) CONTEXT else 0
val keepTail = if (wholeDiff || j < ops.size) CONTEXT else 0
if (run.size <= keepHead + keepTail) {
run.forEach(::push)
} else {
if (keepHead > 0) run.subList(0, keepHead).forEach(::push)
val collapsed = run.subList(keepHead, run.size - keepTail)
out.add(DiffLine(DiffLine.Kind.Hunk, "⋯ ${collapsed.size} unchanged lines ⋯"))
if (collapsed.any { !it.line.terminated }) {
out.add(DiffLine(DiffLine.Kind.Eof, "\\ No newline at end of file"))
}
if (keepTail > 0) run.subList(run.size - keepTail, run.size).forEach(::push)
}
i = j
}
return out
}
/** Coloured, hunked diff lines for a `Diff` content block. `oldText`
* absent (a new file) renders every line of `newText` as added. */
fun computeDiffLines(oldText: String?, newText: String): List<DiffLine> {
val newLines = splitLines(newText)
if (oldText == null) return hunk(newLines.map { Op(DiffLine.Kind.Add, it) })
return hunk(diffLines(splitLines(oldText), newLines))
}
data class Stats(val added: Int, val removed: Int)
/** `+added -removed` counts straight off the diff — real derived data,
* never a fabricated count. */
fun diffStats(lines: List<DiffLine>): Stats {
var added = 0
var removed = 0
for (line in lines) when (line.kind) {
DiffLine.Kind.Add -> added++
DiffLine.Kind.Del -> removed++
else -> {}
}
return Stats(added, removed)
}
}
@@ -0,0 +1,50 @@
package pt.naps.maestro.ui.structured
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
private val DiffAddColor = Color(0xFF2E7D32)
private val DiffAddBackground = Color(0x1A2E7D32)
private val DiffDelBackground = Color(0x1AD32F2F)
/** A `ToolDiff` row's diff content: `+` lines green, `-` lines red
* (docs/STRUCTURED-VIEW.md's "diff view" is a client decision). Lines are
* pre-computed by [DiffModel]. */
@Composable
fun DiffView(path: String, oldText: String?, newText: String, modifier: Modifier = Modifier) {
val lines = remember(oldText, newText) { DiffModel.computeDiffLines(oldText, newText) }
Column(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
text = path,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
for (line in lines) {
val (prefix, color, background) = when (line.kind) {
DiffLine.Kind.Add -> Triple("+ ", DiffAddColor, DiffAddBackground)
DiffLine.Kind.Del -> Triple("- ", MaterialTheme.colorScheme.error, DiffDelBackground)
DiffLine.Kind.Ctx -> Triple(" ", MaterialTheme.colorScheme.onSurfaceVariant, Color.Transparent)
DiffLine.Kind.Hunk, DiffLine.Kind.Eof -> Triple("", MaterialTheme.colorScheme.onSurfaceVariant, Color.Transparent)
}
Text(
text = prefix + line.text,
color = color,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier
.fillMaxWidth()
.background(background)
.padding(horizontal = 4.dp),
)
}
}
}
@@ -0,0 +1,91 @@
package pt.naps.maestro.ui.structured
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
/**
* Minimal markdown-to-[AnnotatedString] renderer for agent replies (#267
* v1): fenced code blocks, `#` headers, `-`/`*` bullets, and inline
* `**bold**`/`*italic*`/`` `code` ``. Not a CommonMark implementation —
* good enough for model prose without pulling in a markdown library.
*/
fun renderSimpleMarkdown(text: String): AnnotatedString = buildAnnotatedString {
var inCodeBlock = false
val lines = text.split("\n")
lines.forEachIndexed { index, rawLine ->
when {
rawLine.trim().startsWith("```") -> inCodeBlock = !inCodeBlock
inCodeBlock -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(rawLine) }
else -> {
val header = HEADER_PATTERN.matchEntire(rawLine)
val bullet = BULLET_PATTERN.matchEntire(rawLine)
when {
header != null -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
appendInlineMarkdown(header.groupValues[2])
}
bullet != null -> {
append("• ")
appendInlineMarkdown(bullet.groupValues[1])
}
else -> appendInlineMarkdown(rawLine)
}
}
}
if (index != lines.lastIndex) append("\n")
}
}
private val HEADER_PATTERN = Regex("^(#{1,6})\\s+(.*)$")
private val BULLET_PATTERN = Regex("^[-*]\\s+(.*)$")
/** Scans for the next inline marker (`` ` ``, `**`, `*`) and applies its
* span style, falling back to plain text for anything before it. */
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
var i = 0
while (i < text.length) {
when {
text.startsWith("**", i) -> {
val end = text.indexOf("**", i + 2)
if (end == -1) {
append(text.substring(i))
i = text.length
} else {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }
i = end + 2
}
}
text.startsWith("`", i) -> {
val end = text.indexOf("`", i + 1)
if (end == -1) {
append(text.substring(i))
i = text.length
} else {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }
i = end + 1
}
}
text.startsWith("*", i) -> {
val end = text.indexOf("*", i + 1)
if (end == -1) {
append(text.substring(i))
i = text.length
} else {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) }
i = end + 1
}
}
else -> {
val next = listOf(text.indexOf("**", i), text.indexOf("`", i), text.indexOf("*", i))
.filter { it >= 0 }
.minOrNull() ?: text.length
append(text.substring(i, next))
i = next
}
}
}
}
@@ -0,0 +1,197 @@
package pt.naps.maestro.ui.structured
import pt.naps.maestro.data.ReducedState
import pt.naps.maestro.data.StructuredApprovalDecision
import pt.naps.maestro.data.TranscriptRow
import pt.naps.maestro.data.TranscriptRowKind
import kotlin.math.abs
/**
* Pure transcript logic for the structured pane (#267), kept out of the
* composables so it is testable without Compose. Ported from
* `web/src/components/structured/model.ts`; docs/STRUCTURED-VIEW.md is the
* contract for what a client, versus the daemon, decides.
*/
object StructuredModel {
private val KIND_LABELS = mapOf(
"read" to "Read",
"edit" to "Edit",
"delete" to "Delete",
"move" to "Move",
"search" to "Search",
"execute" to "Execute",
"think" to "Think",
"fetch" to "Fetch",
"switch_mode" to "Switch mode",
)
/** Human label behind a `ToolKind`. An unrecognised/absent kind is
* "Tool" — never a blank label. */
fun toolKindLabel(kind: String?): String = kind?.let { KIND_LABELS[it] } ?: "Tool"
data class ToolTitle(val label: String, val arg: String)
private val TITLE_PATTERN = Regex("^(\\S+)\\(([\\s\\S]*)\\)$")
/** Split a title into `{label, arg}` when it follows the adapter's own
* `Name(argument)` convention; otherwise the whole title is the arg and
* `kind` supplies a generic label. */
fun splitToolTitle(title: String?, name: String?, kind: String?): ToolTitle {
val label = name ?: toolKindLabel(kind)
if (title.isNullOrEmpty()) return ToolTitle(label, "")
val match = TITLE_PATTERN.matchEntire(title)
return if (match != null) {
ToolTitle(name ?: match.groupValues[1], match.groupValues[2])
} else {
ToolTitle(label, title)
}
}
data class StatusMeta(val text: String, val kind: String)
fun statusMeta(status: String?, hasDiff: Boolean): StatusMeta? = when {
status == "in_progress" -> StatusMeta("running", "running")
status == "failed" -> StatusMeta("failed", "failed")
status == "pending" && !hasDiff -> StatusMeta("pending", "pending")
else -> null
}
fun lastRowIdOfKind(rows: List<TranscriptRow>, kind: TranscriptRowKind): String? =
rows.lastOrNull { it.kind == kind }?.id
/** The row id a live `pending_approval` names: the newest `Approval`
* row. Only one approval is ever live (docs/STRUCTURED-VIEW.md), so the
* last one is the pending one. */
fun pendingApprovalRowId(rows: List<TranscriptRow>, state: ReducedState): String? =
if (state.pending_approval != null) lastRowIdOfKind(rows, TranscriptRowKind.Approval) else null
fun turnIsActive(state: ReducedState): Boolean = state.turn.responding || state.turn.thinking
/** Which decisions a permission request actually backs, by the
* `PermissionOptionKind` values it offers. A decision with no matching
* option resolves `Cancelled` server-side regardless — offering its
* button would let the operator "allow" something that silently
* no-ops. */
fun offeredDecisions(options: List<AcpPermissionOption>): List<StructuredApprovalDecision> {
val kinds = options.map { it.kind }.toSet()
val decisions = mutableListOf<StructuredApprovalDecision>()
if ("allow_once" in kinds) decisions += StructuredApprovalDecision.AllowOnce
if ("allow_always" in kinds) decisions += StructuredApprovalDecision.AllowForSession
if ("reject_once" in kinds || "reject_always" in kinds) decisions += StructuredApprovalDecision.Deny
return decisions
}
data class ActivityGroup(
val toolCount: Int,
val rows: List<TranscriptRow>,
val lastToolLabel: String,
val lastToolArg: String,
)
/**
* Group contiguous tool rows (and resolved approvals between them) into
* collapsible blocks. An `AgentText`/`AgentThought` row between tools
* splits the run. A group must contain at least two tools to qualify; a
* single tool stays standalone. Keyed by `turn:<userId>:<seq>`.
*/
fun activityGroupsByTurn(rows: List<TranscriptRow>, pendingApprovalId: String?): Map<String, ActivityGroup> {
val groups = LinkedHashMap<String, ActivityGroup>()
var turnId = "before-first-message"
var runSeq = 0
var runTools = mutableListOf<TranscriptRow>()
var runPending = mutableListOf<TranscriptRow>()
fun flushRun() {
if (runTools.size >= 2) {
val gid = "turn:$turnId:$runSeq"
val tool = parseToolCall(runTools.last().data)
val title = splitToolTitle(tool.title, tool.name, tool.kind)
groups[gid] = ActivityGroup(runTools.size, runTools.toList(), title.label, title.arg)
runSeq++
}
runTools = mutableListOf()
runPending = mutableListOf()
}
for (row in rows) {
when {
row.kind == TranscriptRowKind.User -> {
flushRun()
turnId = row.id
runSeq = 0
}
row.kind == TranscriptRowKind.AgentText || row.kind == TranscriptRowKind.AgentThought -> flushRun()
row.kind == TranscriptRowKind.Approval && row.id != pendingApprovalId -> runPending.add(row)
row.kind == TranscriptRowKind.ToolCall || row.kind == TranscriptRowKind.ToolDiff -> {
runTools.addAll(runPending)
runPending = mutableListOf()
runTools.add(row)
}
else -> {}
}
}
flushRun()
return groups
}
/** One entry of the flattened render list: either a standalone row or a
* whole activity group. */
sealed class TranscriptItem {
data class RowItem(val row: TranscriptRow) : TranscriptItem()
data class GroupItem(val id: String, val group: ActivityGroup) : TranscriptItem()
}
/**
* The transcript in render order: rows, with each contiguous tool
* stretch folded into a single group item where one exists. `Plan` rows
* and state-only notices are dropped (both clients omit `Plan`). A turn
* footer only survives as the final item, so old footers stop reading
* like transcript content.
*/
fun transcriptItems(rows: List<TranscriptRow>, pendingApprovalId: String?): List<TranscriptItem> {
val groups = activityGroupsByTurn(rows, pendingApprovalId)
val rowToGroup = HashMap<String, String>()
for ((groupId, group) in groups) for (r in group.rows) rowToGroup[r.id] = groupId
val items = mutableListOf<TranscriptItem>()
val emitted = mutableSetOf<String>()
for (row in rows) {
val groupId = rowToGroup[row.id]
if (groupId != null) {
if (emitted.add(groupId)) items.add(TranscriptItem.GroupItem(groupId, groups.getValue(groupId)))
continue
}
if (row.kind == TranscriptRowKind.Plan) continue
if (row.kind == TranscriptRowKind.Notice && isStateOnlyNotice(row)) continue
if (row.kind == TranscriptRowKind.Approval && row.id != pendingApprovalId) continue
items.add(TranscriptItem.RowItem(row))
}
return items.filterIndexed { index, item ->
!(item is TranscriptItem.RowItem && parseTurnMeta(item.row) != null) || index == items.size - 1
}
}
fun formatElapsed(milliseconds: Long): String {
val seconds = maxOf(0L, milliseconds / 1_000)
if (seconds < 60) return "${seconds}s"
val minutes = seconds / 60
val remainder = seconds % 60
return if (remainder == 0L) "${minutes}m" else "${minutes}m ${remainder}s"
}
private fun compactNumber(value: Long): String {
val magnitude = abs(value)
return when {
magnitude < 1_000 -> value.toString()
magnitude < 1_000_000 -> {
if (value % 1_000 == 0L) "${value / 1_000}k" else "%.1fk".format(value / 1_000.0)
}
else -> {
if (value % 1_000_000 == 0L) "${value / 1_000_000}m" else "%.1fm".format(value / 1_000_000.0)
}
}
}
fun formatContextTokens(value: Long): String = "${compactNumber(value)} context"
}
@@ -0,0 +1,182 @@
package pt.naps.maestro.ui.structured
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.collect
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.api.MaestroWebSocket
import pt.naps.maestro.data.StructuredApprovalDecision
import pt.naps.maestro.data.Uuid
/**
* Full-screen structured pane for one session (issue #267): an ACP
* transcript over `WS /api/structured/:id`, the composer that drives
* `.../prompt` and `.../cancel`, and the approval controls that answer
* `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StructuredScreen(
daemonBaseUrl: String,
sessionId: Uuid,
sessionTitle: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val client = remember(daemonBaseUrl) { MaestroClient(daemonBaseUrl) }
val webSocket = remember(daemonBaseUrl) { MaestroWebSocket(daemonBaseUrl) }
val viewModel = remember(sessionId, daemonBaseUrl) { StructuredViewModel(client, webSocket, sessionId) }
DisposableEffect(viewModel) { onDispose { viewModel.close() } }
val uiState by viewModel.state.collectAsState()
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(sessionTitle) },
navigationIcon = {
IconButton(onClick = onBack) {
Text(text = "←", style = MaterialTheme.typography.titleLarge)
}
},
)
},
) { innerPadding ->
StructuredPane(
state = uiState,
onDraftChange = viewModel::setDraft,
onSend = viewModel::sendPrompt,
onCancel = viewModel::cancelTurn,
onDecide = viewModel::resolveApproval,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
/** The pane's body, independent of the screen chrome above — easier to
* preview/test without a daemon or a `Scaffold`. */
@Composable
fun StructuredPane(
state: StructuredUiState,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
onCancel: () -> Unit,
onDecide: (rowId: String, decision: StructuredApprovalDecision) -> Unit,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
var expandedRows by remember { mutableStateOf(setOf<String>()) }
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
var followBottom by remember { mutableStateOf(true) }
val items = state.items
val pendingRowId = state.pendingApprovalRowId
// Track whether the operator is at (or near) the bottom, so new content
// only pulls the view down while they were already following it.
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo }.collect { info ->
val last = info.visibleItemsInfo.lastOrNull()
followBottom = last == null || last.index >= info.totalItemsCount - 1
}
}
LaunchedEffect(items, state.activityLabel()) {
if (followBottom && items.isNotEmpty()) {
listState.scrollToItem(items.size - 1)
}
}
Column(modifier = modifier) {
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
) {
items(
items,
key = { item ->
when (item) {
is StructuredModel.TranscriptItem.RowItem -> item.row.id
is StructuredModel.TranscriptItem.GroupItem -> item.id
}
},
) { item ->
when (item) {
is StructuredModel.TranscriptItem.RowItem -> TranscriptRowView(
row = item.row,
pendingApproval = item.row.id == pendingRowId,
approvalBusy = state.approvalBusy,
expanded = expandedRows.contains(item.row.id),
onExpandedChange = { expanded -> expandedRows = toggled(expandedRows, item.row.id, expanded) },
onDecide = { decision -> onDecide(item.row.id, decision) },
)
is StructuredModel.TranscriptItem.GroupItem -> ToolGroup(
toolCount = item.group.toolCount,
lastToolLabel = item.group.lastToolLabel,
lastToolArg = item.group.lastToolArg,
expanded = expandedGroups.contains(item.id),
onExpandedChange = { expanded -> expandedGroups = toggled(expandedGroups, item.id, expanded) },
) {
for (row in item.group.rows) {
TranscriptRowView(
row = row,
pendingApproval = row.id == pendingRowId,
approvalBusy = state.approvalBusy,
expanded = expandedRows.contains(row.id),
onExpandedChange = { expanded -> expandedRows = toggled(expandedRows, row.id, expanded) },
onDecide = { decision -> onDecide(row.id, decision) },
)
}
}
}
}
}
TurnStatusBar(label = state.activityLabel())
if (state.error != null) {
Text(
text = state.error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
)
}
Composer(
draft = state.draft,
onDraftChange = onDraftChange,
onSend = onSend,
onCancel = onCancel,
turnActive = state.turnActive,
sendEnabled = !state.posting,
)
}
}
private fun toggled(set: Set<String>, id: String, expanded: Boolean): Set<String> =
if (expanded) set + id else set - id
@@ -0,0 +1,215 @@
package pt.naps.maestro.ui.structured
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.api.MaestroWebSocket
import pt.naps.maestro.api.StructuredConnection
import pt.naps.maestro.data.ReducedState
import pt.naps.maestro.data.StructuredApprovalDecision
import pt.naps.maestro.data.StructuredEvent
import pt.naps.maestro.data.TranscriptDelta
import pt.naps.maestro.data.TranscriptRow
import pt.naps.maestro.data.Uuid
enum class StructuredConnectionStatus { Connecting, Open, Closed, Error }
data class StructuredUiState(
val rows: List<TranscriptRow> = emptyList(),
val reducedState: ReducedState = ReducedState(),
val connection: StructuredConnectionStatus = StructuredConnectionStatus.Connecting,
val connectionDetail: String? = null,
val draft: String = "",
val posting: Boolean = false,
val approvalBusy: Boolean = false,
val error: String? = null,
val nowMs: Long = System.currentTimeMillis(),
) {
val turnActive: Boolean get() = StructuredModel.turnIsActive(reducedState)
val pendingApprovalRowId: String? get() = StructuredModel.pendingApprovalRowId(rows, reducedState)
val items: List<StructuredModel.TranscriptItem> get() = StructuredModel.transcriptItems(rows, pendingApprovalRowId)
/** The turn-status strip's label, or `null` while nothing is happening
* (docs/STRUCTURED-VIEW.md's turn indicators, rendered as a client
* decides). */
fun activityLabel(): String? {
val turn = reducedState.turn
if (!turnActive && !posting) return null
val tools = turn.active_tool_calls.size
val label = when {
posting && !turnActive -> "Sending"
turn.thinking -> "Thinking"
tools > 0 -> "Running $tools ${if (tools == 1) "tool" else "tools"}"
else -> "Responding"
}
val startedAtMs = turn.started_at?.let { runCatching { java.time.Instant.parse(it).toEpochMilli() }.getOrNull() }
val elapsed = startedAtMs?.let { maxOf(0L, nowMs - it) } ?: 0L
return "$label · ${StructuredModel.formatElapsed(elapsed)}"
}
}
/**
* Owns the `WS /api/structured/:id` connection and the REST calls the
* pane's composer/approval controls make. A plain class over the socket's
* `Flow` rather than an `androidx.lifecycle.ViewModel` — same shape as
* [pt.naps.maestro.ui.spawn.SpawnViewModel]; the caller's own `remember`
* scope owns its lifetime.
*/
class StructuredViewModel(
private val client: MaestroClient,
private val webSocket: MaestroWebSocket,
private val sessionId: Uuid,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val _state = MutableStateFlow(StructuredUiState())
val state: StateFlow<StructuredUiState> = _state.asStateFlow()
private var connection: StructuredConnection? = null
private var socketJob: Job? = null
private var clockJob: Job? = null
/** The nonce this view model's own in-flight `resolveApproval` call
* targets, so a later projection update can tell "resolved" apart from
* "superseded by a new request" without waiting on the POST itself. */
private var approvalInFlightNonce: String? = null
init {
connect()
clockJob = scope.launch {
while (true) {
delay(1_000)
_state.update { it.copy(nowMs = System.currentTimeMillis()) }
}
}
}
/** (Re)opens the socket. Every connect resends the whole transcript
* (docs/STRUCTURED-VIEW.md), so state is replaced wholesale rather than
* merged into what came before. */
fun connect() {
socketJob?.cancel()
connection?.close()
_state.update { it.copy(connection = StructuredConnectionStatus.Connecting, connectionDetail = null) }
val conn = webSocket.connectStructured(sessionId)
connection = conn
socketJob = scope.launch {
try {
conn.events.collect { event ->
_state.update { it.copy(connection = StructuredConnectionStatus.Open) }
applyEvent(event)
}
_state.update { it.copy(connection = StructuredConnectionStatus.Closed) }
} catch (e: Throwable) {
_state.update { it.copy(connection = StructuredConnectionStatus.Error, connectionDetail = e.message) }
}
}
}
private fun applyEvent(event: StructuredEvent) {
_state.update { current ->
when (event) {
is StructuredEvent.TranscriptSnapshotEvent -> current.copy(rows = event.snapshot.rows)
is StructuredEvent.ReducedStateEvent -> withReducedState(current, event.state)
is StructuredEvent.ProjectionUpdate -> {
val rows = event.transcript?.let { applyDelta(current.rows, it) } ?: current.rows
val next = withReducedState(current, event.state)
val posting = if (StructuredModel.turnIsActive(event.state)) false else next.posting
next.copy(rows = rows, posting = posting)
}
}
}
}
/** Recomputes [StructuredUiState.approvalBusy] against whatever nonce
* this view model's own approval call is waiting on — cleared the
* moment the daemon's own state no longer names that nonce as pending,
* whether because it was answered or superseded. */
private fun withReducedState(current: StructuredUiState, state: ReducedState): StructuredUiState {
val inFlight = approvalInFlightNonce != null && pendingApprovalNonce(state) == approvalInFlightNonce
if (!inFlight) approvalInFlightNonce = null
return current.copy(reducedState = state, approvalBusy = inFlight)
}
private fun applyDelta(rows: List<TranscriptRow>, delta: TranscriptDelta): List<TranscriptRow> = when (delta) {
is TranscriptDelta.Append -> upsert(rows, delta.row)
is TranscriptDelta.Patch -> upsert(rows, delta.row)
is TranscriptDelta.Remove -> rows.filterNot { it.id == delta.id }
}
private fun upsert(rows: List<TranscriptRow>, row: TranscriptRow): List<TranscriptRow> {
val index = rows.indexOfFirst { it.id == row.id }
return if (index == -1) rows + row else rows.toMutableList().also { it[index] = row }
}
fun setDraft(text: String) {
_state.update { it.copy(draft = text) }
}
/** `POST .../prompt`: accepted at any time — idle or mid-turn, the
* daemon queues it server-side (docs/STRUCTURED-VIEW.md), so this never
* queues locally. */
fun sendPrompt() {
val text = _state.value.draft.trim()
if (text.isEmpty() || _state.value.posting) return
_state.update { it.copy(draft = "", posting = true, error = null) }
scope.launch {
try {
client.sendStructuredPrompt(sessionId, text)
} catch (e: Exception) {
_state.update { it.copy(posting = false, draft = text, error = e.message ?: "Failed to send prompt") }
}
}
}
fun cancelTurn() {
_state.update { it.copy(error = null) }
scope.launch {
try {
client.cancelStructuredTurn(sessionId)
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "Failed to cancel turn") }
}
}
}
/** Answers a pending permission request. A no-op — not an error — once
* this is no longer the pending approval (the nonce moved on, or
* resolved elsewhere), matching the daemon's own always-`204`
* semantics. */
fun resolveApproval(rowId: String, decision: StructuredApprovalDecision) {
val current = _state.value
val nonce = pendingApprovalNonce(current.reducedState)
if (nonce == null || rowId != current.pendingApprovalRowId) return
approvalInFlightNonce = nonce
_state.update { it.copy(approvalBusy = true) }
scope.launch {
try {
client.resolveStructuredApproval(sessionId, nonce, decision)
} catch (e: Exception) {
if (approvalInFlightNonce == nonce) {
approvalInFlightNonce = null
_state.update { it.copy(approvalBusy = false, error = e.message ?: "Failed to resolve approval") }
}
}
}
}
fun dismissError() {
_state.update { it.copy(error = null) }
}
fun close() {
clockJob?.cancel()
socketJob?.cancel()
connection?.close()
}
}
@@ -0,0 +1,58 @@
package pt.naps.maestro.ui.structured
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* A turn's collapsed activity block: two or more tool rows folded by
* [StructuredModel.activityGroupsByTurn]. The header counts tool rows only
* and shows the last/current tool label so the operator sees activity at a
* glance without expanding (docs/STRUCTURED-VIEW.md). Opening it reveals
* tools in chronological order.
*/
@Composable
fun ToolGroup(
toolCount: Int,
lastToolLabel: String,
lastToolArg: String,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
val summary = if (lastToolArg.isNotEmpty()) {
"$toolCount tool calls · $lastToolLabel '$lastToolArg'"
} else {
"$toolCount tool calls"
}
Column(modifier = modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onExpandedChange(!expanded) }
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(if (expanded) "▾" else "›", modifier = Modifier.width(20.dp))
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expanded) {
Column(modifier = Modifier.padding(start = 20.dp), content = content)
}
}
}
@@ -0,0 +1,239 @@
package pt.naps.maestro.ui.structured
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.StructuredApprovalDecision
import pt.naps.maestro.data.TranscriptRow
import pt.naps.maestro.data.TranscriptRowKind
private val prettyJson = Json { prettyPrint = true }
/** One transcript row, dispatched by kind (docs/STRUCTURED-VIEW.md fixes
* the eight kinds; rendering is entirely a client decision). `Plan` rows
* and state-only notices are filtered out upstream by
* [StructuredModel.transcriptItems] and never reach here. */
@Composable
fun TranscriptRowView(
row: TranscriptRow,
pendingApproval: Boolean,
approvalBusy: Boolean,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onDecide: (StructuredApprovalDecision) -> Unit,
modifier: Modifier = Modifier,
) {
when (row.kind) {
TranscriptRowKind.User -> UserRow(row, modifier)
TranscriptRowKind.AgentText -> AgentTextRow(row, modifier)
TranscriptRowKind.AgentThought -> AgentThoughtRow(row, expanded, onExpandedChange, modifier)
TranscriptRowKind.ToolCall, TranscriptRowKind.ToolDiff -> ToolCallRow(row, expanded, onExpandedChange, modifier)
TranscriptRowKind.Approval -> {
val request = remember(row.data) { parseApprovalRequest(row.data) }
ApprovalCard(request = request, pending = pendingApproval, busy = approvalBusy, onDecide = onDecide, modifier = modifier)
}
TranscriptRowKind.Plan -> {}
TranscriptRowKind.Notice -> {
val meta = remember(row.data) { parseTurnMeta(row) }
if (meta != null) TurnMetaRow(meta, modifier) else NoticeRow(row, modifier)
}
}
}
@Composable
private fun UserRow(row: TranscriptRow, modifier: Modifier = Modifier) {
val text = remember(row.data) { contentChunkText(row.data) }
if (text.isBlank()) return
Box(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), contentAlignment = Alignment.CenterEnd) {
Box(
modifier = Modifier
.wrapContentWidth()
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(text, color = MaterialTheme.colorScheme.onPrimaryContainer, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun AgentTextRow(row: TranscriptRow, modifier: Modifier = Modifier) {
val text = remember(row.data) { contentChunkText(row.data) }
if (text.isBlank()) return
Text(
text = renderSimpleMarkdown(text),
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
@Composable
private fun AgentThoughtRow(
row: TranscriptRow,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val text = remember(row.data) { contentChunkText(row.data).trim() }
if (text.isBlank()) return
Column(
modifier = modifier
.fillMaxWidth()
.clickable { onExpandedChange(!expanded) }
.padding(vertical = 4.dp),
) {
Text("Thinking", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = text,
maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ToolCallRow(
row: TranscriptRow,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val tool = remember(row.data) { parseToolCall(row.data) }
val hasDiff = tool.content.any { it is AcpToolCallContent.Diff }
val title = remember(tool) { StructuredModel.splitToolTitle(tool.title, tool.name, tool.kind) }
val statusMeta = remember(tool.status, hasDiff) { StructuredModel.statusMeta(tool.status, hasDiff) }
val diffSummary = if (hasDiff) remember(tool.content) { diffStatsSummary(tool.content) } else null
val disclosable = tool.content.isNotEmpty() || tool.rawInput != null || tool.rawOutput != null
val metaText = listOfNotNull(diffSummary, statusMeta?.text).joinToString(" · ")
Column(modifier = modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.let { if (disclosable) it.clickable { onExpandedChange(!expanded) } else it }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(title.label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(6.dp))
Text(
title.arg,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (metaText.isNotEmpty()) {
Spacer(Modifier.width(6.dp))
Text(
metaText,
style = MaterialTheme.typography.labelSmall,
color = when (statusMeta?.kind) {
"failed" -> MaterialTheme.colorScheme.error
"running" -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
if (disclosable && expanded) {
Column(modifier = Modifier.padding(start = 8.dp, top = 4.dp)) {
for (block in tool.content) {
when (block) {
is AcpToolCallContent.Content -> {
val text = contentBlockText(block.block)
if (text.isNotBlank()) {
Text(text, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace))
}
}
is AcpToolCallContent.Diff -> DiffView(path = block.path, oldText = block.oldText, newText = block.newText)
is AcpToolCallContent.Terminal -> Text(
block.terminalId?.let { "Terminal · $it" } ?: "Embedded terminal",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
tool.rawInput?.let { ToolDetailBlock("Input", prettyPrintJson(it)) }
tool.rawOutput?.let { ToolDetailBlock("Output", prettyPrintJson(it)) }
}
}
}
}
@Composable
private fun ToolDetailBlock(label: String, text: String) {
Column(modifier = Modifier.padding(top = 4.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(text, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace))
}
}
private fun diffStatsSummary(content: List<AcpToolCallContent>): String {
var added = 0
var removed = 0
for (block in content) {
if (block !is AcpToolCallContent.Diff) continue
val stats = DiffModel.diffStats(DiffModel.computeDiffLines(block.oldText, block.newText))
added += stats.added
removed += stats.removed
}
return "+$added −$removed"
}
private fun prettyPrintJson(element: JsonElement): String =
runCatching { prettyJson.encodeToString(JsonElement.serializer(), element) }.getOrElse { element.toString() }
@Composable
private fun NoticeRow(row: TranscriptRow, modifier: Modifier = Modifier) {
Text(
text = "[${row.kind.name}]",
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun TurnMetaRow(meta: TurnMetaPayload, modifier: Modifier = Modifier) {
val parts = mutableListOf(StructuredModel.formatElapsed(meta.elapsedMs))
when {
meta.contextDelta != null -> {
val sign = if (meta.contextDelta > 0) "+" else ""
parts += "$sign${meta.contextDelta} context tokens"
}
meta.contextTokens != null -> parts += StructuredModel.formatContextTokens(meta.contextTokens)
}
meta.model?.let { parts += it }
if (meta.outcome != "completed") parts += meta.outcome
Text(
text = parts.joinToString(" · "),
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -0,0 +1,35 @@
package pt.naps.maestro.ui.structured
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Strip above the composer: responding/thinking/active tool calls plus
* elapsed time (docs/STRUCTURED-VIEW.md's turn indicators, drawn as a
* client decides). Hidden while nothing is happening. */
@Composable
fun TurnStatusBar(label: String?, modifier: Modifier = Modifier) {
if (label == null) return
Row(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -0,0 +1,76 @@
package pt.naps.maestro.ui.terminal
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
* in the terminal page's own JS instead of sending a byte sequence. */
sealed class TerminalKeyAction(val label: String) {
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
}
/**
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
* — armed here, then folded into the very next key (tapped on this bar or
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
*/
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
TerminalKeyAction.Send("Esc", "Esc"),
TerminalKeyAction.Send("Tab", "Tab"),
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
TerminalKeyAction.Modifier("alt", "Alt"),
TerminalKeyAction.Send("↑", "Up"),
TerminalKeyAction.Send("↓", "Down"),
TerminalKeyAction.Send("←", "Left"),
TerminalKeyAction.Send("→", "Right"),
)
@Composable
fun TerminalKeyBar(
armed: Set<String>,
onSend: (String) -> Unit,
onToggleModifier: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
for (action in TERMINAL_KEY_ACTIONS) {
when (action) {
is TerminalKeyAction.Modifier -> {
val isArmed = action.name in armed
TextButton(onClick = { onToggleModifier(action.name) }) {
Text(
text = action.display,
color = if (isArmed) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
is TerminalKeyAction.Send -> {
TextButton(onClick = { onSend(action.key) }) {
Text(text = action.display)
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.ui.terminal
import java.net.URLEncoder
import pt.naps.maestro.api.Routes
import pt.naps.maestro.data.Uuid
/**
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
* loads for one session. The embedded xterm.js page reads its `ws` query
* param and opens that as its own `WS /api/attach/:id` connection directly
* — this object only computes the URL, it never touches the socket.
*/
object TerminalPageUrl {
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
return "$ASSET_URL?ws=$encoded"
}
}
@@ -0,0 +1,83 @@
package pt.naps.maestro.ui.terminal
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import pt.naps.maestro.data.Uuid
/**
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun TerminalScreen(
daemonBaseUrl: String,
sessionId: Uuid,
sessionTitle: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
var armed by remember { mutableStateOf(emptySet<String>()) }
val keyboardOpen = WindowInsets.isImeVisible
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(sessionTitle) },
navigationIcon = {
IconButton(onClick = onBack) {
Text(text = "←", style = MaterialTheme.typography.titleLarge)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
TerminalWebView(
pageUrl = pageUrl,
modifier = Modifier
.weight(1f)
.fillMaxSize(),
onReady = { controller = it },
onModifierConsumed = { name -> armed = armed - name },
)
if (keyboardOpen) {
TerminalKeyBar(
armed = armed,
onSend = { key ->
controller?.sendKey(key)
armed = emptySet()
},
onToggleModifier = { name ->
armed = if (name in armed) armed - name else armed + name
controller?.toggleModifier(name)
},
)
}
}
}
}
@@ -0,0 +1,89 @@
package pt.naps.maestro.ui.terminal
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.json.JSONObject
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
* xterm theme, so the WebView doesn't flash white while the page loads. */
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
/**
* Drives the terminal page loaded into a [TerminalWebView] from native code
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
* this controller only calls the `maestro*` bridge functions it exposes.
*/
class TerminalWebViewController internal constructor(private val webView: WebView) {
fun sendKey(key: String) {
val arg = JSONObject.quote(key)
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
}
fun toggleModifier(name: String) {
val arg = JSONObject.quote(name)
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
}
fun focus() {
webView.requestFocus()
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
}
}
/**
* Terminal pane composable for issue #266: a [WebView] loading the bundled
* xterm.js page (`assets/terminal/index.html`), which connects straight to
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
* the soft keyboard, so the native key bar can clear its toggle to match.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun TerminalWebView(
pageUrl: String,
modifier: Modifier = Modifier,
onReady: (TerminalWebViewController) -> Unit = {},
onModifierConsumed: (String) -> Unit = {},
) {
val mainHandler = remember { Handler(Looper.getMainLooper()) }
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
addJavascriptInterface(
object {
@JavascriptInterface
fun onModConsumed(name: String) {
mainHandler.post { onModifierConsumed(name) }
}
},
"AndroidBridge",
)
onReady(TerminalWebViewController(this))
loadUrl(pageUrl)
}
},
update = { webView ->
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
},
onRelease = { it.destroy() },
)
}
@@ -0,0 +1,46 @@
package pt.naps.maestro.ui.theme
import androidx.compose.ui.graphics.Color
/** Palette translated from the web SPA's OKLCH design tokens
* (web/src/styles.css) into sRGB. Dark is the default theme; Light is opt-in.
* Semantic mapping (see docs referenced from issue #271):
* bg -> background, surface* -> surface layers, ink -> onBackground/onSurface,
* muted -> onSurfaceVariant, faint -> outline, accent -> primary,
* live -> tertiary, danger -> error, warn -> secondary. */
// ---- Dark theme (default) ----
val DarkBg = Color(0xFF0E1012)
val DarkSurface = Color(0xFF16181B)
val DarkSurface2 = Color(0xFF1E2125)
val DarkSurface3 = Color(0xFF272B2F)
val DarkBorder = Color(0xFF2E3136)
val DarkBorderStrong = Color(0xFF42474D)
val DarkInk = Color(0xFFEBEDEF)
val DarkMuted = Color(0xFFA1A5AA)
val DarkFaint = Color(0xFF777B7F)
val DarkAccent = Color(0xFFE2E5E8)
val DarkAccentStrong = Color(0xFFF4F5F7)
val DarkAccentInk = Color(0xFF13161C)
val DarkLive = Color(0xFF65EA92)
val DarkDanger = Color(0xFFFF6F69)
val DarkWarn = Color(0xFFEEBC4A)
val DarkTermBg = Color(0xFF0B0C0F)
// ---- Light theme ----
val LightBg = Color(0xFFF9FAFB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurface2 = Color(0xFFF2F3F5)
val LightSurface3 = Color(0xFFE6E8EA)
val LightBorder = Color(0xFFD9DBDF)
val LightBorderStrong = Color(0xFFC1C4C9)
val LightInk = Color(0xFF20242A)
val LightMuted = Color(0xFF565B62)
val LightFaint = Color(0xFF81878D)
val LightAccent = Color(0xFF23272B)
val LightAccentStrong = Color(0xFF13161B)
val LightAccentInk = Color(0xFFF9FAFB)
val LightLive = Color(0xFF069A4A)
val LightDanger = Color(0xFFD01D21)
val LightWarn = Color(0xFFC78200)
val LightTermBg = Color(0xFF0B0F14)
@@ -0,0 +1,99 @@
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
/** Dark scheme (default), translated from the web SPA's dark token set
* (web/src/styles.css) — see Color.kt for the token -> role mapping. */
private val DarkColorScheme = darkColorScheme(
background = DarkBg,
onBackground = DarkInk,
surface = DarkSurface,
onSurface = DarkInk,
surfaceVariant = DarkSurface2,
onSurfaceVariant = DarkMuted,
outline = DarkFaint,
outlineVariant = DarkBorder,
surfaceTint = DarkAccent,
primary = DarkAccent,
onPrimary = DarkAccentInk,
primaryContainer = DarkSurface3,
onPrimaryContainer = DarkInk,
secondary = DarkWarn,
onSecondary = DarkAccentInk,
secondaryContainer = DarkSurface3,
onSecondaryContainer = DarkInk,
tertiary = DarkLive,
onTertiary = DarkAccentInk,
tertiaryContainer = DarkSurface3,
onTertiaryContainer = DarkInk,
error = DarkDanger,
onError = DarkAccentInk,
errorContainer = DarkSurface3,
onErrorContainer = DarkDanger,
)
/** Light scheme, opt-in. */
private val LightColorScheme = lightColorScheme(
background = LightBg,
onBackground = LightInk,
surface = LightSurface,
onSurface = LightInk,
surfaceVariant = LightSurface2,
onSurfaceVariant = LightMuted,
outline = LightFaint,
outlineVariant = LightBorder,
surfaceTint = LightAccent,
primary = LightAccent,
onPrimary = LightAccentInk,
primaryContainer = LightSurface3,
onPrimaryContainer = LightInk,
secondary = LightWarn,
onSecondary = LightAccentInk,
secondaryContainer = LightSurface3,
onSecondaryContainer = LightInk,
tertiary = LightLive,
onTertiary = LightAccentInk,
tertiaryContainer = LightSurface3,
onTertiaryContainer = LightInk,
error = LightDanger,
onError = LightAccentInk,
errorContainer = LightSurface3,
onErrorContainer = LightDanger,
)
/** Dark is the product default (docs/DESIGN.md); light is opt-in, and dynamic
* color (Material You, API 31+) is opt-in on top of that. Edge-to-edge itself
* is enabled once in MainActivity#onCreate — [enableEdgeToEdge] draws content
* behind transparent system bars for whichever scheme resolves here, and the
* default `SystemBarStyle.auto` picks light/dark bar icons from this theme's
* [darkTheme] flag, not the system setting. */
@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,99 @@
package pt.naps.maestro.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import pt.naps.maestro.R
/** Bundled (SIL OFL, android/licenses/JetBrainsMono-OFL.txt) rather than the
* system mono fallback, so the terminal pane and code blocks render the same
* face as the web SPA (--font-mono) on every device. */
val JetBrainsMono = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
)
/** UI chrome uses the platform sans (--font-sans is a system-ui stack on
* web); only terminal/code content switches to [JetBrainsMono]. */
private val Sans = FontFamily.Default
/** Scale roughly mirrors the web token steps (web/src/styles.css --fs-11
* through --fs-20); role names are Material3's, not the CSS ones. */
val Typography = Typography(
titleLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 17.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
titleSmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
lineHeight = 22.sp,
letterSpacing = 0.1.sp,
),
bodyMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodySmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.1.sp,
),
labelLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
),
labelSmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.2.sp,
),
)
/** For terminal output and code blocks — callers set this explicitly instead
* of a [Typography] role, since mono content isn't part of the M3 type scale. */
val MonoTextStyle = TextStyle(
fontFamily = JetBrainsMono,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
)
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Matches web/public/icon.svg's background so the adaptive icon reads as
the same mark as the PWA icon (issue #272). -->
<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="#1A1D24"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Foreground layer of the adaptive icon: the same concentric-circle mark
as web/public/icon.svg, scaled from its 512 viewBox into this 108 one
(issue #272). Stays inside the adaptive-icon safe zone (r=35.86 of 54
half-width, ~66%). -->
<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="#5FE3A1"
android:pathData="M39.23,54 A14.77,14.77 0 1,0 68.77,54 A14.77,14.77 0 1,0 39.23,54 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5FE3A1"
android:strokeAlpha="0.5"
android:strokeWidth="2.53"
android:pathData="M28.69,54 A25.31,25.31 0 1,0 79.31,54 A25.31,25.31 0 1,0 28.69,54 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5FE3A1"
android:strokeAlpha="0.25"
android:strokeWidth="2.11"
android:pathData="M18.14,54 A35.86,35.86 0 1,0 89.86,54 A35.86,35.86 0 1,0 18.14,54 Z" />
</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,9 @@
<resources>
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
<!-- No statusBarColor override: MainActivity#onCreate calls
enableEdgeToEdge(), which makes both system bars transparent at
runtime (issue #271). Setting one here would only show briefly
before that call lands. -->
<item name="android:windowBackground">#FF0E1012</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)
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Test
class BackoffTest {
@Test
fun `doubles from 500ms and caps at 10s`() {
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
val delays = List(8) { backoff.nextDelayMs() }
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
}
@Test
fun `reset returns to the initial delay`() {
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
repeat(4) { backoff.nextDelayMs() }
backoff.reset()
assertEquals(500L, backoff.nextDelayMs())
}
}
@@ -0,0 +1,60 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
class CatalogStoreTest {
private fun session(id: String) = Session(
id = id,
cwd = "/tmp",
command = "bash",
status = SessionStatus.Running,
created_at = "2026-05-24T12:00:00Z",
)
@Test
fun `snapshot replaces the map keyed by id`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
assertEquals(setOf("a", "b"), store.sessions.value.keys)
}
@Test
fun `added and updated upsert one entry`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(emptyList()))
store.apply(CatalogEvent.SessionAdded(session("a")))
assertEquals(1, store.sessions.value.size)
val renamed = session("a").copy(command = "zsh")
store.apply(CatalogEvent.SessionUpdated(renamed))
assertEquals("zsh", store.sessions.value.getValue("a").command)
}
@Test
fun `removed drops the entry`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
store.apply(CatalogEvent.SessionRemoved("a"))
assertEquals(setOf("b"), store.sessions.value.keys)
}
@Test
fun `lagged reports the skip count without touching the map`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
val skipped = store.apply(CatalogEvent.Lagged(3))
assertEquals(3L, skipped)
assertEquals(setOf("a"), store.sessions.value.keys)
}
@Test
fun `non-lagged events report no skip`() {
val store = CatalogStore()
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
}
}
@@ -0,0 +1,55 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Test
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
class DaemonIdentityTest {
private fun info(commit: String = "", instance: String = "") =
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
@Test
fun `same commit and instance is Same`() {
val a = info(commit = "abc123", instance = "i1")
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
}
@Test
fun `different commit is Upgraded`() {
val before = info(commit = "abc123", instance = "i1")
val after = info(commit = "def456", instance = "i2")
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
}
@Test
fun `same commit but different instance is Restarted`() {
val before = info(commit = "abc123", instance = "i1")
val after = info(commit = "abc123", instance = "i2")
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
}
@Test
fun `empty commits on both sides never read as an upgrade`() {
val before = info(commit = "", instance = "i1")
val after = info(commit = "", instance = "i2")
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
}
@Test
fun `format release names the commit and short date`() {
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
}
@Test
fun `dirty build is flagged`() {
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
}
@Test
fun `empty commit is unknown release`() {
assertEquals("unknown release", formatRelease(BuildInfo()))
}
}
@@ -0,0 +1,114 @@
package pt.naps.maestro.data
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Wire-format checks against fixtures mirrored from
* `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type
* that drifts from the Rust source of truth fails here first. */
class WireSerializationTest {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private fun legacySessionJson(extra: String = "") = """
{"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash",
"status":"Running","created_at":"2026-05-24T12:00:00Z"$extra}
""".trimIndent()
@Test
fun `legacy session payload defaults new fields`() {
val s = json.decodeFromString<Session>(legacySessionJson())
assertEquals(ActivityState.Idle, s.activity)
assertEquals(SessionView.Terminal, s.view)
assertNull(s.foreground)
assertNull(s.blocked_work)
assertEquals(EPOCH, s.last_activity_at)
}
@Test
fun `unknown activity state degrades to Idle`() {
val s = json.decodeFromString<Session>(legacySessionJson(""","activity":"QuantumEntangled""""))
assertEquals(ActivityState.Idle, s.activity)
}
@Test
fun `unknown name source degrades to User`() {
assertEquals(NameSource.User, json.decodeFromString<NameSource>("\"Rolled\""))
assertEquals(NameSource.Haiku, json.decodeFromString<NameSource>("\"Haiku\""))
}
@Test
fun `catalog event tags by type`() {
assertEquals(
"""{"type":"Lagged","skipped":3}""",
json.encodeToString<CatalogEvent>(CatalogEvent.Lagged(3)),
)
val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001")
assertEquals(removed, json.decodeFromString<CatalogEvent>(json.encodeToString<CatalogEvent>(removed)))
}
@Test
fun `client control tags by type`() {
assertEquals(
"""{"type":"Resize","cols":120,"rows":40}""",
json.encodeToString<ClientControl>(ClientControl.Resize(120, 40)),
)
assertEquals(ClientControl.Ping, json.decodeFromString<ClientControl>("""{"type":"Ping"}"""))
}
@Test
fun `structured approval decision is a bare string`() {
assertEquals(
StructuredApprovalDecision.AllowOnce,
json.decodeFromString<StructuredApprovalDecision>("\"allow_once\""),
)
assertEquals(
"\"allow_for_session\"",
json.encodeToString(StructuredApprovalDecision.AllowForSession),
)
}
@Test
fun `structured event tags snake_case`() {
val event = json.decodeFromString<StructuredEvent>(
"""{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""",
)
assertTrue(event is StructuredEvent.TranscriptSnapshotEvent)
assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor)
}
@Test
fun `transcript delta tags snake_case and round trips`() {
val row = TranscriptRow(
id = "tool:read-1",
kind = TranscriptRowKind.ToolCall,
data = json.parseToJsonElement("""{"name":"vendor.read"}"""),
)
val patch = TranscriptDelta.Patch(row)
val encoded = json.encodeToString<TranscriptDelta>(patch)
assertTrue(encoded.startsWith("""{"type":"patch","row":"""))
assertEquals(patch, json.decodeFromString<TranscriptDelta>(encoded))
assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought))
}
@Test
fun `rename request encodes plainly`() {
assertEquals(
"""{"name":"amber-falcon"}""",
json.encodeToString(RenameSessionRequest("amber-falcon")),
)
}
@Test
fun `api error decodes nested unsaved work with defaults`() {
val apiError = json.decodeFromString<ApiError>(
"""{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""",
)
assertEquals(3, apiError.error.unsaved_work?.uncommitted_files)
assertEquals(0, apiError.error.unsaved_work?.unpushed_commits)
}
}
@@ -0,0 +1,134 @@
package pt.naps.maestro.ui.commandbar
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
private fun session(
id: String,
name: String = id,
command: String = "bash",
cwd: String = "/home/user/project",
status: SessionStatus = SessionStatus.Running,
createdAt: String = "2026-01-01T00:00:00Z",
lastFocusedAt: String? = null,
) = Session(
id = id,
cwd = cwd,
command = command,
metadata = mapOf("name" to JsonPrimitive(name)),
status = status,
created_at = createdAt,
last_focused_at = lastFocusedAt,
)
class CommandBarModelTest {
@Test
fun `a sigil is only read in first position`() {
assertEquals(BarSigil.Commands, parseBarQuery(">quit").sigil)
assertEquals("quit", parseBarQuery(">quit").query)
assertEquals(BarSigil.Directories, parseBarQuery("~/dev").sigil)
assertEquals("/dev", parseBarQuery("~/dev").query)
assertNull(parseBarQuery("foo>bar").sigil)
assertEquals("foo>bar", parseBarQuery("foo>bar").query)
}
@Test
fun `backspacing past a sigil returns the mixed list`() {
assertNull(parseBarQuery("").sigil)
assertEquals("", parseBarQuery("").query)
}
@Test
fun `most recently focused session sorts first`() {
val a = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
val b = session("b", lastFocusedAt = "2026-01-02T00:00:00Z")
assertEquals(listOf(b, a), orderSessionsForBar(listOf(a, b)))
}
@Test
fun `a never-focused session sorts after every focused one`() {
val focused = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
val neverFocused = session("b", createdAt = "2026-01-05T00:00:00Z")
assertEquals(listOf(focused, neverFocused), orderSessionsForBar(listOf(neverFocused, focused)))
}
@Test
fun `among never-focused sessions the most recently created sorts first`() {
val older = session("a", createdAt = "2026-01-01T00:00:00Z")
val newer = session("b", createdAt = "2026-01-02T00:00:00Z")
assertEquals(listOf(newer, older), orderSessionsForBar(listOf(older, newer)))
}
@Test
fun `dead sessions sort last regardless of recency`() {
val dead = session("a", status = SessionStatus.Dead, lastFocusedAt = "2026-01-09T00:00:00Z")
val alive = session("b", lastFocusedAt = "2026-01-01T00:00:00Z")
assertEquals(listOf(alive, dead), orderSessionsForBar(listOf(dead, alive)))
}
@Test
fun `abbreviateCwd replaces the home prefix`() {
assertEquals("~/dev/api", abbreviateCwd("/home/user/dev/api", "/home/user"))
assertEquals("~", abbreviateCwd("/home/user", "/home/user"))
assertEquals("/var/lib", abbreviateCwd("/var/lib", "/home/user"))
}
@Test
fun `expandHome inverts abbreviateCwd`() {
assertEquals("/home/user/dev/api", expandHome("~/dev/api", "/home/user"))
assertEquals("/home/user", expandHome("~", "/home/user"))
}
@Test
fun `an empty query matches every candidate at score 0 in input order`() {
val a = BarCandidate.SessionItem(session("a"))
val b = BarCandidate.SessionItem(session("b"))
val results = rankCandidates("", listOf(a, b), homeDir = "")
assertEquals(listOf(a, b), results.map { it.candidate })
assertTrue(results.all { it.score == 0 })
}
@Test
fun `a query matching nothing returns an empty list`() {
val candidates = listOf(BarCandidate.SessionItem(session("a", command = "bash")))
assertTrue(rankCandidates("zzz", candidates, homeDir = "").isEmpty())
}
@Test
fun `an exact name match outranks a session found through its directory`() {
val api = BarCandidate.SessionItem(session("api", cwd = "/home/user/web"))
val worker = BarCandidate.SessionItem(session("worker", cwd = "/home/user/api"))
val results = rankCandidates("api", listOf(api, worker), homeDir = "")
assertEquals(listOf(api, worker), results.map { it.candidate })
assertEquals("name", results[0].matchedField)
assertEquals("directory", results[1].matchedField)
}
@Test
fun `a session beats a command of equal score, kind order tie-break`() {
val sessionItem = BarCandidate.SessionItem(session("kill"))
val command = BarCandidate.CommandItem(BarCommand(id = "kill", label = "kill"))
val results = rankCandidates("kill", listOf(command, sessionItem), homeDir = "")
assertEquals(listOf(sessionItem, command), results.map { it.candidate })
}
@Test
fun `groups are ordered by their best-scoring member and drop empty kinds`() {
val sessionItem = BarCandidate.SessionItem(session("web-search"))
val command = BarCandidate.CommandItem(BarCommand(id = "spawn", label = "Spawn session"))
val ranked = rankCandidates("web", listOf(command, sessionItem), homeDir = "")
val groups = groupResults(ranked)
assertEquals(listOf("Sessions"), groups.map { it.title })
}
@Test
fun `a fuzzy subsequence still matches out of order characters`() {
val candidate = BarCandidate.SessionItem(session("maestro-tui"))
assertTrue(rankCandidates("mtu", listOf(candidate), homeDir = "").isNotEmpty())
}
}
@@ -0,0 +1,190 @@
package pt.naps.maestro.ui.sidebar
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
import pt.naps.maestro.data.SessionWorktree
private fun session(
command: String = "claude",
cwd: String = "/home/user/project",
metadata: Map<String, JsonPrimitive> = emptyMap(),
worktree: SessionWorktree? = null,
blockedWork: InTheWay? = null,
activitySince: String = "1970-01-01T00:00:00Z",
) = Session(
id = "s1",
cwd = cwd,
command = command,
metadata = metadata,
worktree = worktree,
status = SessionStatus.Running,
created_at = "2026-01-01T00:00:00Z",
blocked_work = blockedWork,
activity_since = activitySince,
)
class DurationFormattingTest {
@Test
fun `floors seconds under a minute`() {
assertEquals("30s", activityDurationLabel(30_000))
assertEquals("59s", activityDurationLabel(59_999))
}
@Test
fun `floors minutes under an hour`() {
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
}
@Test
fun `floors hours with no days unit`() {
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
}
@Test
fun `held ms is null for missing or implausible since`() {
assertNull(activityHeldMs(null, 1_000))
assertNull(activityHeldMs("", 1_000))
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
assertNull(activityHeldMs("not-a-timestamp", 1_000))
}
@Test
fun `held ms is the gap to now for a plausible timestamp`() {
val since = "2026-01-01T00:00:00Z"
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
assertEquals(31_000L, activityHeldMs(since, nowMs))
}
}
class SessionNameTest {
@Test
fun `falls back to the command when no name is set`() {
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
}
@Test
fun `uses the custom name when set`() {
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
assertEquals("api-work", sessionName(s))
}
@Test
fun `an empty stored name is treated as unset`() {
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
assertNull(customSessionName(s))
}
@Test
fun `generated name is provisional, user and haiku are not`() {
val generated = session(
metadata = mapOf(
"name" to JsonPrimitive("brave-otter"),
"name_source" to JsonPrimitive("Generated"),
),
)
val user = session(
metadata = mapOf(
"name" to JsonPrimitive("api-work"),
"name_source" to JsonPrimitive("User"),
),
)
val unknownSource = session(
metadata = mapOf(
"name" to JsonPrimitive("api-work"),
"name_source" to JsonPrimitive("something-new"),
),
)
assertTrue(isProvisionalName(generated))
assertFalse(isProvisionalName(user))
assertFalse(isProvisionalName(unknownSource))
}
}
class BlockerStatsTest {
@Test
fun `renders insertions and deletions in diff notation`() {
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
val stats = blockerStats(w).associate { it.kind to it.text }
assertEquals("+124", stats["add"])
assertEquals("-18", stats["del"])
assertEquals("?3", stats["untracked"])
assertEquals("↑2", stats["unpushed"])
assertNull(stats["files"])
}
@Test
fun `falls back to a file count when a tracked change carries no line delta`() {
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
val stats = blockerStats(w)
assertEquals(listOf(BlockerStat("files", "~2")), stats)
}
@Test
fun `session blockers are null when nothing blocks a kill`() {
assertNull(sessionBlockers(session(blockedWork = null)))
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
}
@Test
fun `format in the way spells out zero as no unsaved work`() {
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
}
}
class ProgramNameTest {
@Test
fun `drops arguments and basenames the path`() {
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
}
@Test
fun `looks through leading env assignments`() {
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
}
@Test
fun `looks through sudo and env wrappers with their own flags`() {
assertEquals("claude", programName("sudo claude"))
assertEquals("claude", programName("env -i FOO=bar claude"))
}
@Test
fun `looks through js-toolchain launchers`() {
assertEquals("claude-code", programName("npx claude-code"))
assertEquals("claude", programName("pnpm dlx claude"))
assertEquals("claude", programName("bunx claude"))
}
@Test
fun `a command that is nothing but wrappers is shown as written`() {
assertEquals("sudo", programName("sudo"))
}
}
class GroupingTest {
@Test
fun `groups by worktree base_repo when present`() {
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
val groups = groupSessions(listOf(a, b))
assertEquals(1, groups.size)
assertEquals("maestro", groups.single().label)
assertEquals(2, groups.single().sessions.size)
}
@Test
fun `falls back to cwd with no worktree`() {
val a = session(cwd = "/home/user/scratch")
val groups = groupSessions(listOf(a))
assertEquals("scratch", groups.single().label)
}
}
@@ -0,0 +1,107 @@
package pt.naps.maestro.ui.spawn
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.SessionView
private val base = SpawnFormValues(
command = "claude",
cwd = "/home/user/project",
name = "amber-fox",
structuredView = false,
autoApprove = false,
worktreeEnabled = false,
worktreeBranch = "",
worktreeBranchMode = WorktreeBranchMode.New,
)
class SpawnModelTest {
@Test
fun `builds a plain request with the picked cwd and command`() {
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
assertEquals("/home/user/project", result.request.cwd)
assertEquals("claude", result.request.command)
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
assertEquals(SessionView.Terminal, result.request.view)
}
@Test
fun `omits metadata name when the name is blank`() {
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
assertTrue(result.request.metadata.isEmpty())
}
@Test
fun `requests structured view for resolved claude and codex commands`() {
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
}
}
@Test
fun `does not request structured view for unsupported commands`() {
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
assertEquals(SessionView.Terminal, result.request.view)
}
@Test
fun `attaches a trimmed branch when worktree is enabled`() {
val result = buildSpawnRequest(
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
"/home/user/project",
) as SpawnBuildResult.Ok
assertEquals("/home/user/project", result.request.worktree?.base_repo)
assertEquals("feat/foo", result.request.worktree?.branch)
assertTrue(result.request.worktree?.create_branch == true)
}
@Test
fun `checks out an existing branch when the mode is existing`() {
val result = buildSpawnRequest(
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
"/home/user/project",
) as SpawnBuildResult.Ok
assertEquals(false, result.request.worktree?.create_branch)
}
@Test
fun `rejects an existing-mode worktree with no branch picked`() {
val result = buildSpawnRequest(
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
"/repo",
)
assertTrue(result is SpawnBuildResult.Error)
}
@Test
fun `rejects a missing working directory`() {
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
}
@Test
fun `rejects a missing command`() {
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
}
@Test
fun `rejects a worktree without an inferred base repo`() {
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
assertTrue(result is SpawnBuildResult.Error)
}
@Test
fun `rejects a worktree without a branch name`() {
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
assertTrue(result is SpawnBuildResult.Error)
}
@Test
fun `resolveProgram strips env assignments and paths`() {
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
assertEquals("claude", resolveProgram("claude"))
assertEquals("zsh", resolveProgram("zsh -l"))
}
}
@@ -0,0 +1,237 @@
package pt.naps.maestro.ui.spawn
import app.cash.turbine.test
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.data.Project
/** Routes each enqueued response by request path rather than arrival order —
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
* so a plain FIFO queue can hand either endpoint the other's response. */
private class RoutedDispatcher : Dispatcher() {
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
fun enqueue(path: String, response: MockResponse) {
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
}
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path.orEmpty().substringBefore('?')
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
}
}
class SpawnViewModelTest {
private lateinit var server: MockWebServer
private lateinit var dispatcher: RoutedDispatcher
private lateinit var client: MaestroClient
private lateinit var scope: CoroutineScope
@Before
fun setUp() {
dispatcher = RoutedDispatcher()
server = MockWebServer()
server.dispatcher = dispatcher
server.start()
client = MaestroClient(server.url("/").toString())
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
@After
fun tearDown() {
scope.cancel()
server.shutdown()
}
private fun jsonResponse(body: String) =
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
* every test needs before it can observe anything past init. */
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
}
private fun newViewModel() = SpawnViewModel(client, scope)
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
var result: SpawnUiState? = null
state.test {
var item = awaitItem()
while (!predicate(item)) item = awaitItem()
result = item
cancelAndIgnoreRemainingEvents()
}
return result ?: error("state predicate never satisfied")
}
@Test
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
enqueueInit(
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
{"id":"claude","label":"Claude","command":"claude"},
{"id":"custom","label":"Custom…","command":""}
],"instance":"i"}""",
)
val vm = newViewModel()
val settled = vm.awaitState { it.selectedPresetId == "claude" }
assertEquals("claude", settled.command)
assertTrue("structured view should auto-tick for claude", settled.structuredView)
assertEquals("/home/x", settled.homeDir)
}
@Test
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
val vm = newViewModel()
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
assertEquals(FALLBACK_PRESETS, settled.presets)
}
@Test
fun `selecting a preset updates the command and structured view`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
vm.selectPreset("zsh")
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
assertEquals("zsh", afterZsh.command)
assertTrue("zsh has no structured view", !afterZsh.structuredView)
vm.selectPreset("claude")
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
assertTrue(afterClaude.structuredView)
}
@Test
fun `manual structured view toggle survives a later preset change`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
vm.setStructuredView(false)
vm.selectPreset("codex")
val after = vm.awaitState { it.selectedPresetId == "codex" }
assertTrue("operator's untick should stick", !after.structuredView)
}
@Test
fun `selecting a project fills cwd and base repo`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
vm.selectProject(Project(path = "/repo", name = "repo"))
val after = vm.awaitState { it.cwd == "/repo" }
assertEquals("/repo", after.baseRepo)
assertEquals("/repo", after.selectedProjectPath)
}
@Test
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
vm.setCwd("/home/user/pro")
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
val withRepo = vm.awaitState { it.baseRepo != null }
assertEquals("/home/user/proj-a", withRepo.baseRepo)
}
@Test
fun `reroll fills the name field from the daemon`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
vm.rerollName()
val after = vm.awaitState { it.name == "amber-fox" }
assertEquals("amber-fox", after.name)
}
@Test
fun `submit rejects a missing working directory without a network call`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
var succeeded = false
vm.submit { succeeded = true }
val after = vm.awaitState { it.error != null }
assertNotNull(after.error)
assertTrue(!succeeded)
}
@Test
fun `submit posts the built request and reports the new session`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
vm.selectProject(Project(path = "/repo", name = "repo"))
vm.awaitState { it.cwd == "/repo" }
dispatcher.enqueue(
"/api/sessions",
jsonResponse(
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
),
)
var result: pt.naps.maestro.data.Session? = null
vm.submit { result = it }
val after = vm.awaitState { !it.submitting && it.error == null }
assertNull(after.error)
assertEquals("s1", result?.id)
var request = server.takeRequest()
while (request.method != "POST") request = server.takeRequest()
assertEquals("/api/sessions", request.path)
}
@Test
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
vm.selectProject(Project(path = "/repo", name = "repo"))
vm.awaitState { it.cwd == "/repo" }
dispatcher.enqueue(
"/api/sessions",
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
)
vm.submit {}
val after = vm.awaitState { it.error != null }
assertTrue(after.error!!.contains("cwd does not exist"))
}
}
@@ -0,0 +1,96 @@
package pt.naps.maestro.ui.structured
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.ReducedState
import pt.naps.maestro.data.TranscriptRow
import pt.naps.maestro.data.TranscriptRowKind
private fun json(text: String) = Json.parseToJsonElement(text)
class AcpPayloadTest {
@Test
fun `contentChunkText reads a text content block`() {
assertEquals("hello", contentChunkText(json("""{"content":{"type":"text","text":"hello"}}""")))
}
@Test
fun `contentBlockText placeholders non-text blocks instead of dropping them`() {
assertEquals("[image]", contentChunkText(json("""{"content":{"type":"image"}}""")))
assertEquals("[audio]", contentChunkText(json("""{"content":{"type":"audio"}}""")))
assertEquals(
"[resource: notes.txt]",
contentChunkText(json("""{"content":{"type":"resource_link","name":"notes.txt"}}""")),
)
}
@Test
fun `parseToolCall reads the fields ToolRow needs`() {
val tool = parseToolCall(
json(
"""{"title":"Bash(ls)","kind":"execute","status":"in_progress",
"content":[{"type":"diff","path":"a.txt","oldText":"x","newText":"y"}],
"locations":[{"path":"a.txt"}]}""",
),
)
assertEquals("Bash(ls)", tool.title)
assertEquals("execute", tool.kind)
assertEquals("in_progress", tool.status)
assertEquals(listOf("a.txt"), tool.locations)
assertTrue(tool.content.single() is AcpToolCallContent.Diff)
}
@Test
fun `parseToolCall degrades gracefully on a malformed shape`() {
val tool = parseToolCall(json("""["not", "an", "object"]"""))
assertNull(tool.title)
assertEquals(emptyList<AcpToolCallContent>(), tool.content)
}
@Test
fun `parseApprovalRequest reads the tool call and permission options`() {
val request = parseApprovalRequest(
json(
"""{"toolCall":{"title":"Write file"},
"options":[{"optionId":"1","name":"Allow","kind":"allow_once"}]}""",
),
)
assertEquals("Write file", request.toolCall.title)
assertEquals(1, request.options.size)
assertEquals("allow_once", request.options.single().kind)
}
@Test
fun `parseTurnMeta reads the daemon footer and ignores rows without one`() {
val footer = TranscriptRow(
id = "notice:1",
kind = TranscriptRowKind.Notice,
data = json("""{"maestroTurnMeta":{"elapsedMs":4200,"outcome":"cancelled","model":"claude"}}"""),
)
val meta = parseTurnMeta(footer)
assertEquals(4200L, meta?.elapsedMs)
assertEquals("cancelled", meta?.outcome)
assertEquals("claude", meta?.model)
val plain = TranscriptRow(id = "notice:2", kind = TranscriptRowKind.Notice, data = json("""{"sessionUpdate":"x"}"""))
assertNull(parseTurnMeta(plain))
}
@Test
fun `isStateOnlyNotice recognises the two consumed-as-state variants`() {
fun notice(update: String) =
TranscriptRow(id = "n", kind = TranscriptRowKind.Notice, data = json("""{"sessionUpdate":"$update"}"""))
assertTrue(isStateOnlyNotice(notice("available_commands_update")))
assertTrue(isStateOnlyNotice(notice("session_info_update")))
assertTrue(!isStateOnlyNotice(notice("agent_message_chunk")))
}
@Test
fun `pendingApprovalNonce reads the nonce only while one is present`() {
assertEquals("abc", pendingApprovalNonce(ReducedState(pending_approval = json("""{"nonce":"abc"}"""))))
assertNull(pendingApprovalNonce(ReducedState()))
}
}
@@ -0,0 +1,48 @@
package pt.naps.maestro.ui.structured
import org.junit.Assert.assertEquals
import org.junit.Test
class DiffModelTest {
@Test
fun `new file adds every line`() {
val lines = DiffModel.computeDiffLines(null, "a\nb\n")
assertEquals(listOf(DiffLine.Kind.Add, DiffLine.Kind.Add), lines.map { it.kind })
assertEquals(listOf("a", "b"), lines.map { it.text })
}
@Test
fun `identical text is all context, no hunk collapsing for a short file`() {
val lines = DiffModel.computeDiffLines("a\nb\nc\n", "a\nb\nc\n")
assertEquals(listOf(DiffLine.Kind.Ctx, DiffLine.Kind.Ctx, DiffLine.Kind.Ctx), lines.map { it.kind })
}
@Test
fun `a changed middle line reports one del and one add`() {
val lines = DiffModel.computeDiffLines("a\nb\nc\n", "a\nx\nc\n")
val stats = DiffModel.diffStats(lines)
assertEquals(1, stats.added)
assertEquals(1, stats.removed)
assertEquals(listOf(DiffLine.Kind.Ctx, DiffLine.Kind.Del, DiffLine.Kind.Add, DiffLine.Kind.Ctx), lines.map { it.kind })
}
@Test
fun `a long unchanged run collapses to a hunk between real context`() {
val old = (1..20).joinToString("\n") { "line$it" } + "\n"
val new = old.replaceFirst("line1\n", "line1\nINSERTED\n")
val lines = DiffModel.computeDiffLines(old, new)
assertEquals(1, lines.count { it.kind == DiffLine.Kind.Hunk })
assertEquals(1, DiffModel.diffStats(lines).added)
}
@Test
fun `a missing trailing newline is marked`() {
val lines = DiffModel.computeDiffLines(null, "a")
assertEquals(listOf(DiffLine.Kind.Add, DiffLine.Kind.Eof), lines.map { it.kind })
}
@Test
fun `empty old and new text produce no lines`() {
assertEquals(emptyList<DiffLine>(), DiffModel.computeDiffLines("", ""))
}
}
@@ -0,0 +1,46 @@
package pt.naps.maestro.ui.structured
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MarkdownTextTest {
@Test
fun `plain text passes through unchanged`() {
val result = renderSimpleMarkdown("just some text")
assertEquals("just some text", result.text)
assertTrue(result.spanStyles.isEmpty())
}
@Test
fun `bold and italic markers are stripped from the text and styled`() {
val result = renderSimpleMarkdown("**bold** and *italic*")
assertEquals("bold and italic", result.text)
assertTrue(result.spanStyles.any { it.item == SpanStyle(fontWeight = FontWeight.Bold) })
assertTrue(result.spanStyles.any { it.item == SpanStyle(fontStyle = FontStyle.Italic) })
}
@Test
fun `inline code keeps its text without the backticks`() {
val result = renderSimpleMarkdown("run `ls -la` now")
assertEquals("run ls -la now", result.text)
}
@Test
fun `a header line is bold and a bullet gets a leading dot`() {
val header = renderSimpleMarkdown("# Title")
assertEquals("Title", header.text)
val bullet = renderSimpleMarkdown("- one\n- two")
assertEquals("• one\n• two", bullet.text)
}
@Test
fun `a fenced code block keeps its content without the fence markers`() {
val result = renderSimpleMarkdown("```\nlet x = 1;\n```")
assertEquals("\nlet x = 1;\n", result.text)
}
}
@@ -0,0 +1,197 @@
package pt.naps.maestro.ui.structured
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.ReducedState
import pt.naps.maestro.data.StructuredApprovalDecision
import pt.naps.maestro.data.TranscriptRow
import pt.naps.maestro.data.TranscriptRowKind
import pt.naps.maestro.data.TurnState
private fun row(id: String, kind: TranscriptRowKind, json: String = "{}") =
TranscriptRow(id = id, kind = kind, data = Json.parseToJsonElement(json))
class StructuredModelTest {
// -- splitToolTitle / toolKindLabel --------------------------------
@Test
fun `splitToolTitle reads the Name(argument) convention`() {
val title = StructuredModel.splitToolTitle("Bash(git status)", null, "execute")
assertEquals("Bash", title.label)
assertEquals("git status", title.arg)
}
@Test
fun `splitToolTitle prefers an explicit name over the parsed one`() {
val title = StructuredModel.splitToolTitle("Bash(git status)", "Shell", "execute")
assertEquals("Shell", title.label)
}
@Test
fun `splitToolTitle without the convention keeps the whole title as the arg`() {
val title = StructuredModel.splitToolTitle("Reading src/main.rs", null, "read")
assertEquals("Read", title.label)
assertEquals("Reading src/main.rs", title.arg)
}
@Test
fun `splitToolTitle with no title at all has an empty arg`() {
val title = StructuredModel.splitToolTitle(null, null, null)
assertEquals("Tool", title.label)
assertEquals("", title.arg)
}
@Test
fun `toolKindLabel falls back to Tool for an unrecognised kind`() {
assertEquals("Tool", StructuredModel.toolKindLabel("something_new"))
assertEquals("Execute", StructuredModel.toolKindLabel("execute"))
}
// -- statusMeta -----------------------------------------------------
@Test
fun `statusMeta reports running and failed regardless of diff`() {
assertEquals("running", StructuredModel.statusMeta("in_progress", hasDiff = true)?.text)
assertEquals("failed", StructuredModel.statusMeta("failed", hasDiff = false)?.text)
}
@Test
fun `statusMeta hides pending once a diff is present`() {
assertEquals("pending", StructuredModel.statusMeta("pending", hasDiff = false)?.text)
assertNull(StructuredModel.statusMeta("pending", hasDiff = true))
}
@Test
fun `statusMeta is null once a tool has completed`() {
assertNull(StructuredModel.statusMeta("completed", hasDiff = false))
}
// -- turn / approval state -------------------------------------------
@Test
fun `turnIsActive is true while responding or thinking`() {
assertTrue(StructuredModel.turnIsActive(ReducedState(turn = TurnState(responding = true))))
assertTrue(StructuredModel.turnIsActive(ReducedState(turn = TurnState(thinking = true))))
assertTrue(!StructuredModel.turnIsActive(ReducedState(turn = TurnState())))
}
@Test
fun `pendingApprovalRowId is the newest approval row only while one is pending`() {
val rows = listOf(
row("approval:1", TranscriptRowKind.Approval),
row("tool:1", TranscriptRowKind.ToolCall),
row("approval:2", TranscriptRowKind.Approval),
)
val pending = ReducedState(pending_approval = Json.parseToJsonElement("""{"nonce":"n"}"""))
assertEquals("approval:2", StructuredModel.pendingApprovalRowId(rows, pending))
assertNull(StructuredModel.pendingApprovalRowId(rows, ReducedState()))
}
@Test
fun `offeredDecisions maps ACP option kinds to the three daemon decisions`() {
val decisions = StructuredModel.offeredDecisions(
listOf(
AcpPermissionOption("1", "Allow", "allow_once"),
AcpPermissionOption("2", "Reject", "reject_always"),
),
)
assertEquals(listOf(StructuredApprovalDecision.AllowOnce, StructuredApprovalDecision.Deny), decisions)
}
@Test
fun `offeredDecisions is empty when nothing recognisable is offered`() {
assertEquals(emptyList<StructuredApprovalDecision>(), StructuredModel.offeredDecisions(emptyList()))
}
// -- grouping / transcriptItems ---------------------------------------
private fun toolRow(id: String, title: String) = row(id, TranscriptRowKind.ToolCall, """{"title":"$title"}""")
@Test
fun `two or more consecutive tool rows fold into a group`() {
val rows = listOf(
row("user:1", TranscriptRowKind.User),
toolRow("tool:1", "Bash(ls)"),
toolRow("tool:2", "Bash(pwd)"),
)
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
assertEquals(2, items.size) // user row + one group
val group = items[1] as StructuredModel.TranscriptItem.GroupItem
assertEquals(2, group.group.toolCount)
assertEquals("Bash", group.group.lastToolLabel)
assertEquals("pwd", group.group.lastToolArg)
}
@Test
fun `a single tool row stays standalone`() {
val rows = listOf(toolRow("tool:1", "Bash(ls)"))
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
assertEquals(1, items.size)
assertTrue(items[0] is StructuredModel.TranscriptItem.RowItem)
}
@Test
fun `agent text between tool calls splits the run into separate groups`() {
val rows = listOf(
toolRow("tool:1", "Bash(a)"),
toolRow("tool:2", "Bash(b)"),
row("agent:1", TranscriptRowKind.AgentText, """{"content":{"type":"text","text":"hi"}}"""),
toolRow("tool:3", "Bash(c)"),
toolRow("tool:4", "Bash(d)"),
)
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
val groups = items.filterIsInstance<StructuredModel.TranscriptItem.GroupItem>()
assertEquals(2, groups.size)
}
@Test
fun `plan rows and state-only notices never render`() {
val rows = listOf(
row("plan", TranscriptRowKind.Plan, """{"entries":[]}"""),
row("notice:1", TranscriptRowKind.Notice, """{"sessionUpdate":"session_info_update"}"""),
)
assertEquals(emptyList<StructuredModel.TranscriptItem>(), StructuredModel.transcriptItems(rows, null))
}
@Test
fun `a resolved approval outside a group is dropped, a pending one always renders`() {
val rows = listOf(row("approval:1", TranscriptRowKind.Approval))
assertEquals(emptyList<StructuredModel.TranscriptItem>(), StructuredModel.transcriptItems(rows, pendingApprovalId = null))
val pendingItems = StructuredModel.transcriptItems(rows, pendingApprovalId = "approval:1")
assertEquals(1, pendingItems.size)
}
@Test
fun `only the final row may be a turn footer, earlier ones are dropped`() {
fun footer(id: String) = row(
id,
TranscriptRowKind.Notice,
"""{"maestroTurnMeta":{"elapsedMs":1000,"outcome":"completed"}}""",
)
val rows = listOf(footer("turn_meta:1"), toolRow("tool:1", "Bash(ls)"), footer("turn_meta:2"))
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
assertEquals(2, items.size)
assertEquals("turn_meta:2", (items.last() as StructuredModel.TranscriptItem.RowItem).row.id)
}
// -- formatting ---------------------------------------------------------
@Test
fun `formatElapsed rounds down to whole seconds and switches to minutes at 60s`() {
assertEquals("0s", StructuredModel.formatElapsed(0))
assertEquals("59s", StructuredModel.formatElapsed(59_000))
assertEquals("1m", StructuredModel.formatElapsed(60_000))
assertEquals("1m 1s", StructuredModel.formatElapsed(61_000))
assertEquals("2m", StructuredModel.formatElapsed(120_000))
}
@Test
fun `formatContextTokens compacts large values`() {
assertEquals("999 context", StructuredModel.formatContextTokens(999))
assertEquals("1k context", StructuredModel.formatContextTokens(1_000))
assertEquals("1.5k context", StructuredModel.formatContextTokens(1_500))
}
}
@@ -0,0 +1,222 @@
package pt.naps.maestro.ui.structured
import app.cash.turbine.test
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.api.MaestroWebSocket
import pt.naps.maestro.data.StructuredApprovalDecision
/** Exercises [StructuredViewModel] end to end against a real
* `MaestroWebSocket`/`MaestroClient` pair, using `MockWebServer`'s socket
* upgrade to stand in for the daemon (docs/STRUCTURED-VIEW.md). */
class StructuredViewModelTest {
private lateinit var server: MockWebServer
private lateinit var client: MaestroClient
private lateinit var webSocket: MaestroWebSocket
private lateinit var scope: CoroutineScope
@Before
fun setUp() {
server = MockWebServer()
server.start()
val baseUrl = server.url("/").toString()
client = MaestroClient(baseUrl)
webSocket = MaestroWebSocket(baseUrl)
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
@After
fun tearDown() {
scope.cancel()
server.shutdown()
}
/** Enqueues one server-side socket upgrade; the returned handle
* resolves once the client's connect has completed the handshake. */
private fun enqueueSocket(): CompletableDeferred<WebSocket> {
val opened = CompletableDeferred<WebSocket>()
server.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
opened.complete(webSocket)
}
},
),
)
return opened
}
private suspend fun StructuredViewModel.awaitState(predicate: (StructuredUiState) -> Boolean): StructuredUiState {
var result: StructuredUiState? = null
state.test(timeout = 5.seconds) {
var item = awaitItem()
while (!predicate(item)) item = awaitItem()
result = item
cancelAndIgnoreRemainingEvents()
}
return result ?: error("state predicate never satisfied")
}
@Test
fun `applies a transcript snapshot from the socket`() = runBlocking {
val opened = enqueueSocket()
val vm = StructuredViewModel(client, webSocket, "s1", scope)
val serverSocket = opened.await()
serverSocket.send(
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
"rows":[{"id":"user:1","kind":"user","data":{"content":{"type":"text","text":"hi"}}}]}}""",
)
val after = vm.awaitState { it.rows.isNotEmpty() }
assertEquals(1, after.rows.size)
assertEquals("user:1", after.rows.first().id)
assertEquals(StructuredConnectionStatus.Open, after.connection)
vm.close()
}
@Test
fun `appends and patches rows via projection updates, driven by reduced state`() = runBlocking {
val opened = enqueueSocket()
val vm = StructuredViewModel(client, webSocket, "s1", scope)
val serverSocket = opened.await()
serverSocket.send("""{"type":"transcript_snapshot","snapshot":{"cursor":1,"rows":[]}}""")
vm.awaitState { it.connection == StructuredConnectionStatus.Open }
serverSocket.send(
"""{"type":"projection_update","transcript":{"type":"append","row":
{"id":"agent:1","kind":"agent_text","data":{"content":{"type":"text","text":"hi"}}}},
"state":{"turn":{"responding":true,"thinking":false,"active_tool_calls":[]}}}""",
)
val afterAppend = vm.awaitState { it.rows.size == 1 }
assertTrue(afterAppend.turnActive)
serverSocket.send(
"""{"type":"projection_update","transcript":{"type":"patch","row":
{"id":"agent:1","kind":"agent_text","data":{"content":{"type":"text","text":"hi there"}}}},
"state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]}}}""",
)
val afterPatch = vm.awaitState { !it.turnActive }
assertEquals(1, afterPatch.rows.size)
assertEquals("hi there", contentChunkText(afterPatch.rows.single().data))
vm.close()
}
@Test
fun `a remove delta drops the row`() = runBlocking {
val opened = enqueueSocket()
val vm = StructuredViewModel(client, webSocket, "s1", scope)
val serverSocket = opened.await()
serverSocket.send(
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
"rows":[{"id":"notice:1","kind":"notice","data":{}}]}}""",
)
vm.awaitState { it.rows.size == 1 }
serverSocket.send(
"""{"type":"projection_update","transcript":{"type":"remove","id":"notice:1"},
"state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]}}}""",
)
val after = vm.awaitState { it.rows.isEmpty() }
assertEquals(emptyList<Any>(), after.rows)
vm.close()
}
@Test
fun `sendPrompt posts to the daemon and clears the draft`() = runBlocking {
val opened = enqueueSocket()
val vm = StructuredViewModel(client, webSocket, "s1", scope)
opened.await()
server.enqueue(MockResponse().setResponseCode(204))
vm.setDraft("hello")
vm.sendPrompt()
val after = vm.awaitState { !it.posting }
assertEquals("", after.draft)
var request = server.takeRequest()
while (request.method != "POST") request = server.takeRequest()
assertEquals("/api/sessions/s1/structured/prompt", request.path)
vm.close()
}
@Test
fun `a failed prompt restores the draft and surfaces an error`() = runBlocking {
val opened = enqueueSocket()
val vm = StructuredViewModel(client, webSocket, "s1", scope)
opened.await()
server.enqueue(MockResponse().setResponseCode(500))
vm.setDraft("hello")
vm.sendPrompt()
val after = vm.awaitState { it.error != null }
assertEquals("hello", after.draft)
vm.close()
}
@Test
fun `cancelTurn posts to the cancel endpoint`() = runBlocking {
val opened = enqueueSocket()
val vm = StructuredViewModel(client, webSocket, "s1", scope)
opened.await()
server.enqueue(MockResponse().setResponseCode(204))
vm.cancelTurn()
var request = server.takeRequest()
while (request.method != "POST") request = server.takeRequest()
assertEquals("/api/sessions/s1/structured/cancel", request.path)
vm.close()
}
@Test
fun `resolveApproval is a no-op for a stale row id and posts for the pending one`() = runBlocking {
val opened = enqueueSocket()
val vm = StructuredViewModel(client, webSocket, "s1", scope)
val serverSocket = opened.await()
serverSocket.send(
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
"rows":[{"id":"approval:1","kind":"approval","data":{}}]}}""",
)
serverSocket.send(
"""{"type":"reduced_state","state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]},
"pending_approval":{"nonce":"n1"}}}""",
)
vm.awaitState { it.pendingApprovalRowId == "approval:1" }
// A row id that is not the pending one never reaches the network —
// the daemon's own nonce rules make this a no-op, not an error.
vm.resolveApproval("approval:0", StructuredApprovalDecision.AllowOnce)
assertEquals(1, server.requestCount) // only the socket upgrade so far
server.enqueue(MockResponse().setResponseCode(204))
vm.resolveApproval("approval:1", StructuredApprovalDecision.AllowOnce)
var request = server.takeRequest()
while (request.method != "POST") request = server.takeRequest()
assertEquals("/api/sessions/s1/structured/approvals/n1", request.path)
vm.close()
}
}
@@ -0,0 +1,24 @@
package pt.naps.maestro.ui.terminal
import org.junit.Assert.assertTrue
import org.junit.Test
class TerminalKeyBarActionsTest {
@Test
fun `contains tab esc and all four arrows`() {
val labels = TERMINAL_KEY_ACTIONS.map { it.label }
assertTrue(labels.containsAll(listOf("Tab", "Esc", "↑", "↓", "←", "→")))
}
@Test
fun `ctrl and alt are modifiers rather than sends`() {
val modifierNames = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Modifier>().map { it.name }
assertTrue(modifierNames.containsAll(listOf("ctrl", "alt")))
}
@Test
fun `arrow keys carry the maestroKey names the terminal page expects`() {
val sendKeys = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Send>().map { it.key }
assertTrue(sendKeys.containsAll(listOf("Up", "Down", "Left", "Right", "Tab", "Esc")))
}
}

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