Compare commits

...
Author SHA1 Message Date
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 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 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 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 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 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 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
57 changed files with 7882 additions and 1073 deletions
Generated
+2
View File
@@ -1467,6 +1467,7 @@ dependencies = [
"maestro-cli", "maestro-cli",
"maestro-daemon", "maestro-daemon",
"maestro-tui", "maestro-tui",
"tempfile",
"tokio", "tokio",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -1477,6 +1478,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"libc", "libc",
"serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
+2 -1
View File
@@ -5,11 +5,12 @@ edition.workspace = true
license.workspace = true license.workspace = true
repository.workspace = true repository.workspace = true
publish.workspace = true publish.workspace = true
description = "ACP client role: one claude-code-acp child per structured session" description = "ACP client role: one provider adapter child per structured session"
[dependencies] [dependencies]
agent-client-protocol.workspace = true agent-client-protocol.workspace = true
libc.workspace = true libc.workspace = true
serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true tokio.workspace = true
+415 -48
View File
@@ -1,5 +1,5 @@
//! ACP client role for the daemon (#134): one `claude-code-acp` child per //! ACP client role for the daemon: one provider adapter child per structured
//! structured session. //! session.
//! //!
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the //! The daemon is the client; the adapter is the agent. [`spawn`] starts the
//! adapter as a child process with stdio pipes, in the session's `cwd` with //! adapter as a child process with stdio pipes, in the session's `cwd` with
@@ -9,10 +9,10 @@
//! //!
//! **The handshake declares almost nothing.** `initialize` reports //! **The handshake declares almost nothing.** `initialize` reports
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the //! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the //! adapter uses the provider's own file and command tools instead of asking
//! daemon to act on its behalf — exactly what already happens in the //! the daemon to act on its behalf — exactly what already happens in the
//! terminal view. `session/request_permission` is the one client method that //! terminal view. `session/request_permission` is the one client method with
//! gets a real implementation, in #139; here it is a stub that declines. //! a real implementation.
//! //!
//! Every `session/update` notification the adapter sends lands on an //! Every `session/update` notification the adapter sends lands on an
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing //! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
@@ -42,30 +42,62 @@ use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use agent_client_protocol::schema::v1::{ use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, Implementation, CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionNotification, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, SetSessionModeRequest,
}; };
use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client}; use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncBufReadExt; use tokio::io::AsyncBufReadExt;
use tokio::sync::{mpsc, oneshot, watch}; use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema; pub use agent_client_protocol::schema::v1 as schema;
/// The adapter binary, resolved on `PATH`. /// ACP adapter selected from the structured session's resolved command.
pub const ADAPTER_BIN: &str = "claude-code-acp"; #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
ClaudeCode,
Codex,
}
/// How to install the adapter when it is missing. Named in the error so an impl AcpAdapter {
/// operator staring at a failed session knows what to run. pub const fn binary(self) -> &'static str {
match self {
Self::ClaudeCode => "claude-code-acp",
Self::Codex => "codex-acp",
}
}
pub const fn install_command(self) -> &'static str {
match self {
Self::ClaudeCode => "npm install -g --prefix ~/.local @zed-industries/claude-code-acp",
Self::Codex => "npm install -g --prefix ~/.local @agentclientprotocol/codex-acp",
}
}
}
impl std::fmt::Display for AcpAdapter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::ClaudeCode => "Claude Code",
Self::Codex => "Codex",
})
}
}
/// Compatibility aliases for Claude Code callers.
pub const ADAPTER_BIN: &str = "claude-code-acp";
pub const INSTALL_COMMAND: &str = pub const INSTALL_COMMAND: &str =
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp"; "npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
/// How long `initialize` + `session/new` may take before the spawn is /// How long `initialize` + `session/new` may take before the spawn is
/// declared failed. The real adapter boots a node process and the Claude /// declared failed. The real adapters boot a node process and their provider
/// Code SDK behind it; a slow machine takes seconds, never a minute. /// client behind it; a slow machine takes seconds, never a minute.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60); pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
/// How long [`AcpSession::terminate`] waits after `session/cancel` before /// How long [`AcpSession::terminate`] waits after `session/cancel` before
@@ -78,6 +110,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2);
/// Bytes of the child's stderr kept for the failure message. /// Bytes of the child's stderr kept for the failure message.
const STDERR_TAIL_BYTES: usize = 4096; const STDERR_TAIL_BYTES: usize = 4096;
const LEGACY_MODEL_CONFIG_ID: &str = "__maestro_legacy_model";
const LEGACY_MODE_CONFIG_ID: &str = "__maestro_legacy_mode";
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/new", response = ExtendedNewSessionResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedNewSessionRequest {
cwd: PathBuf,
mcp_servers: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedNewSessionResponse {
session_id: SessionId,
#[serde(default)]
modes: Option<SessionModeState>,
#[serde(default)]
config_options: Option<Vec<SessionConfigOption>>,
#[serde(default)]
models: Option<LegacyModelState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/load", response = ExtendedLoadSessionResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedLoadSessionRequest {
session_id: SessionId,
cwd: PathBuf,
mcp_servers: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedLoadSessionResponse {
#[serde(default)]
modes: Option<SessionModeState>,
#[serde(default)]
config_options: Option<Vec<SessionConfigOption>>,
#[serde(default)]
models: Option<LegacyModelState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyModelState {
current_model_id: String,
available_models: Vec<LegacyModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyModel {
model_id: String,
name: String,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/set_model", response = LegacySetModelResponse)]
#[serde(rename_all = "camelCase")]
struct LegacySetModelRequest {
session_id: SessionId,
model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
struct LegacySetModelResponse {}
/// What [`spawn`] needs to start one adapter. /// What [`spawn`] needs to start one adapter.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -94,6 +195,8 @@ pub struct SpawnConfig {
/// instead of opening a fresh one. The adapter must advertise /// instead of opening a fresh one. The adapter must advertise
/// `loadSession`; the handshake fails otherwise. /// `loadSession`; the handshake fails otherwise.
pub load_session: Option<String>, pub load_session: Option<String>,
/// The provider-specific adapter to launch.
pub adapter: AcpAdapter,
} }
impl SpawnConfig { impl SpawnConfig {
@@ -103,6 +206,7 @@ impl SpawnConfig {
env, env,
handshake_timeout: HANDSHAKE_TIMEOUT, handshake_timeout: HANDSHAKE_TIMEOUT,
load_session: None, load_session: None,
adapter: AcpAdapter::ClaudeCode,
} }
} }
@@ -111,6 +215,11 @@ impl SpawnConfig {
self.load_session = Some(session_id.into()); self.load_session = Some(session_id.into());
self self
} }
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
self.adapter = adapter;
self
}
} }
/// A blocked ACP permission request delivered to the daemon. /// A blocked ACP permission request delivered to the daemon.
@@ -126,31 +235,39 @@ pub struct PermissionRequest {
pub enum AcpError { pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command /// The adapter is not on `PATH`. The message names the install command
/// so the failed session is actionable rather than silently dead. /// so the failed session is actionable rather than silently dead.
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")] #[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
AdapterMissing, AdapterMissing { adapter: AcpAdapter },
#[error("failed to start {ADAPTER_BIN}: {0}")] #[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
Spawn(#[source] std::io::Error), Spawn {
adapter: AcpAdapter,
#[source]
source: std::io::Error,
},
/// The adapter answered the handshake with an error. /// The adapter answered the handshake with an error.
#[error("ACP handshake failed: {0}")] #[error("ACP handshake failed: {0}")]
Handshake(String), Handshake(String),
/// The adapter went away before the handshake finished. Carries the /// The adapter went away before the handshake finished. Carries the
/// tail of its stderr, which is where a missing Claude login shows up. /// tail of its stderr, which is where a missing Claude login shows up.
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")] #[error("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
ExitedEarly { status: String, stderr: String }, ExitedEarly {
adapter: AcpAdapter,
status: String,
stderr: String,
},
#[error("ACP handshake timed out after {0:?}")] #[error("ACP handshake timed out after {0:?}")]
HandshakeTimeout(Duration), HandshakeTimeout(Duration),
} }
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the /// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
/// session env's `PATH` when it sets one, the daemon's own otherwise. /// session env's `PATH` when it sets one, the daemon's own otherwise.
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> { pub fn resolve_adapter(adapter: AcpAdapter, env: &HashMap<String, String>) -> Option<PathBuf> {
let path: OsString = match env.get("PATH") { let path: OsString = match env.get("PATH") {
Some(p) => OsString::from(p), Some(p) => OsString::from(p),
None => std::env::var_os("PATH")?, None => std::env::var_os("PATH")?,
}; };
std::env::split_paths(&path) std::env::split_paths(&path)
.filter(|dir| !dir.as_os_str().is_empty()) .filter(|dir| !dir.as_os_str().is_empty())
.map(|dir| dir.join(ADAPTER_BIN)) .map(|dir| dir.join(adapter.binary()))
.find(|candidate| is_executable(candidate)) .find(|candidate| is_executable(candidate))
} }
@@ -168,9 +285,20 @@ enum Command {
turn: u64, turn: u64,
done: oneshot::Sender<Result<(), String>>, done: oneshot::Sender<Result<(), String>>,
}, },
SetConfig {
config_id: String,
value: SessionConfigOptionValue,
done: oneshot::Sender<Result<(), String>>,
},
Close, Close,
} }
struct ConnectionConfig {
cwd: PathBuf,
load_session: Option<String>,
adapter: AcpAdapter,
}
enum ChildCommand { enum ChildCommand {
Signal(libc::c_int), Signal(libc::c_int),
Reap(oneshot::Sender<bool>), Reap(oneshot::Sender<bool>),
@@ -301,6 +429,23 @@ impl AcpSession {
Ok(done_rx) Ok(done_rx)
} }
/// Change one option advertised by the ACP agent.
pub fn set_config(
&self,
config_id: String,
value: SessionConfigOptionValue,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::SetConfig {
config_id,
value,
done: done_tx,
})
.map_err(|_| "ACP connection is closed".to_string())?;
Ok(done_rx)
}
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait /// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped. /// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) { pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
@@ -341,7 +486,9 @@ impl Drop for AcpSession {
/// Fails without a running child: on any error the process is already /// Fails without a running child: on any error the process is already
/// signalled, so a failed session never leaves a stray adapter behind. /// signalled, so a failed session never leaves a stray adapter behind.
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> { pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?; let provider = config.adapter;
let adapter = resolve_adapter(provider, &config.env)
.ok_or(AcpError::AdapterMissing { adapter: provider })?;
let mut cmd = tokio::process::Command::new(&adapter); let mut cmd = tokio::process::Command::new(&adapter);
cmd.current_dir(&config.cwd) cmd.current_dir(&config.cwd)
@@ -369,18 +516,27 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
}); });
} }
} }
let mut child = cmd.spawn().map_err(AcpError::Spawn)?; let mut child = cmd.spawn().map_err(|source| AcpError::Spawn {
adapter: provider,
source,
})?;
let pid = child.id().expect("child pid is known before wait"); let pid = child.id().expect("child pid is known before wait");
let stdin = child.stdin.take().expect("piped stdin"); let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout"); let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr"); let stderr = child.stderr.take().expect("piped stderr");
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new())); let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone())); tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone(), provider));
let (exit_tx, exit_rx) = watch::channel(None); let (exit_tx, exit_rx) = watch::channel(None);
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel(); let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx)); tokio::spawn(supervise_child(
child,
pid,
child_commands_rx,
exit_tx,
provider,
));
let (updates_tx, updates_rx) = mpsc::unbounded_channel(); let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (permission_tx, permission_rx) = mpsc::unbounded_channel(); let (permission_tx, permission_rx) = mpsc::unbounded_channel();
@@ -389,8 +545,11 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
tokio::spawn(run_connection( tokio::spawn(run_connection(
transport, transport,
config.cwd.clone(), ConnectionConfig {
config.load_session.clone(), cwd: config.cwd.clone(),
load_session: config.load_session.clone(),
adapter: provider,
},
updates_tx, updates_tx,
permission_tx, permission_tx,
commands_rx, commands_rx,
@@ -423,6 +582,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
.to_string() .to_string()
}; };
AcpError::ExitedEarly { AcpError::ExitedEarly {
adapter: provider,
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()), status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
stderr: if stderr.is_empty() { stderr: if stderr.is_empty() {
String::new() String::new()
@@ -457,12 +617,13 @@ async fn supervise_child(
pid: u32, pid: u32,
mut commands: mpsc::UnboundedReceiver<ChildCommand>, mut commands: mpsc::UnboundedReceiver<ChildCommand>,
exit_tx: watch::Sender<Option<ExitStatus>>, exit_tx: watch::Sender<Option<ExitStatus>>,
adapter: AcpAdapter,
) { ) {
loop { loop {
tokio::select! { tokio::select! {
status = child.wait() => { status = child.wait() => {
let status = status.ok(); let status = status.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited"); tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(status); let _ = exit_tx.send(status);
return; return;
} }
@@ -476,7 +637,7 @@ async fn supervise_child(
Some(ChildCommand::Reap(done)) => match child.try_wait() { Some(ChildCommand::Reap(done)) => match child.try_wait() {
Ok(Some(status)) => { Ok(Some(status)) => {
let _ = done.send(true); let _ = done.send(true);
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited"); tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(Some(status)); let _ = exit_tx.send(Some(status));
return; return;
} }
@@ -484,7 +645,7 @@ async fn supervise_child(
let _ = done.send(false); let _ = done.send(false);
} }
Err(error) => { Err(error) => {
tracing::warn!(pid, %error, "could not reap {ADAPTER_BIN}"); tracing::warn!(pid, %error, provider = %adapter, "could not reap ACP adapter");
let _ = done.send(false); let _ = done.send(false);
} }
}, },
@@ -494,7 +655,7 @@ async fn supervise_child(
// a reused PID. // a reused PID.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
let status = child.wait().await.ok(); let status = child.wait().await.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited"); tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(status); let _ = exit_tx.send(status);
return; return;
} }
@@ -513,10 +674,104 @@ fn client_capabilities() -> ClientCapabilities {
.terminal(false) .terminal(false)
} }
fn advertised_config_options(
mut options: Vec<SessionConfigOption>,
modes: Option<&SessionModeState>,
models: Option<&LegacyModelState>,
) -> Vec<SessionConfigOption> {
let has_model = options
.iter()
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model)));
let has_mode = options
.iter()
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Mode)));
if let Some(models) = models.filter(|_| !has_model) {
let mut choices = models
.available_models
.iter()
.map(|model| {
let mut option =
SessionConfigSelectOption::new(model.model_id.clone(), &model.name);
if model.model_id == "default" {
option.name = "Opus (default)".into();
}
option.description.clone_from(&model.description);
option
})
.collect::<Vec<_>>();
if !models
.available_models
.iter()
.any(|model| model.model_id == "fable")
{
choices.push(SessionConfigSelectOption::new("fable", "Fable"));
}
options.push(
SessionConfigOption::select(
LEGACY_MODEL_CONFIG_ID,
"Model",
models.current_model_id.clone(),
choices,
)
.category(SessionConfigOptionCategory::Model),
);
}
if let Some(modes) = modes.filter(|_| !has_mode) {
let choices = modes
.available_modes
.iter()
.map(|mode| {
let mut option = SessionConfigSelectOption::new(mode.id.0.clone(), &mode.name);
option.description.clone_from(&mode.description);
option
})
.collect::<Vec<_>>();
options.push(
SessionConfigOption::select(
LEGACY_MODE_CONFIG_ID,
"Mode",
modes.current_mode_id.0.clone(),
choices,
)
.category(SessionConfigOptionCategory::Mode),
);
}
options
}
fn replace_config_value(
options: &Arc<Mutex<Vec<SessionConfigOption>>>,
config_id: &str,
value: &SessionConfigOptionValue,
) -> Vec<SessionConfigOption> {
let mut options = options.lock().expect("config options");
if let Some(option) = options
.iter_mut()
.find(|option| option.id.0.as_ref() == config_id)
{
match (&mut option.kind, value) {
(SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => {
select.current_value = value.clone();
}
(SessionConfigKind::Boolean(boolean), SessionConfigOptionValue::Boolean { value }) => {
boolean.current_value = *value;
}
_ => {}
}
}
options.clone()
}
fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> SessionNotification {
SessionNotification::new(
SessionId::new(session_id),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)),
)
}
async fn run_connection<T>( async fn run_connection<T>(
transport: T, transport: T,
cwd: PathBuf, config: ConnectionConfig,
load_session: Option<String>,
updates_tx: mpsc::UnboundedSender<SessionNotification>, updates_tx: mpsc::UnboundedSender<SessionNotification>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>, permission_requests: mpsc::UnboundedSender<PermissionRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>, mut commands_rx: mpsc::UnboundedReceiver<Command>,
@@ -534,6 +789,7 @@ async fn run_connection<T>(
// until a cancelled prompt settles, so this active turn is unambiguous. // until a cancelled prompt settles, so this active turn is unambiguous.
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default())); let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
let permission_turn_seen = permission_turn.clone(); let permission_turn_seen = permission_turn.clone();
let received_updates = updates_tx.clone();
let result = Client let result = Client
.builder() .builder()
.name("maestro") .name("maestro")
@@ -544,7 +800,7 @@ async fn run_connection<T>(
return Ok(()); return Ok(());
} }
// A dropped receiver only means nobody wants updates any more. // A dropped receiver only means nobody wants updates any more.
let _ = updates_tx.send(notification); let _ = received_updates.send(notification);
Ok(()) Ok(())
}, },
agent_client_protocol::on_receive_notification!(), agent_client_protocol::on_receive_notification!(),
@@ -589,11 +845,13 @@ async fn run_connection<T>(
return Ok(()); return Ok(());
} }
}; };
let session_id = match load_session { let (session_id, modes, initial_options, models) = match config.load_session {
Some(existing) => { Some(existing) => {
if !initialized.agent_capabilities.load_session { if !initialized.agent_capabilities.load_session {
let _ = ready_tx.send(Err(ReadyError::Handshake(format!( let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}" "{} ACP adapter ({}) does not advertise loadSession; cannot resume session {existing}",
config.adapter,
config.adapter.binary()
)))); ))));
return Ok(()); return Ok(());
} }
@@ -601,7 +859,11 @@ async fn run_connection<T>(
let (loaded_tx, loaded_rx) = oneshot::channel(); let (loaded_tx, loaded_rx) = oneshot::channel();
let replaying_done = replaying.clone(); let replaying_done = replaying.clone();
if let Err(e) = cx if let Err(e) = cx
.send_request(LoadSessionRequest::new(existing.clone(), cwd)) .send_request(ExtendedLoadSessionRequest {
session_id: SessionId::new(existing.as_str()),
cwd: config.cwd,
mcp_servers: vec![],
})
.on_receiving_result(move |result| { .on_receiving_result(move |result| {
replaying_done.store(false, Ordering::SeqCst); replaying_done.store(false, Ordering::SeqCst);
async move { async move {
@@ -613,8 +875,8 @@ async fn run_connection<T>(
let _ = ready_tx.send(Err(ready_error(e))); let _ = ready_tx.send(Err(ready_error(e)));
return Ok(()); return Ok(());
} }
match loaded_rx.await { let loaded = match loaded_rx.await {
Ok(Ok(_)) => {} Ok(Ok(loaded)) => loaded,
Ok(Err(e)) => { Ok(Err(e)) => {
let _ = ready_tx.send(Err(ready_error(e))); let _ = ready_tx.send(Err(ready_error(e)));
return Ok(()); return Ok(());
@@ -625,21 +887,48 @@ async fn run_connection<T>(
))); )));
return Ok(()); return Ok(());
} }
} };
existing (existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models)
} }
None => match cx None => match cx
.send_request(NewSessionRequest::new(cwd)) .send_request(ExtendedNewSessionRequest {
cwd: config.cwd,
mcp_servers: vec![],
})
.block_task() .block_task()
.await .await
{ {
Ok(created) => created.session_id.0.to_string(), Ok(created) => (
created.session_id.0.to_string(),
created.modes,
created.config_options.unwrap_or_default(),
created.models,
),
Err(e) => { Err(e) => {
let _ = ready_tx.send(Err(ready_error(e))); let _ = ready_tx.send(Err(ready_error(e)));
return Ok(()); return Ok(());
} }
}, },
}; };
let config_options = Arc::new(Mutex::new(advertised_config_options(
initial_options,
modes.as_ref(),
models.as_ref(),
)));
if let Some(modes) = &modes {
let _ = updates_tx.send(SessionNotification::new(
SessionId::new(session_id.as_str()),
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
modes.current_mode_id.clone(),
)),
));
}
if !config_options.lock().expect("config options").is_empty() {
let _ = updates_tx.send(config_notification(
&session_id,
config_options.lock().expect("config options").clone(),
));
}
let _ = ready_tx.send(Ok(session_id.clone())); let _ = ready_tx.send(Ok(session_id.clone()));
loop { loop {
@@ -689,6 +978,83 @@ async fn run_connection<T>(
} }
} }
} }
Some(Command::SetConfig { config_id, value, done }) => {
let done = Arc::new(Mutex::new(Some(done)));
let callback_done = done.clone();
let callback_options = config_options.clone();
let callback_updates = updates_tx.clone();
let callback_session = session_id.clone();
let callback_config_id = config_id.clone();
let callback_value = value.clone();
let sent = if config_id == LEGACY_MODEL_CONFIG_ID {
let Some(model_id) = value.as_value_id() else {
let _ = done.lock().expect("config sender").take()
.map(|done| done.send(Err("model must be a string value".into())));
continue;
};
cx.send_request(LegacySetModelRequest {
session_id: SessionId::new(session_id.as_str()),
model_id: model_id.0.to_string(),
}).on_receiving_result(move |result| async move {
if result.is_ok() {
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
let _ = callback_updates.send(config_notification(&callback_session, options));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
}
Ok(())
})
} else if config_id == LEGACY_MODE_CONFIG_ID {
let Some(mode_id) = value.as_value_id() else {
let _ = done.lock().expect("config sender").take()
.map(|done| done.send(Err("mode must be a string value".into())));
continue;
};
cx.send_request(SetSessionModeRequest::new(
SessionId::new(session_id.as_str()),
mode_id.0.to_string(),
)).on_receiving_result(move |result| async move {
if result.is_ok() {
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
let _ = callback_updates.send(config_notification(&callback_session, options));
let _ = callback_updates.send(SessionNotification::new(
SessionId::new(callback_session.as_str()),
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
callback_value.as_value_id().expect("checked mode value").0.clone(),
)),
));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
}
Ok(())
})
} else {
cx.send_request(SetSessionConfigOptionRequest::new(
SessionId::new(session_id.as_str()),
config_id,
value,
))
.on_receiving_result(move |result| async move {
if let Ok(response) = &result {
let mut current = callback_options.lock().expect("config options");
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
current.extend(response.config_options.clone());
let _ = callback_updates.send(config_notification(&callback_session, current.clone()));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
}
Ok(())
})
};
if let Err(error) = sent {
if let Some(done) = done.lock().expect("config sender").take() {
let _ = done.send(Err(error.to_string()));
}
}
}
Some(Command::Close) | None => break, Some(Command::Close) | None => break,
}, },
_ = cx.incoming_closed() => break, _ = cx.incoming_closed() => break,
@@ -706,10 +1072,11 @@ async fn drain_stderr(
stderr: tokio::process::ChildStderr, stderr: tokio::process::ChildStderr,
pid: u32, pid: u32,
tail: Arc<Mutex<VecDeque<u8>>>, tail: Arc<Mutex<VecDeque<u8>>>,
adapter: AcpAdapter,
) { ) {
let mut lines = tokio::io::BufReader::new(stderr).lines(); let mut lines = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(pid, "{ADAPTER_BIN}: {line}"); tracing::debug!(pid, provider = %adapter, "ACP adapter: {line}");
let mut tail = tail.lock().expect("stderr tail"); let mut tail = tail.lock().expect("stderr tail");
tail.extend(line.as_bytes()); tail.extend(line.as_bytes());
tail.push_back(b'\n'); tail.push_back(b'\n');
+53 -2
View File
@@ -19,6 +19,8 @@ Environment knobs, all optional:
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
`session/new` `session/new`
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
`session/new`
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new` FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
so the SIGTERM path is exercised) so the SIGTERM path is exercised)
@@ -34,6 +36,7 @@ Environment knobs, all optional:
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
JSON-RPC error JSON-RPC error
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
callback until a later prompt callback until a later prompt
@@ -76,6 +79,41 @@ prompt_failures = 0
pending_prompt_id = None pending_prompt_id = None
pending_permission_id = None pending_permission_id = None
delayed_permission = False delayed_permission = False
current_model = "opus"
current_mode = "default"
current_effort = "high"
def settings_response(include_session=False):
result = {}
if include_session:
result["sessionId"] = session_id
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
result.update({
"models": {
"currentModelId": current_model,
"availableModels": [
{"modelId": "opus", "name": "Opus"},
{"modelId": "sonnet", "name": "Sonnet"},
],
},
"modes": {
"currentModeId": current_mode,
"availableModes": [
{"id": "default", "name": "Default"},
{"id": "plan", "name": "Plan"},
],
},
"configOptions": [{
"id": "effort", "name": "Effort", "category": "thought_level",
"type": "select", "currentValue": current_effort,
"options": [
{"value": "low", "name": "Low"},
{"value": "high", "name": "High"},
],
}],
})
return result
def chunk(text): def chunk(text):
@@ -133,8 +171,11 @@ for raw in sys.stdin:
"authMethods": [], "authMethods": [],
}}) }})
elif method == "session/new": elif method == "session/new":
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
while gate and not os.path.exists(gate):
time.sleep(0.01)
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000) time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}}) send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
send(chunk("hello from fake")) send(chunk("hello from fake"))
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1": if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
sys.exit(0) sys.exit(0)
@@ -147,8 +188,18 @@ for raw in sys.stdin:
continue continue
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))): for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
send(chunk(f"replayed {i + 1}")) send(chunk(f"replayed {i + 1}"))
send({"jsonrpc": "2.0", "id": rid, "result": {}}) send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
send(chunk("hello after load")) send(chunk("hello after load"))
elif method == "session/set_model":
current_model = params.get("modelId")
send({"jsonrpc": "2.0", "id": rid, "result": {}})
elif method == "session/set_mode":
current_mode = params.get("modeId")
send({"jsonrpc": "2.0", "id": rid, "result": {}})
elif method == "session/set_config_option":
current_effort = params.get("value")
send({"jsonrpc": "2.0", "id": rid, "result": {
"configOptions": settings_response()["configOptions"]}})
elif method == "session/cancel": elif method == "session/cancel":
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1": if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
send(chunk("final on cancel")) send(chunk("final on cancel"))
+120 -9
View File
@@ -1,12 +1,12 @@
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus //! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
//! one ignored test against the real `claude-code-acp`. //! ignored smoke tests against each maintained adapter.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use maestro_acp::schema::{ContentBlock, SessionUpdate}; use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND}; use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use tempfile::TempDir; use tempfile::TempDir;
fn fake_agent() -> PathBuf { fn fake_agent() -> PathBuf {
@@ -25,6 +25,7 @@ impl Fake {
let bin = dir.path().join("bin"); let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap(); std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap(); std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir } Self { dir }
} }
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env)) let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await .await
.expect_err("no adapter on PATH"); .expect_err("no adapter on PATH");
assert!(matches!(err, AcpError::AdapterMissing)); assert!(matches!(
err,
AcpError::AdapterMissing {
adapter: AcpAdapter::ClaudeCode
}
));
let msg = err.to_string(); let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{msg}"); assert!(msg.contains(ADAPTER_BIN), "{msg}");
assert!(msg.contains(INSTALL_COMMAND), "{msg}"); assert!(msg.contains(INSTALL_COMMAND), "{msg}");
} }
#[test]
fn codex_adapter_has_its_own_binary_and_install_guidance() {
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
assert!(AcpAdapter::Codex
.install_command()
.contains("@agentclientprotocol/codex-acp"));
}
#[tokio::test]
async fn spawn_selects_the_codex_adapter() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await
.expect("spawn Codex adapter");
assert!(fake.log().contains("recv session/new"));
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test] #[tokio::test]
async fn session_updates_reach_the_taken_receiver() { async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new(); let fake = Fake::new();
@@ -180,11 +207,68 @@ async fn session_updates_reach_the_taken_receiver() {
.await; .await;
} }
#[tokio::test]
async fn advertised_settings_are_normalized_and_mutable() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
let mut updates = session.take_updates().expect("updates");
let initial = loop {
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("settings update")
.expect("channel open");
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
break options.config_options;
}
};
assert_eq!(initial.len(), 3);
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "effort"));
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
let model = initial
.iter()
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
.expect("legacy model option");
let model = serde_json::to_value(model).unwrap();
assert_eq!(model["options"][0]["name"], "Opus");
assert!(model["options"]
.as_array()
.unwrap()
.iter()
.any(|option| option["value"] == "fable"));
let changed = session
.set_config(
"__maestro_legacy_model".into(),
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
)
.expect("queue model change");
changed
.await
.expect("response delivered")
.expect("model changed");
fake.wait_for_log("recv session/set_model").await;
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test] #[tokio::test]
async fn terminate_sends_cancel_then_sigterm() { async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path())) let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await .await
.expect("spawn"); .expect("spawn");
let pid = session.pid(); let pid = session.pid();
@@ -225,7 +309,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
async fn handshake_error_fails_the_spawn_and_reaps_the_child() { async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()); let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into()); config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails"); let err = maestro_acp::spawn(config).await.expect_err("init fails");
@@ -284,7 +368,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
let err = maestro_acp::spawn(config).await.expect_err("child died"); let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err { match &err {
AcpError::ExitedEarly { status, stderr } => { AcpError::ExitedEarly { status, stderr, .. } => {
assert!(status.contains('3'), "{status}"); assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}"); assert!(stderr.contains("not logged in"), "{stderr}");
} }
@@ -320,7 +404,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
async fn load_session_resumes_the_stored_id_and_drops_the_replay() { async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into()); config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn"); let session = maestro_acp::spawn(config).await.expect("spawn");
@@ -353,7 +440,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
async fn load_session_requires_the_load_capability() { async fn load_session_requires_the_load_capability() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into()); config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config) let err = maestro_acp::spawn(config)
@@ -368,7 +458,10 @@ async fn load_session_requires_the_load_capability() {
async fn load_session_error_fails_the_spawn() { async fn load_session_error_fails_the_spawn() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into()); config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails"); let err = maestro_acp::spawn(config).await.expect_err("load fails");
@@ -392,3 +485,21 @@ async fn real_adapter_handshake() {
.await; .await;
assert!(session.has_exited()); assert!(session.has_exited());
} }
/// End to end against the maintained Codex adapter. It needs a locally
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
#[tokio::test]
#[ignore]
async fn real_codex_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session =
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
.await
.expect("real codex-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
+45 -1
View File
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
pub enum AttachError { pub enum AttachError {
#[error("ws: {0}")] #[error("ws: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error), WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
#[error("daemon returned {status} ({code}): {message}")]
Rejected {
status: String,
code: String,
message: String,
},
#[error("io: {0}")] #[error("io: {0}")]
Io(#[from] io::Error), Io(#[from] io::Error),
#[error("termios: {0}")] #[error("termios: {0}")]
@@ -32,6 +38,21 @@ pub enum AttachError {
} }
impl AttachError { impl AttachError {
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
if let Some(body) = response.body().as_deref() {
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
return Self::Rejected {
status: response.status().to_string(),
code: error_body.error.code,
message: error_body.error.message,
};
}
}
}
Self::WebSocket(error)
}
/// True when the failure was an inability to reach the WS endpoint at the /// True when the failure was an inability to reach the WS endpoint at the
/// transport level (connection refused), as opposed to an HTTP-level /// transport level (connection refused), as opposed to an HTTP-level
/// rejection from a daemon that did answer. /// rejection from a daemon that did answer.
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
/// byte that exits cleanly without killing the session. /// byte that exits cleanly without killing the session.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> { pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?; let (ws, _resp) = tokio_tungstenite::connect_async(url)
.await
.map_err(AttachError::from_websocket)?;
let (mut sink, mut stream) = ws.split(); let (mut sink, mut stream) = ws.split();
let raw = RawTty::enable()?; let raw = RawTty::enable()?;
@@ -233,4 +256,25 @@ mod tests {
Err(AttachError::InvalidDetachKey(_)) Err(AttachError::InvalidDetachKey(_))
)); ));
} }
#[test]
fn attach_surfaces_structured_handshake_errors() {
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
"not_terminal",
"session is not a terminal session",
))
.unwrap();
let response = tokio_tungstenite::tungstenite::http::Response::builder()
.status(409)
.body(Some(body))
.unwrap();
let error =
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
assert_eq!(
error.to_string(),
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
);
}
} }
+8
View File
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
/// there. /// there.
#[arg(long)] #[arg(long)]
pub existing_branch: bool, pub existing_branch: bool,
/// Run a Claude Code or Codex session in a plain terminal instead of the
/// structured (ACP) view it otherwise gets by default.
#[arg(long, conflicts_with = "structured")]
pub terminal: bool,
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
/// Codex commands have an adapter; the daemon refuses anything else.
#[arg(long)]
pub structured: bool,
/// After spawning, attach immediately. /// After spawning, attach immediately.
#[arg(long, short = 'a')] #[arg(long, short = 'a')]
pub attach: bool, pub attach: bool,
+100 -8
View File
@@ -3,10 +3,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use maestro_protocol::display::abbreviate_dir; use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::{ use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest, ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, WorktreeSpec, Session, SessionStatus, SessionView, WorktreeSpec,
}; };
use serde_json::Value; use serde_json::Value;
@@ -51,7 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
}; };
let body = CreateSessionRequest { let body = CreateSessionRequest {
view: Default::default(), view: spawn_view(&args),
cwd: args.cwd.clone(), cwd: args.cwd.clone(),
command: args.command.clone(), command: args.command.clone(),
env, env,
@@ -61,6 +61,22 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
client.create_session(&body).await client.create_session(&body).await
} }
/// The view a `maestro spawn` asks for: structured whenever the command
/// resolves to an agent with an ACP adapter, since that is the richer session
/// and the one both other clients now default to. `--terminal` opts out;
/// `--structured` forces the request through even for a command this side
/// does not recognise, leaving the refusal to the daemon.
fn spawn_view(args: &SpawnArgs) -> SessionView {
if args.terminal {
return SessionView::Terminal;
}
if args.structured || supports_structured_view(&args.command) {
SessionView::Structured
} else {
SessionView::Terminal
}
}
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead /// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where /// of removing it; the outcome names what was kept so dispatch can say where
/// the work went. /// the work went.
@@ -179,8 +195,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
} }
writeln!( writeln!(
out, out,
" {:<36} {:<8} {:<9} {:<28} {:<40} {}", " {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED" "ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
) )
.ok(); .ok();
for s in sorted { for s in sorted {
@@ -190,6 +206,10 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
SessionStatus::Running => "running", SessionStatus::Running => "running",
SessionStatus::Dead => "dead", SessionStatus::Dead => "dead",
}; };
let view = match s.view {
SessionView::Terminal => "terminal",
SessionView::Structured => "structured",
};
let activity = match s.activity { let activity = match s.activity {
ActivityState::Active => "active", ActivityState::Active => "active",
ActivityState::Idle => "idle", ActivityState::Idle => "idle",
@@ -207,8 +227,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let blocked = blocked_label(s); let blocked = blocked_label(s);
writeln!( writeln!(
out, out,
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}", " {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
s.id, status, activity, dir, command s.id, status, view, activity, dir, command
) )
.ok(); .ok();
} }
@@ -230,11 +250,67 @@ mod tests {
use std::path::PathBuf; use std::path::PathBuf;
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use maestro_protocol::{ActivityState, Session, SessionStatus}; use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
use uuid::Uuid; use uuid::Uuid;
use super::DaemonInfo; use super::DaemonInfo;
use clap::Parser;
use crate::cli::{Cli, Command};
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
argv.extend_from_slice(extra);
let cli = Cli::try_parse_from(argv).expect("parses");
let Command::Spawn(args) = cli.command else {
panic!("expected spawn")
};
args
}
/// The CLI follows the two interactive clients: a command with an ACP
/// adapter spawns structured unless `--terminal` says otherwise, and
/// `--structured` still forces the request for a command this side does
/// not recognise.
#[test]
fn spawn_defaults_to_structured_for_supported_agents() {
use super::spawn_view;
assert_eq!(
spawn_view(&spawn_args(&["claude"])),
SessionView::Structured
);
assert_eq!(
spawn_view(&spawn_args(&["npx codex exec"])),
SessionView::Structured
);
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
assert_eq!(
spawn_view(&spawn_args(&["claude", "--terminal"])),
SessionView::Terminal
);
assert_eq!(
spawn_view(&spawn_args(&["opencode", "--structured"])),
SessionView::Structured
);
}
#[test]
fn spawn_refuses_both_view_flags_at_once() {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"claude",
"--structured",
"--terminal",
])
.unwrap_err();
assert!(err.to_string().contains("--terminal"), "{err}");
}
fn session(cwd: &str, command: &str, id: u128) -> Session { fn session(cwd: &str, command: &str, id: u128) -> Session {
Session { Session {
view: Default::default(), view: Default::default(),
@@ -286,6 +362,22 @@ mod tests {
assert!(s.contains("idle"), "{s}"); assert!(s.contains("idle"), "{s}");
} }
#[test]
fn table_exposes_terminal_and_structured_views() {
let terminal = session("/tmp", "bash", 1);
let mut structured = session("/tmp", "claude", 2);
structured.view = SessionView::Structured;
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
let out = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = out.lines().collect();
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
assert!(lines[header + 1].contains("terminal"), "{out}");
assert!(lines[header + 2].contains("structured"), "{out}");
}
#[test] #[test]
fn live_cwd_overrides_spawn_dir_in_table() { fn live_cwd_overrides_spawn_dir_in_table() {
let mut s = session("/home/naps/maestro", "bash", 1); let mut s = session("/home/naps/maestro", "bash", 1);
+99 -9
View File
@@ -12,7 +12,8 @@
use std::sync::Arc; use std::sync::Arc;
use axum::extract::{Path, Query, State}; use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post}; use axum::routing::{any, delete, get, post};
@@ -21,12 +22,13 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
StructuredApprovalDecision, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION, StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
PROTOCOL_VERSION,
}; };
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::session::{QueueAcpPrompt, SessionManager}; use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
use crate::sidecar::EventCursor; use crate::sidecar::EventCursor;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -69,7 +71,12 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::STRUCTURED, any(structured_ws)) .route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay)) .route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt)) .route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(
routes::patterns::STRUCTURED_ATTACHMENT,
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
)
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel)) .route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route( .route(
routes::patterns::STRUCTURED_APPROVAL, routes::patterns::STRUCTURED_APPROVAL,
post(structured_approval), post(structured_approval),
@@ -179,6 +186,7 @@ fn session_error_status(code: &str) -> StatusCode {
// The session outlived its pane: nothing to type into, and nothing // The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it. // the daemon can do about it.
"not_running" => StatusCode::CONFLICT, "not_running" => StatusCode::CONFLICT,
"not_terminal" => StatusCode::CONFLICT,
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE, "shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY, _ => StatusCode::BAD_GATEWAY,
} }
@@ -429,6 +437,46 @@ async fn structured_replay(
} }
} }
#[derive(Deserialize)]
struct AttachmentQuery {
name: String,
}
async fn structured_attachment(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<AttachmentQuery>,
bytes: Bytes,
) -> Response {
let name = query.name;
if name.is_empty()
|| name.len() > 180
|| name == "."
|| name == ".."
|| name
.chars()
.any(|c| c.is_control() || c == '/' || c == '\\')
{
return error_response(
StatusCode::BAD_REQUEST,
"invalid_filename",
"use a filename without directory separators, up to 180 bytes",
);
}
match s.manager.store_acp_attachment(id, &name, &bytes).await {
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_prompt( async fn structured_prompt(
State(s): State<LocalApiState>, State(s): State<LocalApiState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@@ -471,6 +519,37 @@ async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
} }
} }
async fn structured_config(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<StructuredConfigRequest>,
) -> Response {
match s
.manager
.set_acp_config(id, body.config_id, body.value)
.await
{
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
Ok(SetAcpConfig::InvalidValue) => error_response(
StatusCode::BAD_REQUEST,
"invalid_config_value",
"configuration values must be strings or booleans",
),
Ok(SetAcpConfig::Unavailable) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Ok(SetAcpConfig::Failed(message)) => {
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
}
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_approval( async fn structured_approval(
State(s): State<LocalApiState>, State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>, Path((id, nonce)): Path<(Uuid, String)>,
@@ -534,12 +613,23 @@ async fn attach_ws(
State(s): State<LocalApiState>, State(s): State<LocalApiState>,
ws: axum::extract::WebSocketUpgrade, ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> { ) -> Result<Response, Response> {
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else { let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
return Err(error_response( Ok(subscription) => subscription,
StatusCode::NOT_FOUND, Err(SessionError::NotFound(_)) => {
"unknown_session", return Err(error_response(
"no such session on this daemon", StatusCode::NOT_FOUND,
)); "unknown_session",
"no such session on this daemon",
));
}
Err(error) => {
let info = error.to_error_info();
return Err(error_response(
session_error_status(&info.code),
&info.code,
&info.message,
));
}
}; };
let manager = s.manager.clone(); let manager = s.manager.clone();
Ok(ws.on_upgrade(move |socket| async move { Ok(ws.on_upgrade(move |socket| async move {
+261 -15
View File
@@ -2,6 +2,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall}; use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
use maestro_protocol::{ use maestro_protocol::{
ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind,
@@ -37,9 +38,12 @@ pub struct Projection {
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>, pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
state: ReducedState, state: ReducedState,
active_turn: Option<u64>, active_turn: Option<u64>,
active_turn_started_at: Option<DateTime<Utc>>,
active_turn_context_tokens: Option<u64>,
turn_failed: bool, turn_failed: bool,
next_turn: u64, next_turn: u64,
turn_tool_calls: HashSet<String>, turn_tool_calls: HashSet<String>,
anonymous_content_row: Option<(String, String)>,
} }
impl Projection { impl Projection {
@@ -60,6 +64,7 @@ impl Projection {
SessionUpdate::ToolCall(tool) => self.tool_call(tool), SessionUpdate::ToolCall(tool) => self.tool_call(tool),
SessionUpdate::ToolCallUpdate(update) => self.tool_update(update), SessionUpdate::ToolCallUpdate(update) => self.tool_update(update),
SessionUpdate::Plan(plan) => { SessionUpdate::Plan(plan) => {
self.anonymous_content_row = None;
let data = to_value(plan); let data = to_value(plan);
self.state.plan = Some(data.clone()); self.state.plan = Some(data.clone());
Some(self.replace("plan".into(), TranscriptRowKind::Plan, data)) Some(self.replace("plan".into(), TranscriptRowKind::Plan, data))
@@ -77,16 +82,20 @@ impl Projection {
SessionUpdate::ConfigOptionUpdate(options) => { SessionUpdate::ConfigOptionUpdate(options) => {
let value = to_value(options); let value = to_value(options);
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone()); self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
self.state.config_options = Some(value);
None None
} }
// ACP deliberately adds notification variants over time. Known but // ACP deliberately adds notification variants over time. Known but
// non-transcript updates become a generic notice instead of being // non-transcript updates become a generic notice instead of being
// dropped or forcing clients to understand ACP's enum. // dropped or forcing clients to understand ACP's enum.
_ => Some(self.append( _ => {
format!("notice:{seq}"), self.anonymous_content_row = None;
TranscriptRowKind::Notice, Some(self.append(
to_value(&notification.update), format!("notice:{seq}"),
)), TranscriptRowKind::Notice,
to_value(&notification.update),
))
}
}; };
ProjectionUpdate { ProjectionUpdate {
transcript, transcript,
@@ -122,12 +131,19 @@ impl Projection {
/// Start a prompt turn and return its monotonically increasing token. /// Start a prompt turn and return its monotonically increasing token.
pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) { pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) {
self.anonymous_content_row = None;
self.next_turn += 1; self.next_turn += 1;
let turn = self.next_turn; let turn = self.next_turn;
self.active_turn = Some(turn); self.active_turn = Some(turn);
let started_at = Utc::now();
let started_context_tokens = usage_tokens(self.state.usage.as_ref());
self.active_turn_started_at = Some(started_at);
self.active_turn_context_tokens = started_context_tokens;
self.turn_failed = false; self.turn_failed = false;
self.state.turn.responding = true; self.state.turn.responding = true;
self.state.turn.thinking = true; self.state.turn.thinking = true;
self.state.turn.started_at = Some(started_at);
self.state.turn.started_context_tokens = started_context_tokens;
self.turn_tool_calls.clear(); self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear(); self.state.turn.active_tool_calls.clear();
( (
@@ -156,10 +172,44 @@ impl Projection {
self.turn_failed = lifecycle == TurnLifecycle::Failure; self.turn_failed = lifecycle == TurnLifecycle::Failure;
self.state.turn.responding = false; self.state.turn.responding = false;
self.state.turn.thinking = false; self.state.turn.thinking = false;
let finished_at = Utc::now();
let started_at = self.active_turn_started_at.take();
let started_context_tokens = self.active_turn_context_tokens.take();
self.state.turn.started_at = None;
self.state.turn.started_context_tokens = None;
self.turn_tool_calls.clear(); self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear(); self.state.turn.active_tool_calls.clear();
let context_tokens = usage_tokens(self.state.usage.as_ref());
let context_delta = context_tokens
.zip(started_context_tokens)
.map(|(end, start)| i128::from(end) - i128::from(start));
let elapsed_ms = started_at.map_or(0, |started| {
finished_at
.signed_duration_since(started)
.num_milliseconds()
.max(0)
});
let outcome = match lifecycle {
TurnLifecycle::Finish => "completed",
TurnLifecycle::Cancel => "cancelled",
TurnLifecycle::Failure => "failed",
TurnLifecycle::Begin => unreachable!(),
};
let row = self.append(
format!("turn_meta:{}", uuid::Uuid::new_v4()),
TranscriptRowKind::Notice,
serde_json::json!({
"maestroTurnMeta": {
"elapsedMs": elapsed_ms,
"contextTokens": context_tokens,
"contextDelta": context_delta,
"outcome": outcome,
"model": self.state.model,
}
}),
);
Some(ProjectionUpdate { Some(ProjectionUpdate {
transcript: None, transcript: Some(row),
state: self.state(), state: self.state(),
}) })
} }
@@ -186,7 +236,8 @@ impl Projection {
} }
} }
pub fn restore_approval(&mut self, row: TranscriptRow) -> ProjectionUpdate { pub fn restore_row(&mut self, row: TranscriptRow) -> ProjectionUpdate {
self.anonymous_content_row = None;
let transcript = if !self.row_index.contains_key(&row.id) { let transcript = if !self.row_index.contains_key(&row.id) {
self.row_index.insert(row.id.clone(), self.rows.len()); self.row_index.insert(row.id.clone(), self.rows.len());
self.rows.push(row.clone()); self.rows.push(row.clone());
@@ -245,12 +296,32 @@ impl Projection {
kind: TranscriptRowKind, kind: TranscriptRowKind,
prefix: &str, prefix: &str,
) -> Option<TranscriptDelta> { ) -> Option<TranscriptDelta> {
let id = chunk
.message_id
.as_ref()
.map(|id| format!("{prefix}:{id}"))
.unwrap_or_else(|| format!("{prefix}:{seq}"));
let data = to_value(chunk); let data = to_value(chunk);
let id = if let Some(message_id) = chunk.message_id.as_ref() {
self.anonymous_content_row = None;
format!("{prefix}:{message_id}")
} else {
let text = data.pointer("/content/text").and_then(Value::as_str);
let previous = self
.anonymous_content_row
.as_ref()
.filter(|(previous_prefix, _)| previous_prefix == prefix)
.map(|(_, id)| id.clone());
if text.is_some_and(|text| !text.is_empty()) {
if let Some(id) = previous {
let index = *self
.row_index
.get(&id)
.expect("anonymous content row remains addressable");
let row = &mut self.rows[index];
append_text(&mut row.data, &data);
return Some(TranscriptDelta::Patch { row: row.clone() });
}
}
let id = format!("{prefix}:{seq}");
self.anonymous_content_row = text.map(|_| (prefix.to_owned(), id.clone()));
id
};
if let Some(index) = self.row_index.get(&id).copied() { if let Some(index) = self.row_index.get(&id).copied() {
let row = &mut self.rows[index]; let row = &mut self.rows[index];
append_text(&mut row.data, &data); append_text(&mut row.data, &data);
@@ -260,6 +331,7 @@ impl Projection {
} }
fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> { fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> {
self.anonymous_content_row = None;
let id = format!("tool:{}", tool.tool_call_id); let id = format!("tool:{}", tool.tool_call_id);
let tool_id = tool.tool_call_id.to_string(); let tool_id = tool.tool_call_id.to_string();
let mut canonical = tool.clone(); let mut canonical = tool.clone();
@@ -290,6 +362,7 @@ impl Projection {
&mut self, &mut self,
update: &maestro_acp::schema::ToolCallUpdate, update: &maestro_acp::schema::ToolCallUpdate,
) -> Option<TranscriptDelta> { ) -> Option<TranscriptDelta> {
self.anonymous_content_row = None;
let tool_id = update.tool_call_id.to_string(); let tool_id = update.tool_call_id.to_string();
let Some(tool) = self.tools.get_mut(&tool_id) else { let Some(tool) = self.tools.get_mut(&tool_id) else {
self.pending_tool_updates self.pending_tool_updates
@@ -383,6 +456,10 @@ impl Projection {
} }
} }
fn usage_tokens(usage: Option<&Value>) -> Option<u64> {
usage?.get("used")?.as_u64()
}
fn to_value(value: &impl serde::Serialize) -> Value { fn to_value(value: &impl serde::Serialize) -> Value {
serde_json::to_value(value).expect("ACP schema values serialize") serde_json::to_value(value).expect("ACP schema values serialize")
} }
@@ -426,7 +503,7 @@ fn model_from_options(value: &Value) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use maestro_acp::schema::SessionNotification; use maestro_acp::schema::SessionNotification;
use maestro_protocol::{TranscriptDelta, TranscriptRowKind}; use maestro_protocol::{TranscriptDelta, TranscriptRow, TranscriptRowKind};
use super::{Projection, TurnLifecycle}; use super::{Projection, TurnLifecycle};
@@ -496,6 +573,137 @@ mod tests {
); );
} }
#[test]
fn projects_the_codex_adapter_fixture_without_provider_specific_rows() {
let fixture = include_str!("../tests/fixtures/codex-acp-session.jsonl");
let events = fixture
.lines()
.map(serde_json::from_str::<SessionNotification>)
.collect::<Result<Vec<_>, _>>()
.expect("Codex ACP fixture follows the supported schema");
let mut projection = Projection::default();
for (seq, event) in events.iter().enumerate() {
projection.apply((seq + 1) as u64, event);
}
let rows = projection.snapshot(events.len() as u64).rows;
assert_eq!(
rows.iter().map(|row| row.kind).collect::<Vec<_>>(),
[
TranscriptRowKind::User,
TranscriptRowKind::AgentThought,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolDiff,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::Plan,
TranscriptRowKind::Notice,
TranscriptRowKind::Notice,
TranscriptRowKind::AgentText,
]
);
assert_eq!(rows[2].data["rawInput"]["command"], "rg TODO");
assert_eq!(rows[2].data["rawOutput"]["exit_code"], 0);
assert_eq!(rows[4].data["_meta"]["is_mcp_tool_call"], true);
assert_eq!(rows[5].data["rawInput"]["query"], "Agent Client Protocol");
assert_eq!(
rows[7].data["content"][0]["content"]["mimeType"],
"image/png"
);
let state = projection.state();
assert_eq!(state.model.as_deref(), Some("gpt-5.3-codex"));
assert_eq!(state.mode.as_deref(), Some("agent"));
assert_eq!(state.usage.as_ref().unwrap()["used"], 1200);
assert_eq!(state.context.as_ref().unwrap()["size"], 258400);
assert_eq!(
state.config_options.as_ref().unwrap()["configOptions"][2]["currentValue"],
"high"
);
}
#[test]
fn coalesces_anonymous_stream_chunks_without_joining_turns() {
let mut projection = Projection::default();
let (first_turn, _) = projection.begin_turn();
for (seq, text) in [(1, ""), (2, "p"), (3, "ong")] {
projection.apply(
seq,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text},
})),
);
}
projection.turn_lifecycle(TurnLifecycle::Finish, first_turn);
projection.begin_turn();
projection.apply(
4,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "next"},
})),
);
let rows = projection.snapshot(4).rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "pong");
assert!(rows[1].data.get("maestroTurnMeta").is_some());
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "next");
}
#[test]
fn anonymous_empty_chunk_delimits_turns_during_replay() {
let mut projection = Projection::default();
for (seq, text) in [(1, ""), (2, "visible"), (3, ""), (4, "p"), (5, "ong")] {
projection.apply(
seq,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text},
})),
);
}
let rows = projection.snapshot(5).rows;
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "visible");
assert_eq!(rows[1].data.pointer("/content/text").unwrap(), "pong");
}
#[test]
fn restored_user_prompt_delimits_anonymous_agent_rows() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "before"},
})),
);
projection.restore_row(TranscriptRow {
id: "user:prompt".into(),
kind: TranscriptRowKind::User,
data: serde_json::json!({"content": {"type": "text", "text": "next"}}),
});
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "after"},
})),
);
let rows = projection.snapshot(2).rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "before");
assert_eq!(rows[1].kind, TranscriptRowKind::User);
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "after");
}
#[test] #[test]
fn tool_update_patches_the_original_stable_row() { fn tool_update_patches_the_original_stable_row() {
let mut projection = Projection::default(); let mut projection = Projection::default();
@@ -614,6 +822,10 @@ mod tests {
assert_eq!(state.context.as_ref().unwrap()["size"], 200); assert_eq!(state.context.as_ref().unwrap()["size"], 200);
assert_eq!(state.mode.as_deref(), Some("plan")); assert_eq!(state.mode.as_deref(), Some("plan"));
assert_eq!(state.model.as_deref(), Some("opus")); assert_eq!(state.model.as_deref(), Some("opus"));
assert_eq!(
state.config_options.as_ref().unwrap()["configOptions"][0]["currentValue"],
"opus"
);
assert_eq!( assert_eq!(
state.plan.as_ref().unwrap()["entries"], state.plan.as_ref().unwrap()["entries"],
serde_json::json!([]) serde_json::json!([])
@@ -685,6 +897,38 @@ mod tests {
} }
} }
#[test]
fn completed_turn_keeps_duration_and_context_delta_in_transcript() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"usage_update", "used":10_000, "size":200_000
})),
);
let (turn, begun) = projection.begin_turn();
assert!(begun.state.turn.started_at.is_some());
assert_eq!(begun.state.turn.started_context_tokens, Some(10_000));
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"usage_update", "used":12_500, "size":200_000
})),
);
let finished = projection
.turn_lifecycle(TurnLifecycle::Finish, turn)
.expect("active turn finishes");
let TranscriptDelta::Append { row } = finished.transcript.unwrap() else {
panic!("turn footer");
};
assert_eq!(row.kind, TranscriptRowKind::Notice);
assert_eq!(row.data["maestroTurnMeta"]["contextDelta"], 2_500);
assert_eq!(row.data["maestroTurnMeta"]["contextTokens"], 12_500);
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
assert!(finished.state.turn.started_at.is_none());
}
fn terminal_turn_drops_unfinished_tools( fn terminal_turn_drops_unfinished_tools(
terminal: TurnLifecycle, terminal: TurnLifecycle,
unfinished_id: &str, unfinished_id: &str,
@@ -703,7 +947,9 @@ mod tests {
projection.turn_lifecycle(terminal, turn); projection.turn_lifecycle(terminal, turn);
assert!(projection.state().turn.active_tool_calls.is_empty()); assert!(projection.state().turn.active_tool_calls.is_empty());
assert_eq!(projection.snapshot(1).rows.len(), 1); let rows = projection.snapshot(1).rows;
assert_eq!(rows.len(), 2);
assert!(rows[1].data.get("maestroTurnMeta").is_some());
projection.begin_turn(); projection.begin_turn();
projection.apply( projection.apply(
@@ -829,7 +1075,7 @@ mod tests {
panic!("approval row") panic!("approval row")
}; };
let mut recovered = Projection::default(); let mut recovered = Projection::default();
let update = recovered.restore_approval(row.clone()); let update = recovered.restore_row(row.clone());
assert_eq!( assert_eq!(
update.transcript, update.transcript,
Some(TranscriptDelta::Append { row: row.clone() }) Some(TranscriptDelta::Append { row: row.clone() })
File diff suppressed because it is too large Load Diff
+6
View File
@@ -263,9 +263,15 @@ pub struct LoggedEvent {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "maestroEvent", rename_all = "snake_case")] #[serde(tag = "maestroEvent", rename_all = "snake_case")]
pub enum PersistedEvent { pub enum PersistedEvent {
User {
row: TranscriptRow,
},
Approval { Approval {
row: TranscriptRow, row: TranscriptRow,
}, },
Turn {
row: TranscriptRow,
},
#[serde(skip)] #[serde(skip)]
Notification(Box<SessionNotification>), Notification(Box<SessionNotification>),
} }
+296 -10
View File
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture}; use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_acp::schema::{ContentBlock, SessionUpdate}; use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND}; use maestro_acp::{AcpAdapter, ADAPTER_BIN};
use maestro_daemon::projection::TurnLifecycle; use maestro_daemon::projection::TurnLifecycle;
use maestro_daemon::sidecar::{EventCursor, SidecarStore}; use maestro_daemon::sidecar::{EventCursor, SidecarStore};
use maestro_daemon::worktree::WorktreeManager; use maestro_daemon::worktree::WorktreeManager;
@@ -38,6 +38,7 @@ impl Fake {
let bin = dir.path().join("bin"); let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap(); std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap(); std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir } Self { dir }
} }
@@ -96,10 +97,18 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
} }
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest { fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
structured_command(cwd, env, "claude")
}
fn structured_command(
cwd: &Path,
env: HashMap<String, String>,
command: &str,
) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: SessionView::Structured, view: SessionView::Structured,
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
command: "claude".into(), command: command.into(),
env, env,
metadata: HashMap::new(), metadata: HashMap::new(),
worktree: None, worktree: None,
@@ -130,7 +139,11 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let session = fx let session = fx
.manager .manager
.create(structured(cwd.path(), env)) .create(structured_command(
cwd.path(),
env,
"codex --dangerously-bypass-approvals-and-sandbox",
))
.await .await
.expect("create"); .expect("create");
@@ -215,7 +228,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into()); env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
let session = fx let session = fx
.manager .manager
.create(structured(cwd.path(), env)) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.unwrap(); .unwrap();
@@ -349,12 +362,75 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
.await .await
.expect("finish lifecycle broadcast") .expect("finish lifecycle broadcast")
.expect("projection broadcast"); .expect("projection broadcast");
assert_eq!(finish.transcript, None); let Some(TranscriptDelta::Append { row }) = finish.transcript else {
panic!("finish should append durable turn metadata");
};
assert_eq!(row.kind, TranscriptRowKind::Notice);
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
assert_eq!(finish.state.turn, Default::default()); assert_eq!(finish.state.turn, Default::default());
let recovered = fx
.manager
.read_acp_projection_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("turn replay")
.expect("structured session");
assert!(recovered.iter().any(|event| {
matches!(
&event.transcript,
Some(TranscriptDelta::Append { row })
if row.data["maestroTurnMeta"]["outcome"] == "completed"
)
}));
fx.manager.kill(session.id, false).await.expect("kill"); fx.manager.kill(session.id, false).await.expect("kill");
} }
#[tokio::test]
async fn queued_prompt_is_a_durable_user_transcript_row() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "show my prompt".into())
.await
.expect("queue prompt"),
maestro_daemon::session::QueueAcpPrompt::Queued
));
let (snapshot, _, _) = fx
.manager
.subscribe_acp_projection(session.id, EventCursor::Since(0))
.await
.expect("projection subscription")
.expect("structured session");
let user = snapshot
.rows
.iter()
.find(|row| row.kind == TranscriptRowKind::User)
.expect("user prompt row");
assert_eq!(user.data["content"]["text"], "show my prompt");
let persisted = fx
.manager
.read_acp_events(session.id, EventCursor::Since(0), 100)
.expect("event replay");
assert!(persisted.iter().any(|event| matches!(
&event.event,
maestro_daemon::sidecar::PersistedEvent::User { row }
if row.data["content"]["text"] == "show my prompt"
)));
fx.manager.kill(session.id, false).await.unwrap();
}
#[tokio::test] #[tokio::test]
async fn stale_turn_completion_cannot_end_the_next_turn() { async fn stale_turn_completion_cannot_end_the_next_turn() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
@@ -507,7 +583,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let session = fx let session = fx
.manager .manager
.create(structured(cwd.path(), env)) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.expect("create"); .expect("create");
let acp_id = session.acp_session_id.clone().expect("acp id"); let acp_id = session.acp_session_id.clone().expect("acp id");
@@ -621,7 +697,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: SessionView::Structured, view: SessionView::Structured,
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "claude".into(), command: "codex".into(),
env: fake.env(), env: fake.env(),
metadata: HashMap::new(), metadata: HashMap::new(),
worktree: Some(WorktreeSpec { worktree: Some(WorktreeSpec {
@@ -716,15 +792,18 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
let session = fx let session = fx
.manager .manager
.create(structured(cwd.path(), env)) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.expect("a failed session, not an error"); .expect("a failed session, not an error");
assert_eq!(session.status, SessionStatus::Dead); assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error); assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail"); let detail = session.activity_detail.clone().expect("actionable detail");
assert!(detail.contains(ADAPTER_BIN), "{detail}"); assert!(detail.contains(AcpAdapter::Codex.binary()), "{detail}");
assert!(detail.contains(INSTALL_COMMAND), "{detail}"); assert!(
detail.contains(AcpAdapter::Codex.install_command()),
"{detail}"
);
assert_eq!(session.acp_session_id, None); assert_eq!(session.acp_session_id, None);
// It is a real session: listed, on disk, and dismissable like any other // It is a real session: listed, on disk, and dismissable like any other
@@ -738,6 +817,28 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
assert!(!fx.manager.has(session.id).await); assert!(!fx.manager.has(session.id).await);
} }
#[tokio::test]
async fn unsupported_structured_command_fails_without_launching_claude() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured_command(cwd.path(), fake.env(), "opencode"))
.await
.expect("failed structured session is persisted");
assert_eq!(session.status, SessionStatus::Dead);
let detail = session.activity_detail.expect("selection error");
assert!(detail.contains("Claude Code or Codex"), "{detail}");
assert!(
fake.log().is_empty(),
"unsupported command launched an adapter"
);
fx.manager.kill(session.id, false).await.expect("dismiss");
}
#[tokio::test] #[tokio::test]
async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() { async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
@@ -950,6 +1051,191 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
assert_eq!(fake.log().matches("signal SIGTERM").count(), 1); assert_eq!(fake.log().matches("signal SIGTERM").count(), 1);
} }
#[tokio::test]
async fn structured_handshakes_run_independently() {
let fx = DaemonFixture::new();
let blocked = Fake::new();
let ready = Fake::new();
let cwd = TempDir::new().unwrap();
let gate = cwd.path().join("release-first");
let mut blocked_env = blocked.env();
blocked_env.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate.to_string_lossy().into_owned(),
);
let first_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let first =
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
wait_for_async(Duration::from_secs(5), || async {
blocked.log().contains("recv session/new")
})
.await;
let second = tokio::time::timeout(
Duration::from_secs(5),
fx.manager.create(structured(cwd.path(), ready.env())),
)
.await
.expect("second handshake was blocked by the first")
.expect("second create");
assert_eq!(second.status, SessionStatus::Running);
assert!(!first.is_finished(), "the gate must still block the first");
std::fs::write(&gate, b"release").unwrap();
let first = first.await.expect("first task").expect("first create");
fx.manager.kill(first.id, false).await.expect("kill first");
fx.manager
.kill(second.id, false)
.await
.expect("kill second");
}
#[tokio::test]
async fn shutdown_waits_for_every_admitted_structured_create() {
let fx = DaemonFixture::new();
let a = Fake::new();
let b = Fake::new();
let late = Fake::new();
let cwd = TempDir::new().unwrap();
let gate_a = cwd.path().join("release-a");
let gate_b = cwd.path().join("release-b");
let mut env_a = a.env();
let mut env_b = b.env();
env_a.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate_a.to_string_lossy().into_owned(),
);
env_b.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate_b.to_string_lossy().into_owned(),
);
let create_a = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
};
let create_b = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
};
wait_for_async(Duration::from_secs(5), || async {
a.log().contains("recv session/new") && b.log().contains("recv session/new")
})
.await;
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
wait_for_async(Duration::from_secs(5), || async {
matches!(
fx.manager.create(structured(cwd.path(), late.env())).await,
Err(maestro_daemon::SessionError::ShuttingDown)
)
})
.await;
assert!(!shutdown.is_finished());
std::fs::write(&gate_a, b"release").unwrap();
let session_a = create_a.await.expect("create a task").expect("create a");
assert!(
!shutdown.is_finished(),
"shutdown ignored the second create"
);
std::fs::write(&gate_b, b"release").unwrap();
let session_b = create_b.await.expect("create b task").expect("create b");
shutdown.await.expect("shutdown task");
wait_for_exit(a.pid()).await;
wait_for_exit(b.pid()).await;
assert_eq!(
fx.manager.status(session_a.id).await,
Some(SessionStatus::Dead)
);
assert_eq!(
fx.manager.status(session_b.id).await,
Some(SessionStatus::Dead)
);
}
#[tokio::test]
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let gate = repo.path().join("release-create");
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
env.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate.to_string_lossy().into_owned(),
);
let request = CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
env,
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "cancelled-create".into(),
create_branch: true,
}),
};
let create = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.create(request).await })
};
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let id = fake
.log()
.lines()
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.expect("session id in adapter log");
let pid = fake.pid();
create.abort();
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
assert!(create.await.unwrap_err().is_cancelled());
tokio::time::timeout(Duration::from_secs(5), shutdown)
.await
.expect("cancelled admission kept shutdown waiting")
.expect("shutdown task");
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(5), || async {
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
&& !repo.path().join("cancelled-create").exists()
})
.await;
assert!(fx.manager.snapshot().await.is_empty());
let id = id.to_string();
assert!(
std::fs::read_dir(fx.sidecar_dir())
.into_iter()
.flatten()
.all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(&id)),
"cancelled create left a sidecar artifact"
);
}
#[tokio::test] #[tokio::test]
async fn shutdown_drains_final_notifications_to_the_log() { async fn shutdown_drains_final_notifications_to_the_log() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
@@ -0,0 +1,16 @@
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
+87
View File
@@ -234,6 +234,60 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
); );
} }
#[tokio::test]
async fn structured_attachments_preserve_bytes_and_reject_paths() {
let h = spawn("attachments").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
let endpoint = url(
h.addr,
&format!("/api/sessions/{id}/structured/attachments"),
);
let bytes = vec![0, 255, 10, 13];
let response = client
.post(&endpoint)
.query(&[("name", "sample.bin")])
.body(bytes.clone())
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.unwrap();
let path = body["path"].as_str().unwrap();
assert!(Path::new(path).is_absolute());
assert_eq!(std::fs::read(path).unwrap(), bytes);
for name in ["../escape", "a/b", "a\\b", "..", ""] {
let response = client
.post(&endpoint)
.query(&[("name", name)])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 400, "{name}");
}
let response = client
.post(&endpoint)
.query(&[("name", "large.bin")])
.body(vec![0; 20 * 1024 * 1024 + 1])
.send()
.await
.unwrap();
assert_eq!(response.status(), 413);
let response = client
.post(url(
h.addr,
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
))
.query(&[("name", "file.txt")])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test] #[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() { async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await; let h = spawn("naps-laptop").await;
@@ -1311,6 +1365,39 @@ async fn attach_ws_streams_pty_bytes() {
assert!(saw_hello, "PTY did not echo through the attach WS"); assert!(saw_hello, "PTY did not echo through the attach WS");
} }
#[tokio::test]
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let response = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
.json(&maestro_protocol::SendInputRequest {
text: "ignored".into(),
submit: false,
})
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = response.json().await.unwrap();
assert_eq!(error.error.code, "not_terminal");
assert_eq!(error.error.message, "session is not a terminal session");
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
.await
.expect_err("structured session must not upgrade to a PTY socket");
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
panic!("expected an HTTP handshake rejection, got {error}");
};
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let body = response.body().as_deref().expect("ApiError response body");
let error: ApiError = serde_json::from_slice(body).unwrap();
assert_eq!(error.error.code, "not_terminal");
assert_eq!(error.error.message, "session is not a terminal session");
}
#[tokio::test] #[tokio::test]
async fn attach_ws_for_unknown_session_returns_404() { async fn attach_ws_for_unknown_session_returns_404() {
let h = spawn("naps-laptop").await; let h = spawn("naps-laptop").await;
+16 -6
View File
@@ -405,15 +405,17 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
); );
} }
/// 49ab7e9: a new-branch add onto a name that already exists suffixes the /// `git worktree add -b` alone fails outright when the branch already exists,
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md. /// even with the target directory free — a random handle commonly collides
/// with one an earlier, already-removed worktree left behind. `add_new`
/// sidesteps it on the branch name itself instead of erroring (#77).
#[test] #[test]
fn add_new_suffixes_when_branch_already_exists() { fn add_new_suffixes_branch_name_on_branch_collision() {
let repo_dir = TempDir::new().unwrap(); let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf(); let repo = repo_dir.path().to_path_buf();
init_repo(&repo); init_repo(&repo);
// pre-create the branch // pre-create the branch, unused by any worktree.
let status = Command::new("git") let status = Command::new("git")
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
@@ -425,9 +427,17 @@ fn add_new_suffixes_when_branch_already_exists() {
let wt_root = TempDir::new().unwrap(); let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/dup").unwrap(); let path = mgr
.add_new(&repo, "feat/dup")
.expect("sidesteps the branch collision");
assert!(path.ends_with("feat/dup-2"), "{}", path.display()); assert!(path.ends_with("feat/dup-2"), "{}", path.display());
assert!(path.is_dir()); assert!(path.exists());
// The pre-existing branch is untouched; the new worktree checked out the
// suffixed one instead of erroring or adopting `feat/dup`.
let branches = worktree_branches(&repo);
assert!(branches.contains(&"feat/dup-2".to_string()), "{branches:?}");
assert!(!branches.contains(&"feat/dup".to_string()), "{branches:?}");
} }
/// The project picker lists linked worktrees as pickable rows, so `base_repo` /// The project picker lists linked worktrees as pickable rows, so `base_repo`
+32 -28
View File
@@ -383,13 +383,11 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
client.kill_session(s.id).await.expect("kill"); client.kill_session(s.id).await.expect("kill");
} }
/// #66: a second spawn onto an *existing* branch that already has a worktree /// #66/#77: a second spawn onto the same branch used to be rejected outright;
/// is rejected with a structured worktree error; the first worktree stays /// since the branch-collision suffixing landed, it succeeds instead — the
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9 /// second worktree lands on `<branch>-2` and the first stays untouched.
/// suffixes the new branch name instead, see
/// `colliding_branch_name_gets_suffixed_too`.)
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() { async fn second_spawn_on_same_branch_gets_suffixed() {
let (_daemon, client) = bring_up().await; let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap(); let base = TempDir::new().unwrap();
@@ -399,28 +397,33 @@ async fn second_spawn_on_same_branch_is_rejected() {
.spawn_session(&worktree_req(base.path(), "feature/dup", true)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await .await
.expect("first spawn"); .expect("first spawn");
let dup = client let second = client
.spawn_session(&worktree_req(base.path(), "feature/dup", false)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await; .await
match dup { .expect("second spawn suffixes instead of rejecting");
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
assert_eq!(
info.code, "worktree_failed",
"expected worktree error: {info:?}"
);
}
other => panic!("expected rejection for duplicate branch, got {other:?}"),
}
// Exactly one registration, one directory, still usable. let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
// Count branch records only: the worktree path now contains the branch assert_eq!(
// name too (`worktrees/feature/dup`), so a substring match over every line second.cwd, suffixed,
// would count the same tree twice. "second worktree must land on the suffixed path"
let holders = git_worktree_list(base.path()) );
.iter() assert!(suffixed.is_dir());
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
.count(); // Both registrations exist: the original branch, untouched, and the
assert_eq!(holders, 1, "duplicate spawn must not register another tree"); // suffixed one the second spawn created.
let registrations = git_worktree_list(base.path());
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
"original branch must stay registered: {registrations:?}"
);
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
"suffixed branch must be registered too: {registrations:?}"
);
assert!( assert!(
base.path() base.path()
.join("worktrees") .join("worktrees")
@@ -431,7 +434,8 @@ async fn second_spawn_on_same_branch_is_rejected() {
); );
let _origin = throwaway_origin(base.path()); let _origin = throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill"); client.kill_session(first.id).await.expect("kill first");
client.kill_session(second.id).await.expect("kill second");
} }
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement /// #66/#56: when the client picks a *linked worktree* as base_repo, placement
+623 -71
View File
@@ -166,45 +166,32 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur cur
} }
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments — /// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program /// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`, /// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`. /// `npx`, `pnpm dlx`, …) are looked through, and quoting/escaping is honoured
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
/// substitution, an unterminated quote) falls back to the naive first word
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String { pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect(); match resolve(command) {
let mut i = 0; Some(r) => match r.words.get(r.program_index) {
while i < words.len() { Some(word) => basename(word).to_string(),
let word = words[i]; // Nothing but wrappers and assignments: say what was there
if is_env_assignment(word) { // rather than rendering an empty fragment.
i += 1; None => command.trim().to_string(),
continue; },
} None => legacy_first_word(command),
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
} }
} }
/// Program name (already basenamed and lowercased) -> the tool id its mark /// Program name (already basenamed and lowercased) -> the tool id its mark
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`; /// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
/// recognition is deliberately narrow, so an unrecognised program has no tool. /// recognition is deliberately narrow, so an unrecognised program has no tool.
/// `claude-code-acp` — the daemon's actual structured-session child — is
/// deliberately absent: the daemon launches it directly and ignores
/// `req.command`, so it is never itself a valid user-facing launch form.
const BY_PROGRAM: [(&str, &str); 17] = [ const BY_PROGRAM: [(&str, &str); 17] = [
("claude", "claude"), ("claude", "claude"),
("claude-code", "claude"), ("claude-code", "claude"),
@@ -226,29 +213,500 @@ const BY_PROGRAM: [(&str, &str); 17] = [
]; ];
/// The tool id a command line's icon stands for (`claude`, `codex`, /// The tool id a command line's icon stands for (`claude`, `codex`,
/// `opencode`, ...), or `None` when the program is not one we have a mark for. /// `opencode`, ...), or `None` when the program is not one we have a mark for
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name /// — including a command line [`resolve`] would only guess at. The command
/// the same tools the web's `splitCommand().tool` does. /// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
/// tools the web's `splitCommand().tool` does.
pub fn command_tool(command: &str) -> Option<&'static str> { pub fn command_tool(command: &str) -> Option<&'static str> {
let program = command_program(command).to_lowercase(); let r = resolve(command)?;
let program = basename(r.words.get(r.program_index)?).to_lowercase();
BY_PROGRAM BY_PROGRAM
.iter() .iter()
.find(|(name, _)| *name == program) .find(|(name, _)| *name == program)
.map(|(_, tool)| *tool) .map(|(_, tool)| *tool)
} }
/// `FOO=bar`, the shell's own prefix for one command's environment. /// Whether a command line resolves to an agent Maestro can run as a
fn is_env_assignment(word: &str) -> bool { /// structured (ACP) session — Claude Code or Codex, the two adapters
let mut chars = word.chars(); /// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
/// control on this, and the daemon rejects a `Structured` request for
/// anything else, so the answer has to come from one place.
pub fn supports_structured_view(command: &str) -> bool {
matches!(command_tool(command), Some("claude" | "codex"))
}
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
/// of the word that is the actual program (past every assignment/wrapper it
/// resolved through).
struct Resolution {
words: Vec<String>,
program_index: usize,
}
/// Tokenize `command` and walk past leading `FOO=bar` assignments and known
/// wrapper forms (interleaved in any order/count) to find the program. An
/// assignment run is only consumed at the very start of the line or right
/// after a wrapper whose own semantics accept one there ([`Wrapper::accepts_assignment`]) —
/// `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
/// skipping to `claude`, since `command`/`nohup` just exec their next argument
/// as the program, they don't setenv from an assignment-shaped one (#199).
/// `None` when [`tokenize`] can't safely make sense of the line at all.
fn resolve(command: &str) -> Option<Resolution> {
let words = tokenize(command)?;
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
let mut i = 0;
let mut assignment_allowed = true;
loop {
if assignment_allowed {
while matches!(words.get(i), Some(word) if is_env_assignment(word)) {
i += 1;
}
}
let Some(word) = words.get(i) else { break };
let wrapper = basename(&word.text).to_lowercase();
let Some(w) = wrapper_resolver(&wrapper) else {
break;
};
match (w.resolve)(&refs, i) {
Some(next) if next > i => {
i = next;
assignment_allowed = w.accepts_assignment;
}
// Not a form of the wrapper we recognise (an unknown option, or
// — for `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it):
// the wrapper word itself is the program, not a launcher.
_ => break,
}
}
Some(Resolution {
words: words.into_iter().map(|w| w.text).collect(),
program_index: i,
})
}
/// The naive "first whitespace word, basenamed" reading used only when
/// [`tokenize`] rejects the line outright — still something sane to show,
/// never a wrapper/tool match to act on.
fn legacy_first_word(command: &str) -> String {
match command.split_whitespace().next() {
Some(word) => basename(word).to_string(),
None => command.trim().to_string(),
}
}
/// One token from [`tokenize`]: its dequoted text, and — when both the name
/// and `=` were entirely unquoted/unescaped in the source — the byte length
/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
/// makes the word an executable named `FOO=1`; quoting/escaping only the value
/// preserves the assignment, matching Bash's assignment-word grammar (#199).
#[derive(Debug, Clone, PartialEq, Eq)]
struct Word {
text: String,
plain_eq: Option<usize>,
}
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
/// `None` the moment it needs real shell grammar this resolver doesn't
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
/// separator, like `;` — #199), or an unterminated quote/escape. Never
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
/// backslash immediately before a newline, quoted or not, is a line
/// continuation: removed entirely rather than embedding the newline or
/// ending the word (#199).
fn tokenize(command: &str) -> Option<Vec<Word>> {
let mut words = Vec::new();
let mut current = String::new();
let mut has_current = false;
let mut plain_eq: Option<usize> = None;
let mut assignment_name_is_plain = true;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
match c {
' ' | '\t' | '\r' => {
if has_current {
words.push(Word {
text: std::mem::take(&mut current),
plain_eq: plain_eq.take(),
});
has_current = false;
assignment_name_is_plain = true;
}
}
'\n' => return None,
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
'$' if chars.peek() == Some(&'(') => return None,
'\'' => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
loop {
match chars.next() {
Some('\'') => break,
Some(c2) => current.push(c2),
None => return None, // unterminated single quote
}
}
}
'"' => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
current.push('\\');
current.push(c2);
}
None => return None, // trailing backslash
},
Some(c2) => current.push(c2),
None => return None, // unterminated double quote
}
}
}
'\\' => match chars.next() {
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
current.push(c2);
}
None => return None, // trailing backslash
},
other => {
has_current = true;
if other == '=' && plain_eq.is_none() && assignment_name_is_plain {
plain_eq = Some(current.len());
}
current.push(other);
}
}
}
if has_current {
words.push(Word {
text: current,
plain_eq,
});
}
Some(words)
}
/// A wrapper's known `-`/`--` options: which take no value, and which
/// consume either a following word or a `--flag=value` as theirs. A
/// `-`-prefixed word this table doesn't know halts resolution rather than
/// guessing past it (#199) — callers read that as [`OptionTable::consume`]
/// returning `None`.
struct OptionTable {
flags: &'static [&'static str],
valued: &'static [&'static str],
/// Whether this wrapper's short options may be combined (`-iv`) or take
/// an attached value (`-uroot`), the way real getopt-based tools do
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
/// `dlx`) don't get this — guessing at bundling they don't actually
/// support would resolve through options they'd reject outright.
bundles_short_options: bool,
}
impl OptionTable {
/// Consume this table's options starting at `words[i]`, returning the
/// index just past them (which may equal `i` if there are none) — or
/// `None` on an option this table doesn't recognise, or a valued option
/// missing its operand.
fn consume(&self, words: &[&str], mut i: usize) -> Option<usize> {
loop {
let Some(&word) = words.get(i) else {
return Some(i);
};
if word == "--" {
return Some(i + 1);
}
if word == "-" || !word.starts_with('-') {
return Some(i);
}
if let Some((flag, value)) = word.split_once('=') {
return if self.valued.contains(&flag) && !value.is_empty() {
i += 1;
continue;
} else {
None
};
}
if self.flags.contains(&word) {
i += 1;
continue;
}
if self.valued.contains(&word) {
let value = words.get(i + 1)?;
if value.is_empty() || value.starts_with('-') {
return None;
}
i += 2;
continue;
}
if self.bundles_short_options
&& !word.starts_with("--")
&& word.len() > 2
&& self.matches_short_cluster(word)
{
i += 1;
continue;
}
return None;
}
}
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
/// option with its value attached) — getopt's traditional short-option
/// forms. `false` the moment a character isn't a short option this
/// table knows, or a valued one ends the word with no attached value
/// left to be its operand (#199: rejected rather than guessed to come
/// from the next word instead).
fn matches_short_cluster(&self, word: &str) -> bool {
if !word.is_ascii() {
return false;
}
let bytes = word.as_bytes();
let mut buf = [b'-', 0];
let mut idx = 1;
while idx < bytes.len() {
buf[1] = bytes[idx];
let short = std::str::from_utf8(&buf).expect("ascii buffer");
if self.flags.contains(&short) {
idx += 1;
continue;
}
if self.valued.contains(&short) {
return idx + 1 < bytes.len();
}
return false;
}
true
}
}
const NO_OPTIONS: OptionTable = OptionTable {
flags: &[],
valued: &[],
bundles_short_options: false,
};
const ENV_OPTS: OptionTable = OptionTable {
flags: &[
"-i",
"--ignore-environment",
"-0",
"--null",
"-v",
"--debug",
],
valued: &["-u", "--unset", "-C", "--chdir"],
bundles_short_options: true,
};
const SUDO_OPTS: OptionTable = OptionTable {
flags: &[
"-i",
"--login",
"-n",
"--non-interactive",
"-E",
"--preserve-env",
"-H",
"--set-home",
"-A",
"--askpass",
"-b",
"--background",
"-k",
"--reset-timestamp",
"-K",
"--remove-timestamp",
"-S",
"--stdin",
],
valued: &["-u", "--user", "-g", "--group"],
bundles_short_options: true,
};
/// `-v`/`-V` only print what would run — they never execute it — so
/// resolving through them would claim a program runs that `command` never
/// launches (#199). Only `-p`, which still executes normally, is safe to
/// look through.
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
flags: &["-p"],
valued: &[],
bundles_short_options: true,
};
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
/// `/usr/bin/time`, a different program a bare `time` word doesn't
/// necessarily name. Assuming them here would be guessing which `time`
/// resolves (#199).
fn resolve_time(words: &[&str], i: usize) -> Option<usize> {
let mut next = i + 1;
if words.get(next) == Some(&"-p") {
next += 1;
}
if words.get(next) == Some(&"--") {
next += 1;
}
match words.get(next) {
Some(word) if word.starts_with('-') => None,
_ => Some(next),
}
}
/// `-c`/`--call` embeds a whole shell command string rather than naming the
/// program positionally — resolving past it would mean guessing at a
/// program buried inside that string, so it's left unrecognised and
/// resolution stops at `npx` (#199).
const NPX_OPTS: OptionTable = OptionTable {
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
valued: &["-p", "--package", "--shell"],
bundles_short_options: false,
};
const PNPM_OPTS: OptionTable = OptionTable {
flags: &[
"--silent",
"-s",
"--stream",
"--aggregate-output",
"--no-progress",
],
// `--package`/`--package=value` is documented in this pre-`dlx`
// position (`pnpm --package=x dlx y`), alongside the identical flag
// `dlx` itself also accepts (`PNPM_DLX_OPTS`).
valued: &["-C", "--dir", "--filter", "--package"],
bundles_short_options: false,
};
const YARN_OPTS: OptionTable = OptionTable {
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
valued: &["--cwd"],
bundles_short_options: false,
};
const PNPM_DLX_OPTS: OptionTable = OptionTable {
flags: &["-s", "--silent"],
valued: &["--package"],
bundles_short_options: false,
};
const YARN_DLX_OPTS: OptionTable = OptionTable {
flags: &["-q", "--quiet"],
valued: &["-p", "--package"],
bundles_short_options: false,
};
const BUN_X_OPTS: OptionTable = OptionTable {
flags: &["--bun", "--no-install", "--verbose", "--silent"],
valued: &["-p", "--package"],
bundles_short_options: false,
};
/// A resolver for one wrapper form: given the tokenized command and the
/// index of the wrapper word itself, the index of the word after it (past
/// its options and any subcommand it requires), or `None` if this occurrence
/// isn't a form we resolve through.
type WrapperResolver = fn(&[&str], usize) -> Option<usize>;
/// One recognised wrapper form: how to walk past it, and whether an
/// assignment word is a valid follow-on once it's been walked past.
struct Wrapper {
resolve: WrapperResolver,
/// `true` only for wrappers that themselves interpret a following
/// `FOO=bar` as an environment assignment for the command they launch
/// (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
/// grammar for a brand new simple command (`time`). Every other wrapper
/// here is a plain executable that execs its next argument as the
/// program outright — an assignment-shaped one names a program literally
/// called `FOO=1`, not an env var (#199).
accepts_assignment: bool,
}
fn wrapper_resolver(name: &str) -> Option<Wrapper> {
let (resolve, accepts_assignment): (WrapperResolver, bool) = match name {
"env" => (|words, i| ENV_OPTS.consume(words, i + 1), true),
"sudo" => (|words, i| SUDO_OPTS.consume(words, i + 1), true),
"command" => (|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1), false),
"nohup" => (|words, i| NO_OPTIONS.consume(words, i + 1), false),
"time" => (resolve_time, true),
"npx" => (|words, i| NPX_OPTS.consume(words, i + 1), false),
"pnpm" => (
|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i),
false,
),
"yarn" => (
|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i),
false,
),
"bun" => (resolve_bun, false),
"bunx" => (|words, i| BUN_X_OPTS.consume(words, i + 1), false),
"uvx" => (|_words, i| Some(i + 1), false),
_ => return None,
};
Some(Wrapper {
resolve,
accepts_assignment,
})
}
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
/// — without it, the next word is a `package.json` script name, not a
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
/// itself takes launcher-specific options too, consumed before the executable.
fn resolve_dlx(
opts: &OptionTable,
dlx_opts: &OptionTable,
words: &[&str],
i: usize,
) -> Option<usize> {
let j = opts.consume(words, i + 1)?;
if words.get(j) != Some(&"dlx") {
return None;
}
dlx_opts.consume(words, j + 1)
}
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
/// the executable.
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
if words.get(i + 1) != Some(&"x") {
return None;
}
BUN_X_OPTS.consume(words, i + 2)
}
/// `FOO=bar`, the shell's own prefix for one command's environment —
/// recognised only when the `=` came from plain, unquoted/unescaped source
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
/// an assignment.
fn is_env_assignment(word: &Word) -> bool {
let Some(eq) = word.plain_eq else {
return false;
};
let name = &word.text[..eq];
let mut chars = name.chars();
match chars.next() { match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {} Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false, _ => return false,
} }
let rest: String = chars.collect(); chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
} }
fn basename(word: &str) -> &str { fn basename(word: &str) -> &str {
@@ -497,38 +955,132 @@ mod tests {
"claude" "claude"
); );
assert_eq!(command_program(" zsh -l "), "zsh"); assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx"); }
/// The #199 resolver contract: every case in the shared parity fixture,
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
/// resolver regression in either language fails its own suite.
#[test]
fn resolves_the_shared_command_resolution_fixture() {
#[derive(serde::Deserialize)]
struct Case {
command: String,
program: String,
tool: Option<String>,
}
#[derive(serde::Deserialize)]
struct Fixture {
cases: Vec<Case>,
}
let raw = include_str!("../../../fixtures/command-resolution.json");
let fixture: Fixture = serde_json::from_str(raw).expect("valid fixture JSON");
assert!(!fixture.cases.is_empty());
for case in &fixture.cases {
assert_eq!(
command_program(&case.command),
case.program,
"program for {:?}",
case.command
);
assert_eq!(
command_tool(&case.command),
case.tool.as_deref(),
"tool for {:?}",
case.command
);
}
}
fn word(text: &str) -> Word {
Word {
text: text.into(),
plain_eq: None,
}
}
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
#[test]
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
assert_eq!(
tokenize("claude --resume"),
Some(vec![word("claude"), word("--resume")])
);
assert_eq!(
tokenize("\"/opt/my agent/claude\" --resume"),
Some(vec![word("/opt/my agent/claude"), word("--resume")])
);
assert_eq!(
tokenize("FOO=\"bar baz\" claude"),
Some(vec![
Word {
text: "FOO=bar baz".into(),
plain_eq: Some(3),
},
word("claude"),
])
);
assert_eq!(
tokenize("claude --name=$HOME"),
Some(vec![
word("claude"),
Word {
text: "--name=$HOME".into(),
plain_eq: Some(6),
},
]),
"a bare $ is kept literal, never expanded"
);
}
/// Real shell grammar this resolver doesn't attempt — pipelines, list
/// operators, subshells/substitution, an unescaped newline, an
/// unterminated quote — is rejected outright rather than guessed
/// through (#199).
#[test]
fn tokenize_rejects_ambiguous_shell_grammar() {
for command in [
"claude | tee log.txt",
"claude; echo done",
"claude && echo done",
"echo hi $(claude)",
"claude \"unterminated",
"claude 'unterminated",
"claude \\",
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
] {
assert_eq!(tokenize(command), None, "{command:?}");
}
}
/// A backslash immediately before a newline is a line continuation:
/// removed entirely, joining what it split rather than embedding a
/// literal newline or ending the command (#199).
#[test]
fn tokenize_treats_backslash_newline_as_a_continuation() {
assert_eq!(
tokenize("clau\\\nde --resume"),
Some(vec![word("claude"), word("--resume")])
);
}
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
/// quoting or escaping the `=` itself disqualifies the word, even
/// though the dequoted text looks identical (#199).
#[test]
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
} }
#[test] #[test]
fn command_program_looks_through_paths_env_and_wrappers() { fn structured_view_support_follows_the_resolved_program() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude"); assert!(supports_structured_view("claude --resume"));
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude"); assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
assert_eq!(command_program("npx opencode"), "opencode"); assert!(supports_structured_view("npx claude-code"));
assert_eq!(command_program("pnpm dlx opencode run"), "opencode"); assert!(!supports_structured_view("opencode"));
} assert!(!supports_structured_view("bash -l"));
assert!(!supports_structured_view("claude-code-acp"));
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
/// tools the web's `splitCommand().tool` does.
#[test]
fn command_tool_names_the_mark_the_card_shows() {
assert_eq!(command_tool("claude --resume"), Some("claude"));
assert_eq!(command_tool("npx opencode"), Some("opencode"));
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
assert_eq!(command_tool("CLAUDE"), Some("claude"));
}
/// Narrow on purpose: an unrecognised program has no mark, so the
/// candidate has no `tool` field to match rather than a wrong one.
#[test]
fn command_tool_is_absent_for_an_unrecognised_program() {
assert_eq!(command_tool("hx src/main.rs"), None);
assert_eq!(command_tool(""), None);
} }
} }
+19 -1
View File
@@ -123,7 +123,7 @@ pub struct Session {
/// daemon that predates the field keeps working untouched. /// daemon that predates the field keeps working untouched.
#[serde(default)] #[serde(default)]
pub view: SessionView, pub view: SessionView,
/// The ACP session id the `claude-code-acp` adapter handed back from /// The ACP session id the selected provider adapter handed back from
/// `session/new` (#134). Only a [`SessionView::Structured`] session has /// `session/new` (#134). Only a [`SessionView::Structured`] session has
/// one, and only once its handshake succeeded; a later respawn passes it /// one, and only once its handshake succeeded; a later respawn passes it
/// to `session/load` so the conversation continues (#135). Distinct from /// to `session/load` so the conversation continues (#135). Distinct from
@@ -824,6 +824,15 @@ pub struct TurnState {
pub thinking: bool, pub thinking: bool,
#[serde(default)] #[serde(default)]
pub active_tool_calls: Vec<String>, pub active_tool_calls: Vec<String>,
/// Wall-clock start of the active prompt. This lets reconnecting clients
/// keep one continuous elapsed-time display instead of restarting at zero.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
/// Context-window usage when the active prompt began. ACP exposes context
/// occupancy, not provider billing-token accounting, so clients must label
/// deltas as context tokens rather than tokens billed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_context_tokens: Option<u64>,
} }
/// A whole-session view derived from ACP notifications. /// A whole-session view derived from ACP notifications.
@@ -846,6 +855,8 @@ pub struct ReducedState {
pub model: Option<String>, pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>, pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_options: Option<Value>,
} }
/// Server messages sent over a structured-session WebSocket. /// Server messages sent over a structured-session WebSocket.
@@ -871,6 +882,13 @@ pub struct StructuredPromptRequest {
pub prompt: String, pub prompt: String,
} }
/// HTTP body for `POST /api/sessions/:id/structured/config`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StructuredConfigRequest {
pub config_id: String,
pub value: Value,
}
/// Operator decision for a pending ACP permission request. /// Operator decision for a pending ACP permission request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
+12
View File
@@ -86,11 +86,21 @@ pub fn structured_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/prompt") format!("{SESSIONS}/{id}/structured/prompt")
} }
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
pub fn structured_attachment(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/attachments")
}
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. /// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
pub fn structured_cancel(id: Uuid) -> String { pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel") format!("{SESSIONS}/{id}/structured/cancel")
} }
/// `POST /api/sessions/:id/structured/config` — change an ACP session option.
pub fn structured_config(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/config")
}
/// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission. /// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission.
pub fn structured_approval(id: Uuid, nonce: &str) -> String { pub fn structured_approval(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/approvals/{nonce}") format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
@@ -108,7 +118,9 @@ pub mod patterns {
pub const STRUCTURED: &str = "/api/structured/:id"; pub const STRUCTURED: &str = "/api/structured/:id";
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay"; pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt"; pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel"; pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce"; pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
} }
+99 -1
View File
@@ -11,6 +11,7 @@
//! `dispatch` / the overlay routing. //! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
@@ -54,6 +55,36 @@ enum OverlayDecision {
Pass, Pass,
} }
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
let encoded = base64_encode(text.as_bytes());
let mut output = std::io::stdout();
write!(output, "\x1b]52;c;{encoded}\x07")?;
output.flush()
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[((bits >> 6) & 0x3f) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(bits & 0x3f) as usize] as char
} else {
'='
});
}
output
}
pub struct App { pub struct App {
cfg: TuiConfig, cfg: TuiConfig,
home_dir: String, home_dir: String,
@@ -340,6 +371,10 @@ impl App {
return Ok(false); return Ok(false);
} }
if self.handle_structured_mouse(&key) {
return Ok(false);
}
if let Some(effects) = self.handle_structured_key(&key) { if let Some(effects) = self.handle_structured_key(&key) {
self.perform(effects).await; self.perform(effects).await;
return Ok(false); return Ok(false);
@@ -423,6 +458,61 @@ impl App {
true true
} }
/// Transcript blocks are pointer targets: click selects the whole block,
/// gives it the selection surface, and copies its complete text.
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
let InputKey::Mouse {
kind,
region: Region::Pane,
row,
..
} = key
else {
return false;
};
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
return false;
}
let Some(sid) = self.state.active_session() else {
return false;
};
let Some(handle) = self.structured.get(&sid) else {
return false;
};
let Some(pane) = self.region.pane else {
return false;
};
let offset = usize::from(row.saturating_sub(pane.y));
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
if offset >= usize::from(transcript_height) {
return true;
}
let mut state = handle.state.lock().expect("structured state mutex");
let rendered_line = state.scroll.saturating_add(offset);
if !state.select_rendered_line(rendered_line) {
return true;
}
let Some(id) = state.selected.as_deref() else {
return true;
};
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
return true;
};
if *kind == MouseKind::Hover {
return true;
}
let text = crate::structured_pane::copy_text(transcript);
drop(state);
if text.is_empty() {
return true;
}
match copy_to_terminal_clipboard(&text) {
Ok(()) => self.state.toast("copied transcript block"),
Err(error) => self.state.toast(format!("copy failed: {error}")),
}
true
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to /// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon. /// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) { async fn perform(&mut self, effects: Vec<Effect>) {
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn clipboard_payload_uses_standard_base64() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
}
use crate::action::Action; use crate::action::Action;
use crate::draft::DraftField; use crate::draft::DraftField;
use crate::keymap::Mode; use crate::keymap::Mode;
@@ -3053,17 +3149,19 @@ mod tests {
let request = |view| CreateSessionRequest { let request = |view| CreateSessionRequest {
view, view,
cwd: std::path::PathBuf::from("/srv/work"), cwd: std::path::PathBuf::from("/srv/work"),
command: "claude".into(), command: "codex".into(),
env: Default::default(), env: Default::default(),
metadata: Default::default(), metadata: Default::default(),
worktree: None, worktree: None,
}; };
let structured_id = Uuid::from_u128(188); let structured_id = Uuid::from_u128(188);
app.state.mode = Mode::Insert;
app.submit_spawn(request(SessionView::Structured)).await; app.submit_spawn(request(SessionView::Structured)).await;
assert!(app.catalog.snapshot().sessions.is_empty()); assert!(app.catalog.snapshot().sessions.is_empty());
assert!(app.structured.contains_key(&structured_id)); assert!(app.structured.contains_key(&structured_id));
assert!(app.emulators.get(structured_id).is_none()); assert!(app.emulators.get(structured_id).is_none());
assert_eq!(app.state.mode, Mode::Normal);
let terminal_id = Uuid::from_u128(187); let terminal_id = Uuid::from_u128(187);
app.submit_spawn(request(SessionView::Terminal)).await; app.submit_spawn(request(SessionView::Terminal)).await;
+259 -16
View File
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset}; use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::{abbreviate_dir, command_tool}; use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::{ use maestro_protocol::{
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec, BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
}; };
@@ -91,7 +91,7 @@ pub enum DraftField {
/// Disclosure collapsing the optional view and worktree controls below. /// Disclosure collapsing the optional view and worktree controls below.
/// Closed by default; Enter / Space toggles. /// Closed by default; Enter / Space toggles.
Advanced, Advanced,
/// ACP-backed transcript instead of a terminal. Claude Code only. /// ACP-backed transcript instead of a terminal. Claude Code or Codex.
StructuredToggle, StructuredToggle,
/// Worktree on/off for this session (#65). Enter / Space toggles. /// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle, WorktreeToggle,
@@ -255,9 +255,14 @@ pub struct DraftPane {
/// "Advanced options" disclosure (#93). Closed by default — most spawns /// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target. /// take the default target.
pub advanced_collapsed: bool, pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Off by default and supported only /// ACP-backed structured transcript. Supported only when the resolved
/// when the resolved command is Claude Code. /// command is Claude Code or Codex, and on by default whenever it is —
/// until the operator works the toggle themselves.
pub structured_view: bool, pub structured_view: bool,
/// Set once the operator toggles structured view by hand. From then on a
/// command change never re-enables what they turned off, the same guard
/// `name_touched` gives the name field.
pub structured_view_touched: bool,
/// Enclosing repository of the chosen target — from the project scan for /// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known. /// picked rows, `/api/gitroot` for raw paths. `None` until known.
pub base_repo: Option<PathBuf>, pub base_repo: Option<PathBuf>,
@@ -298,7 +303,7 @@ impl Default for DraftPane {
// while its data loads, and stay usable when the daemon reports none. // while its data loads, and stay usable when the daemon reports none.
let mut presets = builtin_presets(); let mut presets = builtin_presets();
presets.push(custom_preset()); presets.push(custom_preset());
Self { let mut draft = Self {
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
presets, presets,
selected_preset: 0, selected_preset: 0,
@@ -319,6 +324,7 @@ impl Default for DraftPane {
worktree_enabled: true, worktree_enabled: true,
advanced_collapsed: true, advanced_collapsed: true,
structured_view: false, structured_view: false,
structured_view_touched: false,
base_repo: None, base_repo: None,
probed_cwd: None, probed_cwd: None,
probe_seq: 0, probe_seq: 0,
@@ -337,7 +343,11 @@ impl Default for DraftPane {
// straight away, and Tab from here reaches the command tiles. // straight away, and Tab from here reaches the command tiles.
focused_field: DraftField::DirPicker, focused_field: DraftField::DirPicker,
home_dir: String::new(), home_dir: String::new(),
} };
// The built-in tile the form opens on decides whether structured view
// starts ticked, exactly as a later preset list would.
draft.sync_structured_view();
draft
} }
} }
@@ -356,7 +366,7 @@ impl DraftPane {
.position(|p| !p.command.is_empty()) .position(|p| !p.command.is_empty())
.unwrap_or(0); .unwrap_or(0);
self.apply_seed_command(); self.apply_seed_command();
self.clear_unsupported_structured_view(); self.sync_structured_view();
} }
/// Land a seeded command on the tile that runs it: its own preset when the /// Land a seeded command on the tile that runs it: its own preset when the
@@ -673,18 +683,24 @@ impl DraftPane {
} }
fn supports_structured_view(&self) -> bool { fn supports_structured_view(&self) -> bool {
command_tool(self.command()) == Some("claude") supports_structured_view(self.command())
} }
fn clear_unsupported_structured_view(&mut self) { /// Re-resolve the toggle after the command changed: an unsupported
/// command forces it off, a supported one turns it back on unless the
/// operator has already made the choice themselves.
fn sync_structured_view(&mut self) {
if !self.supports_structured_view() { if !self.supports_structured_view() {
self.structured_view = false; self.structured_view = false;
} else if !self.structured_view_touched {
self.structured_view = true;
} }
} }
pub fn toggle_structured_view(&mut self) { pub fn toggle_structured_view(&mut self) {
if self.supports_structured_view() { if self.supports_structured_view() {
self.structured_view = !self.structured_view; self.structured_view = !self.structured_view;
self.structured_view_touched = true;
} }
} }
@@ -827,7 +843,7 @@ impl DraftPane {
} }
self.selected_preset = self.selected_preset =
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize; ((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
self.clear_unsupported_structured_view(); self.sync_structured_view();
} }
/// Step-one boxes surviving the type-to-filter, in display order. A /// Step-one boxes surviving the type-to-filter, in display order. A
@@ -1130,7 +1146,7 @@ impl DraftPane {
DraftField::Preset | DraftField::Submit => {} DraftField::Preset | DraftField::Submit => {}
DraftField::CustomCommand => { DraftField::CustomCommand => {
self.custom_command.handle_key(other); self.custom_command.handle_key(other);
self.clear_unsupported_structured_view(); self.sync_structured_view();
} }
// Typing filters the list; the highlight resets to the top // Typing filters the list; the highlight resets to the top
// so a narrowed search always starts at its best match. // so a narrowed search always starts at its best match.
@@ -1888,18 +1904,39 @@ mod tests {
} }
#[test] #[test]
fn structured_view_is_default_off_and_only_visible_for_claude_code() { fn structured_view_is_default_on_and_visible_for_supported_agents() {
let mut draft = DraftPane { let mut draft = DraftPane {
advanced_collapsed: false, advanced_collapsed: false,
..DraftPane::default() ..DraftPane::default()
}; };
assert!(!draft.structured_view); draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
},
SpawnPreset {
id: "opencode".into(),
label: "OpenCode".into(),
command: "opencode".into(),
},
]);
assert!(draft.structured_view, "on by default for a supported agent");
assert!(draft.field_order().contains(&DraftField::StructuredToggle)); assert!(draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(rendered.contains("[ ] structured view"), "{rendered}"); assert!(rendered.contains("[x] structured view"), "{rendered}");
draft.toggle_structured_view(); draft.cycle_preset(1);
assert!(draft.structured_view); assert_eq!(draft.command(), "codex");
assert!(
draft.structured_view,
"Codex keeps the structured selection"
);
draft.cycle_preset(1); draft.cycle_preset(1);
assert_eq!(draft.command(), "opencode"); assert_eq!(draft.command(), "opencode");
assert!( assert!(
@@ -1926,6 +1963,212 @@ mod tests {
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal); assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
} }
/// A custom command typed into the free-text field, run through the same
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
/// env prefix, or a wrapper still resolves to Claude Code or Codex, so
/// the toggle stays offered and the request carries `Structured`; an
/// unrelated program does not.
#[test]
fn structured_view_follows_command_resolution_for_custom_commands() {
let cases: &[(&str, bool)] = &[
("claude", true),
("claude --resume", true),
("CLAUDE", true),
("/usr/local/bin/claude -p hi", true),
("./bin/claude", true),
("FOO=1 BAR=2 claude", true),
("npx claude-code", true),
("npx --yes claude-code", true),
("sudo claude", true),
("sudo -u root claude", true),
("env -i claude", true),
("nohup claude", true),
("time claude", true),
("command claude", true),
("pnpm --silent dlx claude-code", true),
("yarn --silent dlx claude-code", true),
("bun x claude-code", true),
("\"/opt/my agent/claude\" --resume", true),
("codex", true),
("codex --full-auto", true),
("CODEX=1 codex", true),
("/usr/local/bin/codex exec", true),
("npx codex", true),
("sudo -u root codex", true),
("env -i codex", true),
("command codex", true),
("pnpm --silent dlx codex", true),
("\"/opt/my agent/codex\" exec", true),
("bash", false),
("opencode", false),
("/usr/local/bin/bash -l", false),
// A package-manager wrapper without `dlx`/`x` runs a
// `package.json` script, not the package (#199) — must not be
// mistaken for a Claude launch.
("pnpm claude", false),
("yarn claude", false),
// The daemon's structured-session child, never a valid
// user-facing launch form (#199).
("claude-code-acp", false),
];
for &(command, supported) in cases {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value(command);
draft.sync_structured_view();
assert_eq!(
draft.field_order().contains(&DraftField::StructuredToggle),
supported,
"toggle visibility for {command:?}"
);
assert_eq!(
draft.structured_view, supported,
"default selection for {command:?}"
);
let view = draft.build_request().unwrap().view;
assert_eq!(
view,
if supported {
SessionView::Structured
} else {
SessionView::Terminal
},
"request view for {command:?}"
);
draft.toggle_structured_view();
assert!(
!draft.structured_view,
"the toggle turns the default off for {command:?}"
);
}
}
/// A configured preset (the spawn-form "alias" for a command) is gated
/// the same way as free text: a preset whose command resolves to Claude
/// Code or Codex through a wrapper still offers structured view.
#[test]
fn structured_view_follows_command_resolution_for_presets() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude-wrapped".into(),
label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(),
},
SpawnPreset {
id: "codex-wrapped".into(),
label: "Codex (npx)".into(),
command: "npx codex exec".into(),
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
},
]);
assert_eq!(draft.command(), "npx claude-code --resume");
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching between supported presets preserves the selection.
draft.cycle_preset(1);
assert_eq!(draft.command(), "npx codex exec");
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching to a preset that does not resolve to an ACP agent clears the
// stale selection instead of letting an invalid request through.
draft.cycle_preset(1);
assert_eq!(draft.command(), "aider");
assert!(!draft.structured_view, "stale selection must clear");
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// The default only applies until the operator has an opinion: once they
/// untick the box, moving between supported commands leaves it unticked
/// instead of turning it back on under them.
#[test]
fn an_unticked_structured_view_survives_a_command_change() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
},
]);
assert!(draft.structured_view);
draft.toggle_structured_view();
assert!(!draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "codex");
assert!(
!draft.structured_view,
"the operator's choice outranks the default"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from a supported agent clears an armed
/// selection immediately, matching the preset-switch case above — an
/// invalid `Structured` request can never reach `build_request`.
#[test]
fn structured_view_clears_when_custom_command_edited_away_from_supported_agent() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
focused_field: DraftField::CustomCommand,
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("codex");
draft.sync_structured_view();
assert!(draft.structured_view);
typed(&mut draft, " -p");
assert_eq!(draft.command(), "codex -p");
assert!(draft.structured_view, "still Codex, selection survives");
for _ in 0.."codex -p".chars().count() {
draft.on_input(&InputKey::Backspace);
}
typed(&mut draft, "bash");
assert_eq!(draft.command(), "bash");
assert!(
!draft.structured_view,
"editing the program away from Codex clears the stale selection"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
#[test] #[test]
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() { fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
let mut draft = DraftPane { let mut draft = DraftPane {
+7
View File
@@ -55,6 +55,7 @@ pub enum MouseKind {
Click, Click,
DblClick, DblClick,
Drag, Drag,
Hover,
ScrollUp, ScrollUp,
ScrollDown, ScrollDown,
} }
@@ -148,6 +149,7 @@ impl InputKey {
MouseKind::Click => "click", MouseKind::Click => "click",
MouseKind::DblClick => "dblclick", MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag", MouseKind::Drag => "drag",
MouseKind::Hover => "hover",
MouseKind::ScrollUp => "scrollup", MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown", MouseKind::ScrollDown => "scrolldown",
}; };
@@ -178,6 +180,7 @@ impl InputKey {
"click" => MouseKind::Click, "click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick, "dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag, "drag" => MouseKind::Drag,
"hover" => MouseKind::Hover,
"scrollup" => MouseKind::ScrollUp, "scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown, "scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())), other => return Err(InputParseError::UnknownMouseKind(other.into())),
@@ -297,6 +300,7 @@ where
let mk = match kind { let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click, MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag, MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::Moved => MouseKind::Hover,
MouseEventKind::ScrollUp => MouseKind::ScrollUp, MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown, MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None, _ => return None,
@@ -681,6 +685,9 @@ mod tests {
let s = "mouse:dblclick(sidebar-row)"; let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap(); let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s); assert_eq!(k.canonical(), s);
let s = "mouse:hover(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
} }
#[test] #[test]
+379 -1
View File
@@ -90,6 +90,7 @@ impl StructuredPane {
let viewport = usize::from(area.height); let viewport = usize::from(area.height);
let max_scroll = lines.len().saturating_sub(viewport); let max_scroll = lines.len().saturating_sub(viewport);
state.scroll = reconcile_scroll(&state.scroll, &state.line_rows, &lines);
if state.follow_selection { if state.follow_selection {
if let Some(selected) = state.selected.as_deref() { if let Some(selected) = state.selected.as_deref() {
let first = lines let first = lines
@@ -138,6 +139,7 @@ impl StructuredPane {
Rect::new(area.x, area.y + offset as u16, area.width, 1), Rect::new(area.x, area.y + offset as u16, area.width, 1),
); );
} }
state.line_rows = lines.iter().map(|line| line.row_id.clone()).collect();
} }
fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) { fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
@@ -214,13 +216,104 @@ fn error_line(text: &str) -> VisualLine {
} }
} }
/// Translate `scroll` — a raw line index into `old_rows` (the previous
/// render's row-per-line layout) — into the equivalent index into `lines`
/// (this render's freshly rebuilt layout), anchored on row identity rather
/// than raw position. A row's wrapped lines above or intersecting the
/// viewport growing, shrinking, or moving index therefore shifts `scroll`
/// along with it instead of leaving the viewport looking at different
/// content. Falls back to the nearest surviving neighbour row when the
/// anchor row itself is gone, and to the raw index (clamped) when nothing
/// nearby survived either.
fn reconcile_scroll(scroll: &usize, old_rows: &[Option<String>], lines: &[VisualLine]) -> usize {
if old_rows.is_empty() {
return *scroll;
}
let old_index = (*scroll).min(old_rows.len() - 1);
let anchor = old_rows[old_index].clone();
match anchor {
Some(id) => match find_row_block(lines, &id) {
Some((start, len)) => {
start + (old_index - block_start(old_rows, old_index)).min(len - 1)
}
None => fallback_after_removal(old_rows, lines, old_index),
},
None => {
// Untagged tail lines (connecting/dead/error) carry no row identity;
// approximate by preserving distance from the end of the transcript.
let distance_from_end = old_rows.len() - old_index;
lines.len().saturating_sub(distance_from_end)
}
}
}
/// Index of the first line belonging to `id`, and how many lines it spans.
/// A row's wrapped lines are always contiguous in `lines` — `build_lines`
/// emits them as one run per row — so a single forward scan suffices.
fn find_row_block(lines: &[VisualLine], id: &str) -> Option<(usize, usize)> {
let start = lines
.iter()
.position(|line| line.row_id.as_deref() == Some(id))?;
let mut end = start;
while end + 1 < lines.len() && lines[end + 1].row_id.as_deref() == Some(id) {
end += 1;
}
Some((start, end - start + 1))
}
/// First index of the contiguous run that `index` sits in within `rows`.
fn block_start(rows: &[Option<String>], index: usize) -> usize {
let mut start = index;
while start > 0 && rows[start - 1] == rows[index] {
start -= 1;
}
start
}
/// The anchor row is gone (removed, or replaced wholesale by a fresh
/// snapshot). Search outward from its old position, nearest first, for a
/// still-live neighbour row and land on its first line; if none of the old
/// layout's rows survived, fall back to the raw old index clamped into the
/// new layout.
fn fallback_after_removal(
old_rows: &[Option<String>],
lines: &[VisualLine],
old_index: usize,
) -> usize {
let len = old_rows.len();
let mut distance = 1usize;
loop {
if distance > old_index && old_index + distance >= len {
return old_index.min(lines.len().saturating_sub(1));
}
if distance <= old_index {
if let Some((start, _)) = old_rows[old_index - distance]
.as_ref()
.and_then(|id| find_row_block(lines, id))
{
return start;
}
}
if old_index + distance < len {
if let Some((start, _)) = old_rows[old_index + distance]
.as_ref()
.and_then(|id| find_row_block(lines, id))
{
return start;
}
}
distance += 1;
}
}
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> { fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
let mut out = Vec::new(); let mut out = Vec::new();
for row in &state.rows { for (index, row) in state.rows.iter().enumerate() {
if row.kind == TranscriptRowKind::Plan { if row.kind == TranscriptRowKind::Plan {
continue; continue;
} }
let row_width = width.saturating_sub(1).max(1); let row_width = width.saturating_sub(1).max(1);
let start = out.len();
match row.kind { match row.kind {
TranscriptRowKind::User => push_wrapped( TranscriptRowKind::User => push_wrapped(
&mut out, &mut out,
@@ -264,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
), ),
TranscriptRowKind::Plan => {} TranscriptRowKind::Plan => {}
} }
if out.len() > start
&& state.rows[index + 1..]
.iter()
.any(|next| next.kind != TranscriptRowKind::Plan)
{
// A blank surface line is the vertical padding that keeps adjacent
// transcript blocks readable at a glance.
out.push(VisualLine {
text: String::new(),
row_id: None,
rail: false,
tone: Tone::Normal,
});
}
} }
out out
} }
/// Text supplied to the terminal clipboard when a transcript block is
/// clicked. It preserves the full tool detail rather than its compact preview.
pub fn copy_text(row: &TranscriptRow) -> String {
match row.kind {
TranscriptRowKind::User
| TranscriptRowKind::AgentText
| TranscriptRowKind::AgentThought => content_text(row),
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
let (name, argument) = tool_title(row);
let mut lines = vec![if argument.is_empty() {
name
} else {
format!("{name} {argument}")
}];
lines.extend(tool_content_lines(row, None));
lines.join("\n")
}
TranscriptRowKind::Approval => row
.data
.get("toolCall")
.and_then(|tool| tool.get("title"))
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
}
fn push_wrapped( fn push_wrapped(
out: &mut Vec<VisualLine>, out: &mut Vec<VisualLine>,
row: &TranscriptRow, row: &TranscriptRow,
@@ -908,6 +1044,39 @@ mod tests {
); );
} }
#[test]
fn transcript_blocks_have_a_padding_line_between_them() {
let mut state = StructuredState::default();
state.rows = vec![
row(
"one",
TranscriptRowKind::User,
json!({"content":{"type":"text","text":"first"}}),
),
row(
"two",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"second"}}),
),
];
let lines = build_lines(&state, 80);
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].text, "");
assert!(lines[1].row_id.is_none());
}
#[test]
fn copying_a_tool_keeps_its_full_detail() {
let tool = row(
"t",
TranscriptRowKind::ToolCall,
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
);
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
}
#[test] #[test]
fn tool_is_compact_until_selected_row_expands() { fn tool_is_compact_until_selected_row_expands() {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
@@ -1272,4 +1441,213 @@ mod tests {
assert!(text.contains("adapter exited"), "{text}"); assert!(text.contains("adapter exited"), "{text}");
assert!(text.contains("request failed"), "{text}"); assert!(text.contains("request failed"), "{text}");
} }
fn test_pane() -> StructuredPane {
StructuredPane {
handle: StructuredHandle::test_handle(StructuredState::default()),
focused: true,
mode: Mode::Normal,
session_status: SessionStatus::Running,
activity_detail: None,
}
}
fn numbered_rows(state: &mut StructuredState, count: usize) {
for n in 0..count {
state.rows.push(row(
&format!("r{n}"),
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":format!("line {n}")}}),
));
}
}
fn grow_row(state: &mut StructuredState, id: &str) {
let index = state.rows.iter().position(|r| r.id == id).unwrap();
state.rows[index] = row(
id,
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"a very long streaming reply that keeps growing and will wrap across several lines now"}}),
);
}
fn render_transcript_lines(
pane: &StructuredPane,
state: &mut StructuredState,
width: u16,
height: u16,
) -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| pane.render_transcript(frame, frame.area(), state))
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn row_growing_above_the_viewport_keeps_the_visual_anchor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
grow_row(&mut state, "r3");
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after[0].contains("line 10"),
"a row growing above the viewport must not shift what's on screen: {after:?}"
);
}
#[test]
fn appending_a_row_below_the_viewport_keeps_the_visual_anchor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
state.rows.push(row(
"r20",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"line 20"}}),
));
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert_eq!(before, after, "growth below the viewport must not move it");
}
#[test]
fn removing_a_row_above_the_viewport_keeps_the_visual_anchor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
state.rows.retain(|r| r.id != "r3");
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after[0].contains("line 10"),
"a row removed above the viewport must not shift what's on screen: {after:?}"
);
}
#[test]
fn removing_the_anchor_row_falls_back_to_the_nearest_surviving_neighbor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
state.rows.retain(|r| r.id != "r10");
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after[0].contains("line 9"),
"removing the anchor row should fall back to its nearest surviving neighbor: {after:?}"
);
}
#[test]
fn patching_a_row_above_the_viewport_keeps_the_visual_anchor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 20;
render_transcript_lines(&pane, &mut state, 30, 5);
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
// viewport expanding into a multi-line diff.
let index = state.rows.iter().position(|r| r.id == "r5").unwrap();
state.rows[index] = row(
"r5",
TranscriptRowKind::ToolDiff,
json!({"title":"Edit(x)","content":[{"type":"diff","path":"x","oldText":"a","newText":"a\nb\nc\nd"}]}),
);
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after[0].contains("line 10"),
"a patched row above the viewport must not shift what's on screen: {after:?}"
);
}
#[test]
fn narrow_width_reconciles_without_panicking() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
render_transcript_lines(&pane, &mut state, 1, 3);
grow_row(&mut state, "r3");
render_transcript_lines(&pane, &mut state, 1, 3);
assert!(state.scroll <= state.line_rows.len());
}
#[test]
fn follow_selection_without_a_selection_stays_pinned_to_the_live_edge() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = true;
state.selected = None;
numbered_rows(&mut state, 20);
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before.last().unwrap().contains("line 19"), "{before:?}");
state.rows.push(row(
"r20",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"line 20"}}),
));
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after.last().unwrap().contains("line 20"),
"bottom-follow must track the live edge as rows are appended: {after:?}"
);
}
#[test]
fn follow_selection_keeps_the_selected_row_visible_when_a_row_above_it_grows() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = true;
numbered_rows(&mut state, 20);
state.selected = Some("r15".into());
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
before.iter().any(|line| line.contains("line 15")),
"{before:?}"
);
grow_row(&mut state, "r3");
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after.iter().any(|line| line.contains("line 15")),
"the selected row must stay visible while follow_selection is on: {after:?}"
);
}
} }
+38
View File
@@ -25,6 +25,12 @@ pub struct StructuredState {
pub selected: Option<String>, pub selected: Option<String>,
pub expanded: HashSet<String>, pub expanded: HashSet<String>,
pub scroll: usize, pub scroll: usize,
/// Row id per rendered line from the last render pass, parallel to that
/// pass's `lines`. Lets the next render translate `scroll` — a raw index
/// into that now-stale layout — back into a row identity before mapping
/// it onto the freshly rebuilt layout, so growth/shrinkage above the
/// viewport shifts indices without shifting what's on screen.
pub(crate) line_rows: Vec<Option<String>>,
pub follow_selection: bool, pub follow_selection: bool,
pub composer: TextInput, pub composer: TextInput,
pub error: Option<String>, pub error: Option<String>,
@@ -42,6 +48,7 @@ impl Default for StructuredState {
selected: None, selected: None,
expanded: HashSet::new(), expanded: HashSet::new(),
scroll: 0, scroll: 0,
line_rows: Vec::new(),
follow_selection: true, follow_selection: true,
composer: TextInput::default(), composer: TextInput::default(),
error: None, error: None,
@@ -192,6 +199,18 @@ impl StructuredState {
self.follow_selection = true; self.follow_selection = true;
} }
/// Select the transcript block under a rendered transcript line. Pointer
/// input uses the same row identity map as scroll reconciliation, so a
/// wrapped block behaves as one target.
pub fn select_rendered_line(&mut self, line: usize) -> bool {
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
return false;
};
self.selected = Some(id);
self.follow_selection = false;
true
}
pub fn toggle_selected(&mut self) { pub fn toggle_selected(&mut self) {
let Some(id) = self.selected.clone() else { let Some(id) = self.selected.clone() else {
return; return;
@@ -650,6 +669,25 @@ mod tests {
assert_eq!(state.composer.value(), "hi"); assert_eq!(state.composer.value(), "hi");
} }
#[test]
fn selecting_a_rendered_line_selects_its_whole_block() {
let mut state = StructuredState {
line_rows: vec![
Some("one".into()),
Some("one".into()),
None,
Some("two".into()),
],
..Default::default()
};
assert!(state.select_rendered_line(1));
assert_eq!(state.selected.as_deref(), Some("one"));
assert!(!state.follow_selection);
assert!(!state.select_rendered_line(2));
assert_eq!(state.selected.as_deref(), Some("one"));
}
#[test] #[test]
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() { fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
let mut state = StructuredState::default(); let mut state = StructuredState::default();
+3
View File
@@ -29,3 +29,6 @@ tracing-subscriber.workspace = true
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false } maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false } maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
maestro-tui = { path = "../maestro-tui", optional = true } maestro-tui = { path = "../maestro-tui", optional = true }
[dev-dependencies]
tempfile.workspace = true
+102
View File
@@ -0,0 +1,102 @@
//! Process-level routing for the umbrella `maestro` binary (#10): each
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
//! not just parse successfully.
//!
//! A malformed `--config` file makes every crate's dispatch fail identically
//! at config load, before any subcommand touches tmux, the network, or a
//! terminal. That lets the assertions tell "clap parsed it but the handler
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
//! exit 1).
//!
//! The absence tests below only compile when the matching feature is off, so
//! they run under CI's per-feature-subset matrix (`--no-default-features
//! --features cli|daemon|tui`) rather than the default all-features build.
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
}
fn bad_config() -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
f
}
fn run(args: &[&str]) -> Output {
Command::new(bin())
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
}
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
/// config crate's error surfacing through — never clap's own usage-error
/// page, which would mean the subcommand never reached its handler.
fn assert_dispatched(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
}
/// Never reached a handler: clap rejected the subcommand outright because the
/// feature that defines it is off. Only used by the `not(feature = "...")`
/// tests below, which themselves only exist in a single-feature build.
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
fn assert_unrecognized(out: &Output, name: &str) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
assert!(
stderr.contains("unrecognized subcommand"),
"stderr:\n{stderr}"
);
assert!(stderr.contains(name), "stderr:\n{stderr}");
}
#[cfg(feature = "cli")]
#[test]
fn list_dispatches_into_maestro_cli() {
let cfg = bad_config();
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "cli"))]
#[test]
fn list_is_unrecognized_without_cli_feature() {
assert_unrecognized(&run(&["list"]), "list");
}
#[cfg(feature = "daemon")]
#[test]
fn daemon_dispatches_into_maestro_daemon() {
let cfg = bad_config();
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "daemon"))]
#[test]
fn daemon_is_unrecognized_without_daemon_feature() {
assert_unrecognized(&run(&["daemon"]), "daemon");
}
#[cfg(feature = "tui")]
#[test]
fn tui_dispatches_into_maestro_tui() {
let cfg = bad_config();
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "tui"))]
#[test]
fn tui_is_unrecognized_without_tui_feature() {
assert_unrecognized(&run(&["tui"]), "tui");
}
+12 -9
View File
@@ -195,25 +195,28 @@ An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`. `crates/maestro-tui/src/action.rs`.
## Structured sessions: `claude-code-acp` ## Structured sessions: ACP adapters
A session spawned with `view: Structured` (#134) runs Claude Code as a child A session spawned with `view: Structured` runs Claude Code or Codex as a child
of the daemon over the Agent Client Protocol instead of in a tmux pane. The of the daemon over the Agent Client Protocol instead of in a tmux pane. The
daemon resolves the adapter binary, `claude-code-acp`, on its `PATH` at spawn resolved session command selects the adapter binary, which must be on the
time. Install it once per host: session's `PATH` when the spawn request sets one, or the daemon's `PATH`
otherwise. Install the adapter for each provider you use:
```sh ```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp npm install -g --prefix ~/.local @zed-industries/claude-code-acp
npm install -g --prefix ~/.local @agentclientprotocol/codex-acp
``` ```
That puts `claude-code-acp` in `~/.local/bin`, which must be on the daemon's That puts `claude-code-acp` and `codex-acp` in `~/.local/bin`, which must be on
`PATH` (a systemd user unit does not read your shell profile; set the applicable `PATH` (a systemd user unit does not read your shell profile; set
`Environment=PATH=…` in the unit or install somewhere already on it). The `Environment=PATH=…` in the unit or install somewhere already on it). The
adapter needs a logged-in Claude Code (`claude /login`). Claude adapter needs a logged-in Claude Code (`claude /login`); Codex uses the
Codex CLI's or adapter's configured authentication.
When the adapter is missing the spawn still creates the session, marked dead When the adapter is missing the spawn still creates the session, marked dead
with the install command above as its activity detail, so the sidebar says with the provider-specific install command as its activity detail, so clients
what to run. The adapter is a child of the daemon: it ends with a `DELETE say what to run. The adapter is a child of the daemon: it ends with a `DELETE
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon /api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
itself. A daemon restart ends an in-flight structured turn, but preserves its itself. A daemon restart ends an in-flight structured turn, but preserves its
worktree, session record, and event-log transcript. The replacement daemon worktree, session record, and event-log transcript. The replacement daemon
+104 -43
View File
@@ -16,10 +16,10 @@ does not exist yet, and nothing here describes it.
A terminal session's agent runs inside a tmux pane; a structured session's A terminal session's agent runs inside a tmux pane; a structured session's
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
daemon is the ACP **client**; the adapter process, `claude-code-acp`, is the daemon is the ACP **client**; the provider adapter process is the ACP
ACP **agent** — it wraps Claude Code's own SDK and translates the model's **agent**. It translates the provider's events into ACP `session/update`
tool calls into ACP `session/update` notifications instead of drawing them to notifications instead of drawing them to a pane. `crates/maestro-acp` owns the
a pane. `crates/maestro-acp` owns the daemon's side of that connection; daemon's side of that connection;
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two `crates/maestro-daemon/src/projection.rs` folds what it receives into the two
projections below. projections below.
@@ -35,32 +35,44 @@ session's worktree holds unsaved work — applies to a structured session
exactly as it does to a terminal one; only what gets terminated differs (an exactly as it does to a terminal one; only what gets terminated differs (an
ACP handle instead of a tmux pane). ACP handle instead of a tmux pane).
## Choosing the view
`view` is fixed at spawn and never changes. Every client picks it the same
way, from the resolved command alone
(`maestro_protocol::display::supports_structured_view`): a command that
resolves to Claude Code or Codex spawns **Structured** by default, anything
else spawns **Terminal**. The operator can override the default — the web and
TUI spawn forms show the checkbox ticked and let it be unticked, and
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
even for a command the CLI does not recognise and lets the daemon refuse it).
Once the operator works the toggle, a later command change never re-ticks it
under them; it can still be forced off when the new command has no adapter.
The daemon is the last word: a `Structured` request for a command with no
adapter is refused there, whichever client sent it.
## The adapter ## The adapter
`ADAPTER_BIN` (`claude-code-acp`) is resolved by walking `PATH` — the The resolved structured command selects a provider adapter. Its binary is
session's own `PATH` env var if the spawn request set one, the daemon's resolved by walking the session's `PATH` when the spawn request sets one, or
otherwise — and taking the first executable file named `claude-code-acp` on the daemon's `PATH` otherwise, and taking the first executable with that name.
it. Install it once per host:
```sh | Command | Adapter binary | Install command | Provider detail |
npm install -g --prefix ~/.local @zed-industries/claude-code-acp | --- | --- | --- | --- |
``` | `claude` | `claude-code-acp` | `npm install -g --prefix ~/.local @zed-industries/claude-code-acp` | Needs a logged-in Claude Code. |
| `codex` | `codex-acp` | `npm install -g --prefix ~/.local @agentclientprotocol/codex-acp` | Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration. |
That is `maestro_acp::INSTALL_COMMAND`, and it is not just documentation: it The selected install command is baked into `AcpError::AdapterMissing`. When an
is baked into `AcpError::AdapterMissing`'s message. When the adapter cannot adapter cannot be found, spawn still creates a **Dead** session with that
be found, spawn still creates the session — **Dead**, with that install command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
command as its `activity_detail` — so the sidebar names the fix instead of caveat). Provider authentication failures surface through the adapter's stderr
the session silently never appearing (see `docs/INSTALL.md` for the full tail in the same field.
install writeup, including the systemd `PATH` caveat). The adapter also needs
a logged-in Claude Code (`claude /login`); a missing login surfaces the same
way, via the adapter's own stderr tail in the failure message.
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
slow machine to boot a node process and the Claude Code SDK behind it, never slow machine to boot an adapter and its provider process, never long enough
long enough to hang a spawn indefinitely. Any handshake failure — missing to hang a spawn indefinitely. Any handshake failure — missing adapter, early
adapter, early exit, a protocol error, or the timeout — leaves no orphaned exit, a protocol error, or the timeout — leaves no orphaned process: `spawn`
process: `spawn` closes and reaps the child itself before returning the closes and reaps the child itself before returning the error.
error.
## The ACP handshake ## The ACP handshake
@@ -69,17 +81,17 @@ declares, deliberately, almost nothing:
| Capability | Declared | Why | | Capability | Declared | Why |
| --- | --- | --- | | --- | --- | --- |
| `fs.readTextFile` | `false` | The adapter uses Claude Code's own Read tool instead of asking the daemon to read a file on its behalf — the same tool a terminal-view Claude Code session uses. | | `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
| `fs.writeTextFile` | `false` | Same reasoning, for Write. | | `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
| `terminal` | `false` | The adapter runs Claude Code's own Bash tool rather than asking the daemon to open a terminal for it. | | `terminal` | `false` | Providers run commands through their own tool surface. |
Declining all three keeps the daemon out of the file/terminal surface Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because `claude-code-acp` has default; the daemon does not advertise support because neither supported
never asked for it, not because it was weighed and refused. adapter needs it, not because it was weighed and refused.
The one client method that *is* implemented for real is The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below. `session/request_permission` (#139) — see [Approvals](#approvals) below.
@@ -96,6 +108,14 @@ flight is dropped on arrival, deliberately: the daemon's own event log
already holds that history, and forwarding it too would duplicate every row already holds that history, and forwarding it too would duplicate every row
in the projections below. in the projections below.
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
shell commands, file changes, MCP calls, web search, image generation/view,
reasoning, plans, and token usage into standard ACP chunks and tool calls;
those fold into the same eight rows. Codex's `available_commands_update` and
`session_info_update`, like equivalent Claude updates, remain opaque
`Notice` rows. Its `session/load` capability is required for daemon recovery
just as it is for Claude.
## The eight transcript row kinds ## The eight transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured `TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
@@ -106,14 +126,14 @@ crate.
| Kind | Carries | Row identity | | Kind | Carries | Row identity |
| --- | --- | --- | | --- | --- | --- |
| `User` | An echoed `user_message_chunk` — the prompt text as ACP saw it. | `user:<message_id>`, or `user:<seq>` when the chunk carries no id. | | `User` | The prompt accepted by Maestro's structured prompt endpoint, persisted before ACP dispatch. An adapter `user_message_chunk` is also supported for imported ACP history. | `user:<uuid>` for a daemon-recorded prompt; `user:<message_id>` or `user:<seq>` for an ACP chunk. |
| `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>` / `agent:<seq>`. Later chunks for the same id are appended into `data.content.text` in place, not appended as new rows. | | `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>`, or `agent:<seq>` from the first chunk when no id is supplied. Later chunks for the same id—or consecutive anonymous text chunks—are appended into `data.content.text` in place. An empty anonymous chunk starts a fresh row, matching the turn marker emitted by adapters that omit message ids. |
| `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / `thought:<seq>`, coalesced the same way as `AgentText`. | | `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / first anonymous `thought:<seq>`, coalesced the same way as `AgentText`. |
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. | | `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. | | `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. | | `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. | | `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. | `notice:<seq>`. | | `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
`Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the `Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the
`agent-client-protocol` dependency's schema already deserializes (it `agent-client-protocol` dependency's schema already deserializes (it
@@ -147,7 +167,10 @@ choices, not daemon ones.
TUI renderers can never disagree about it. TUI renderers can never disagree about it.
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel, - Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
or a failure ends it (`TurnState.responding` / `.thinking` / or a failure ends it (`TurnState.responding` / `.thinking` /
`.active_tool_calls` are derived, not renderer-computed). `.active_tool_calls` / `.started_at` are derived, not renderer-computed).
Ending a turn appends a durable `maestroTurnMeta` footer with its elapsed
time, outcome, model, and context-window usage/delta. ACP does not expose
provider billing-token counts, so these are explicitly context tokens.
- Approval nonce minting, secrecy, and the single-active-approval rule (see - Approval nonce minting, secrecy, and the single-active-approval rule (see
below) — a client never sees an ACP permission request before the daemon below) — a client never sees an ACP permission request before the daemon
has already turned it into an `Approval` row and a nonce. has already turned it into an `Approval` row and a nonce.
@@ -162,9 +185,24 @@ choices, not daemon ones.
**A client decides:** **A client decides:**
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a - How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
diff view for `ToolDiff`, a tool card for `ToolCall`, a checklist for diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
`Plan`, and a floor presentation for `Notice`. presentation for `Notice`. `Plan` is the exception: both clients omit it
- How `responding` vs. `thinking` are distinguished visually, if at all. from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
source for composer `/` autocomplete at any whitespace-delimited token under
the caret. Completing a command replaces only that token. The client omits
those state-only notices, along with `session_info_update`, from the visible
transcript.
- How live `responding`, `thinking`, tool activity, elapsed time, and context
usage are drawn. The web client keeps these in a compact strip above the
composer and renders each durable turn footer inline with the transcript.
- How the operator is asked to resolve an `Approval` row — the daemon only - How the operator is asked to resolve an `Approval` row — the daemon only
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`), exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
never the UI that solicits it. never the UI that solicits it.
@@ -195,8 +233,9 @@ every row, once appended, is only ever patched or left alone.
The event log's own line position is the cursor: 1-based, shared by ACP The event log's own line position is the cursor: 1-based, shared by ACP
notifications and daemon-generated rows alike (`sidecar.rs`'s notifications and daemon-generated rows alike (`sidecar.rs`'s
`PersistedEvent` envelope is what lets an `Approval` row share a cursor `PersistedEvent` envelope is what lets `User` and `Approval` rows share a cursor
sequence with raw ACP JSON without pretending to be ACP traffic). Two ways to sequence with raw ACP JSON without pretending to be ACP traffic; the envelope
also carries durable `Turn` footers). Two ways to
read it: read it:
- **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and - **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and
@@ -245,7 +284,7 @@ pending decision, entirely on the daemon side:
- **Secrecy from the agent and from durable history.** The adapter itself - **Secrecy from the agent and from durable history.** The adapter itself
never learns the nonce — it is a daemon-to-client secret, never sent back never learns the nonce — it is a daemon-to-client secret, never sent back
over the ACP connection. It is likewise never written to the event log: over the ACP connection. It is likewise never written to the event log:
restoring an `Approval` row from disk (`Projection::restore_approval`) restoring an `Approval` row from disk (`Projection::restore_row`)
produces a historical row with no `pending_approval` in state, because the produces a historical row with no `pending_approval` in state, because the
nonce that would make it "pending" never made it to disk in the first nonce that would make it "pending" never made it to disk in the first
place. A restart therefore cannot resume a pending approval — see place. A restart therefore cannot resume a pending approval — see
@@ -279,6 +318,26 @@ pending decision, entirely on the daemon side:
## Prompt and cancel: bounded liveness ## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
be removed before sending; failed sends retain the draft and attachments.
Pastes also stay inline when all ten attachment slots are occupied or the text
exceeds the 20 MiB file limit.
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
Only live structured sessions accept uploads. Filenames are limited to 180
bytes and cannot contain directory separators or control characters. Files get unique names and private
file permissions in `<id>.attachments` beside the session sidecar. They persist
across daemon restarts; removing a composer chip does not delete uploaded data.
There is currently no automatic attachment cleanup.
The prompt includes each filename and host path so the adapter can read it
through its own file tools, including images when the provider supports them.
These references are persisted in the ordinary user transcript row. Attachment
bytes are not embedded in ACP messages or transcript history.
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized `POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
against each other through one per-session `operation` lock against each other through one per-session `operation` lock
(`AcpEventStream::operation`), because ACP permission callbacks carry no (`AcpEventStream::operation`), because ACP permission callbacks carry no
@@ -287,7 +346,9 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn." allowed to claim the daemon's idea of "the active turn."
- A prompt claims the turn and dispatches it to ACP as one operation. While a - A prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing — one active turn per session, never a hidden queue of prompts. queuing — one active turn per session, never a hidden queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`, - Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
@@ -313,8 +374,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw - **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
ACP `SessionNotification` (kept in its original JSON so old logs stay ACP `SessionNotification` (kept in its original JSON so old logs stay
readable) or a `{"maestroEvent": "approval", "row": …}` envelope for a readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
daemon-generated row. Both share the same 1-based line-position cursor. A for a daemon-generated row. Both share the same 1-based line-position cursor. A
line a hard kill left half-written is terminated before the next append, so line a hard kill left half-written is terminated before the next append, so
it keeps its position and is skipped as malformed rather than corrupting it keeps its position and is skipped as malformed rather than corrupting
what follows. An append that fails *while the daemon is running* — a what follows. An append that fails *while the daemon is running* — a
+120
View File
@@ -0,0 +1,120 @@
{
"_description": "the parity fixture for #199's command resolver — every case here is asserted identically by crates/maestro-protocol/src/display.rs (command_program/command_tool) and web/src/toolIcon.ts (splitCommand). `program` is the resolved, basenamed program; `tool` is the icon id it maps to, or null when unrecognised/ambiguous.",
"cases": [
{ "command": "claude", "program": "claude", "tool": "claude" },
{ "command": "claude --resume", "program": "claude", "tool": "claude" },
{ "command": "CLAUDE", "program": "CLAUDE", "tool": "claude" },
{ "command": "/usr/local/bin/claude -p hi", "program": "claude", "tool": "claude" },
{ "command": "./bin/claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=1 BAR=2 claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=\"bar baz\" claude", "program": "claude", "tool": "claude" },
{ "command": "FOO='bar baz' claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=bar\\ baz claude", "program": "claude", "tool": "claude" },
{ "command": "'/usr/local/bin/claude'", "program": "claude", "tool": "claude" },
{ "command": "\"/opt/my agent/claude\" --resume", "program": "claude", "tool": "claude" },
{ "command": "env -i claude", "program": "claude", "tool": "claude" },
{ "command": "env --ignore-environment claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -u root claude", "program": "claude", "tool": "claude" },
{ "command": "sudo --user=root claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -- claude --resume", "program": "claude", "tool": "claude" },
{ "command": "nohup claude", "program": "claude", "tool": "claude" },
{ "command": "time claude", "program": "claude", "tool": "claude" },
{ "command": "time -p claude", "program": "claude", "tool": "claude" },
{ "command": "time -- claude", "program": "claude", "tool": "claude" },
{ "command": "time -p -- claude", "program": "claude", "tool": "claude" },
{ "command": "time -pp claude", "program": "time", "tool": null },
{ "command": "time -p -p claude", "program": "time", "tool": null },
{ "command": "time -o /tmp/t.log claude", "program": "time", "tool": null },
{ "command": "command claude", "program": "claude", "tool": "claude" },
{ "command": "command -v claude", "program": "command", "tool": null },
{ "command": "command -V claude", "program": "command", "tool": null },
{ "command": "npx --yes claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "npx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm -C ./pkg dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx -s claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --silent claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx -q claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --quiet claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "uvx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --bun claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --bun claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x -p @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --package @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --package=@anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --no-install claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --verbose claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --silent claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun --bun x claude-code", "program": "bun", "tool": null },
{ "command": "pnpm dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --package= claude-code", "program": "pnpm", "tool": null },
{ "command": "pnpm dlx --package", "program": "pnpm", "tool": null },
{ "command": "pnpm dlx -c \"claude-code\"", "program": "pnpm", "tool": null },
{ "command": "pnpm --package=typescript dlx tsc", "program": "tsc", "tool": null },
{ "command": "pnpm dlx --unknown-flag claude-code", "program": "pnpm", "tool": null },
{ "command": "yarn dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx -p foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --package= claude-code", "program": "yarn", "tool": null },
{ "command": "yarn dlx -p", "program": "yarn", "tool": null },
{ "command": "yarn dlx -p --quiet claude-code", "program": "yarn", "tool": null },
{ "command": "bunx --package= claude-code", "program": "bunx", "tool": null },
{ "command": "bunx --package", "program": "bunx", "tool": null },
{ "command": "bun x -p --silent claude-code", "program": "bun", "tool": null },
{ "command": "sudo env -i claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=1 sudo BAR=2 claude", "program": "claude", "tool": "claude" },
{ "command": "env -iv claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -nE claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -uroot claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -u", "program": "sudo", "tool": null },
{ "command": "env --unset= claude", "program": "env", "tool": null },
{ "command": "sudo --user= claude", "program": "sudo", "tool": null },
{ "command": "sudo --user \"\" claude", "program": "sudo", "tool": null },
{ "command": "sudo -nu claude", "program": "sudo", "tool": null },
{ "command": "env -S 'python3 -u' script.py", "program": "env", "tool": null },
{ "command": "npx -c \"claude --version\"", "program": "npx", "tool": null },
{ "command": "npx --call \"claude --version\"", "program": "npx", "tool": null },
{ "command": "command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "nohup FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "npx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "pnpm dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "yarn dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "bun x FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "bunx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "uvx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "sudo time FOO=1 claude", "program": "claude", "tool": "claude" },
{ "command": "nohup env FOO=1 claude", "program": "claude", "tool": "claude" },
{ "command": "sudo command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "command nohup FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "time command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "\"FOO=1\" claude", "program": "FOO=1", "tool": null },
{ "command": "FOO\\=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\"O\"O=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\\OO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "\"FOO\"=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\"\"OO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "claude\nclaude-code", "program": "claude", "tool": null },
{ "command": "clau\\\nde --resume", "program": "claude", "tool": "claude" },
{ "command": "npx opencode", "program": "opencode", "tool": "opencode" },
{ "command": "pnpm dlx opencode run", "program": "opencode", "tool": "opencode" },
{ "command": "pnpm claude", "program": "pnpm", "tool": null },
{ "command": "yarn claude", "program": "yarn", "tool": null },
{ "command": "claude-code-acp", "program": "claude-code-acp", "tool": null },
{ "command": "hx src/main.rs", "program": "hx", "tool": null },
{ "command": "bash", "program": "bash", "tool": "shell" },
{ "command": "opencode", "program": "opencode", "tool": "opencode" },
{ "command": "sudo", "program": "sudo", "tool": null },
{ "command": "", "program": "", "tool": null },
{ "command": "npx --unknown-flag claude-code", "program": "npx", "tool": null },
{ "command": "claude | tee log.txt", "program": "claude", "tool": null },
{ "command": "claude; echo done", "program": "claude;", "tool": null },
{ "command": "claude && echo done", "program": "claude", "tool": null },
{ "command": "echo hi $(claude)", "program": "echo", "tool": null },
{ "command": "claude \"unterminated", "program": "claude", "tool": null }
]
}
+1 -1
View File
@@ -148,7 +148,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) { pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src; inherit (finalAttrs) pname version src;
fetcherVersion = 3; fetcherVersion = 3;
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ="; hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
}; };
buildPhase = '' buildPhase = ''
+14 -1
View File
@@ -11,7 +11,8 @@
"@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0" "@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
}, },
"devDependencies": { "devDependencies": {
"happy-dom": "^20.11.6", "happy-dom": "^20.11.6",
@@ -1222,6 +1223,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+3 -2
View File
@@ -6,7 +6,7 @@
"packageManager": "pnpm@9.15.4", "packageManager": "pnpm@9.15.4",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b tsconfig.build.json && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -b --noEmit", "typecheck": "tsc -b --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -16,7 +16,8 @@
"@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0" "@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
}, },
"devDependencies": { "devDependencies": {
"happy-dom": "^20.11.6", "happy-dom": "^20.11.6",
+10
View File
@@ -20,6 +20,9 @@ importers:
'@xterm/xterm': '@xterm/xterm':
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.5.0 version: 5.5.0
marked:
specifier: ^18.0.11
version: 18.0.11
devDependencies: devDependencies:
happy-dom: happy-dom:
specifier: ^20.11.6 specifier: ^20.11.6
@@ -424,6 +427,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.11:
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
engines: {node: '>= 20'}
hasBin: true
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -864,6 +872,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.11: {}
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.12: {} nanoid@3.3.12: {}
+25
View File
@@ -12,6 +12,7 @@ import type {
RecentDirs, RecentDirs,
Session, Session,
StructuredApprovalDecision, StructuredApprovalDecision,
StructuredConfigRequest,
StructuredPromptRequest, StructuredPromptRequest,
Uuid, Uuid,
} from "./types.js"; } from "./types.js";
@@ -93,6 +94,21 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
); );
} }
/** Change one option advertised by a live ACP session. */
export async function setStructuredConfig(
id: Uuid,
req: StructuredConfigRequest,
): Promise<void> {
await jsonOrError(
await fetch(routes.structuredConfig(id), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(req),
}),
);
}
/** Report whether this client's pane is the one currently showing `id` — not /** Report whether this client's pane is the one currently showing `id` — not
* merely highlighted in a sidebar. The daemon uses this to clear an * merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough * `AwaitingInput` glow once the pane has been looked at long enough
@@ -168,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
); );
} }
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/octet-stream" },
body: file,
}));
}
/** Cancel a structured session's active ACP turn (#138). */ /** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> { export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError( await jsonOrError(
+21 -2
View File
@@ -64,6 +64,7 @@ export function createAttachConnection(
<div class="attach-body"></div> <div class="attach-body"></div>
`; `;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement; const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
@@ -323,6 +324,9 @@ export function createAttachConnection(
let ws: WebSocket; let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS; let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null; let reconnectTimer: number | null = null;
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
// Follow it once per socket; later scrolling remains under the operator's control.
let awaitingReplay = true;
const sendControl = (msg: ClientControl) => { const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return; if (ws.readyState !== WebSocket.OPEN) return;
@@ -342,6 +346,7 @@ export function createAttachConnection(
}; };
function connect(): void { function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId)); ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
@@ -362,10 +367,20 @@ export function createAttachConnection(
return; return;
} }
if (ev.data instanceof ArrayBuffer) { if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data)); const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) { } else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer". // Some browsers may default to Blob despite binaryType="arraybuffer".
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b))); void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
} }
}; };
@@ -463,6 +478,10 @@ export function createAttachConnection(
// The closure above: the same bytes-out path keystrokes and the mobile key // The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take. // bar already take.
sendInput, sendInput,
updateInfo(next: AttachInfo) {
titleEl.innerHTML = paneTitleHtml(sessionId, next);
titleEl.title = paneTitleAttr(sessionId, next);
},
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
+1
View File
@@ -27,6 +27,7 @@ function fakeConn(kind: string, id: string) {
sendInput(data: string) { sendInput(data: string) {
sent.push({ id, data }); sent.push({ id, data });
}, },
updateInfo() {},
dispose() { dispose() {
disposed.push(`${kind}:${id}`); disposed.push(`${kind}:${id}`);
}, },
+1
View File
@@ -110,6 +110,7 @@ export class ConnectionPool {
} else { } else {
this.cache.delete(id); this.cache.delete(id);
this.cache.set(id, conn); // LRU refresh this.cache.set(id, conn); // LRU refresh
if (info) conn.updateInfo(info);
} }
} else { } else {
conn = this.create(id, info); conn = this.create(id, info);
+25
View File
@@ -130,6 +130,31 @@ describe("DaemonLink", () => {
expect(link.status().state).toBe("online"); expect(link.status().state).toBe("online");
}); });
it("rechecks immediately when the browser returns from the background", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
reply = info("aaa", "i1");
link.recover();
await Promise.resolve();
expect(link.status().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => { it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual(); const sched = manual();
const reload = vi.fn(); const reload = vi.fn();
+11
View File
@@ -148,6 +148,17 @@ export class DaemonLink {
void this.probe(); void this.probe();
} }
/**
* Recheck liveness after the browser resumes from the background. Mobile
* browsers may suspend both WebSocket events and timers while a PWA is
* hidden, leaving a stale `down` state after the daemon is already back.
*/
recover(): void {
if (this.state === "updating") return;
this.noteDisconnected();
void this.probe();
}
/** /**
* One `/api/info` round-trip. Resolves `true` when the daemon answered. * One `/api/info` round-trip. Resolves `true` when the daemon answered.
* *
+15
View File
@@ -132,6 +132,21 @@ describe("CatalogStore reducer", () => {
}); });
describe("CatalogStore reconnect", () => { describe("CatalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.start();
store.reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => { it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket()); const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets); const { store, scheduler } = storeWith(sockets);
+17
View File
@@ -59,6 +59,23 @@ export class CatalogStore {
this.connect(); this.connect();
} }
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// A close event from the superseded socket must not schedule a second
// connection alongside the new one.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.connect();
}
/** Current reconnect delay, for tests asserting the backoff schedule. */ /** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number { backoff(): number {
return this.backoffMs; return this.backoffMs;
+13
View File
@@ -107,6 +107,19 @@ void link.start();
store.start(); store.start();
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after a
// transient lost connection has already recovered.
function recoverFromBackground(): void {
if (document.visibilityState !== "visible") return;
link.recover();
store.reconnect();
}
document.addEventListener("visibilitychange", recoverFromBackground);
window.addEventListener("pageshow", recoverFromBackground);
window.addEventListener("online", recoverFromBackground);
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed // Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite. // without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) { if (import.meta.env.DEV && location.search.includes("mock")) {
+3
View File
@@ -4,6 +4,7 @@
// transcript (structured.ts). // transcript (structured.ts).
import type { Uuid } from "./types.js"; import type { Uuid } from "./types.js";
import type { AttachInfo } from "./paneTitle.js";
export interface PaneConnection { export interface PaneConnection {
readonly sessionId: Uuid; readonly sessionId: Uuid;
@@ -21,6 +22,8 @@ export interface PaneConnection {
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection * literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
* has no PTY, so this is a no-op there. */ * has no PTY, so this is a no-op there. */
sendInput(data: string): void; sendInput(data: string): void;
/** Refresh the pane header after the catalog renames a live session. */
updateInfo(info: AttachInfo): void;
/** Fully disconnect and remove the element. */ /** Fully disconnect and remove the element. */
dispose(): void; dispose(): void;
} }
+9
View File
@@ -47,11 +47,20 @@ export function structuredPrompt(id: Uuid): string {
return `${session(id)}/structured/prompt`; return `${session(id)}/structured/prompt`;
} }
export function structuredAttachment(id: Uuid): string {
return `${session(id)}/structured/attachments`;
}
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */ /** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
export function structuredCancel(id: Uuid): string { export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`; return `${session(id)}/structured/cancel`;
} }
/** `POST /api/sessions/:id/structured/config` — change an ACP session option. */
export function structuredConfig(id: Uuid): string {
return `${session(id)}/structured/config`;
}
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission /** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
* request. */ * request. */
export function structuredApproval(id: Uuid, nonce: string): string { export function structuredApproval(id: Uuid, nonce: string): string {
+15 -9
View File
@@ -26,15 +26,21 @@ describe("buildSpawnRequest", () => {
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } }); expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
}); });
it("requests structured view only for Claude Code", () => { it.each(["claude", "FOO=1 /usr/local/bin/codex", "env -i codex"])(
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({ "requests structured view for resolved %s commands",
req: { (command) => {
cwd: base.cwd, expect(buildSpawnRequest({ ...base, command, structuredView: true }, null)).toEqual({
command: "claude", req: {
metadata: { name: "amber-fox" }, cwd: base.cwd,
view: "Structured", command,
}, metadata: { name: "amber-fox" },
}); view: "Structured",
},
});
},
);
it("does not request structured view for unsupported commands", () => {
expect( expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null), buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({ ).toEqual({
+20 -3
View File
@@ -15,6 +15,13 @@ import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js"; import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
function supportsStructuredView(command: string): boolean {
const tool = splitCommand(command).tool;
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
}
/** Where a worktree's branch comes from: freshly created (default) or picked /** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */ * from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing"; export type WorktreeBranchMode = "new" | "existing";
@@ -68,7 +75,7 @@ export function buildSpawnRequest(
metadata, metadata,
}; };
if (values.structuredView && splitCommand(values.command).tool === "claude") { if (values.structuredView && supportsStructuredView(values.command)) {
req.view = "Structured"; req.view = "Structured";
} }
@@ -368,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// operator has already touched (#76). // operator has already touched (#76).
let commandTouched = false; let commandTouched = false;
// Set once the operator works the checkbox themselves. Until then every
// command change re-derives the default (on for Claude Code and Codex, off
// for everything else); afterwards their choice outranks it, the same guard
// `commandTouched` gives the command field.
let structuredTouched = false;
function updateStructuredAvailability(): void { function updateStructuredAvailability(): void {
const supported = splitCommand(command).tool === "claude"; const supported = supportsStructuredView(command);
structuredWrap.hidden = !supported; structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false; if (!supported) structuredView.checked = false;
else if (!structuredTouched) structuredView.checked = true;
} }
structuredView.addEventListener("change", emit); structuredView.addEventListener("change", () => {
structuredTouched = true;
emit();
});
cmdRaw.addEventListener("input", () => { cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
+65 -15
View File
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("is default-off and shown only for the resolved Claude Code command", async () => { it("is default-on and shown for resolved Claude Code and Codex commands", async () => {
stubApi(); stubApi();
handle = createSpawnForm({ onSuccess() {} }); handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
@@ -57,28 +57,55 @@ describe("spawn form structured view", () => {
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click(); handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(disclosure.hidden).toBe(false); expect(disclosure.hidden).toBe(false);
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(false); expect(checkbox.checked).toBe(true);
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click(); const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
expect(wrap.hidden).toBe(true); custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click(); command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
}); });
it("clears a checked value when the command changes away from Claude Code", async () => { it("keeps an unchecked box unchecked across a command change", async () => {
stubApi(); stubApi();
handle = createSpawnForm({ onSuccess() {} }); handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
await drain(); await drain();
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!; const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
expect(checkbox.checked).toBe(true);
checkbox.click(); checkbox.click();
expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
expect(checkbox.checked).toBe(false);
});
it("clears the checked default when Codex changes to an unsupported command", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "env -i codex";
command.dispatchEvent(new Event("input"));
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
expect(checkbox.checked).toBe(true); expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "opencode"; command.value = "opencode";
command.dispatchEvent(new Event("input")); command.dispatchEvent(new Event("input"));
@@ -86,7 +113,33 @@ describe("spawn form structured view", () => {
expect(checkbox.checked).toBe(false); expect(checkbox.checked).toBe(false);
}); });
it("emits Structured in the submitted request when selected", async () => { it.each(["claude", "codex"])("emits Structured for a %s command by default", async (tool) => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
onSuccess() {},
});
document.body.appendChild(handle.el);
await drain();
if (tool === "codex") {
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
}
handle.el.requestSubmit();
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({
command: tool,
view: "Structured",
});
});
it("emits no view once the operator unticks the box", async () => {
const requests = stubApi(); const requests = stubApi();
handle = createSpawnForm({ handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false }, seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
@@ -100,9 +153,6 @@ describe("spawn form structured view", () => {
await drain(); await drain();
expect(requests).toHaveLength(1); expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({ expect(JSON.parse(String(requests[0].body)).view).toBeUndefined();
command: "claude",
view: "Structured",
});
}); });
}); });
+867 -34
View File
@@ -6,16 +6,24 @@ vi.mock("./api.js", async () => {
return { return {
...actual, ...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined), sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined), cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined), resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
}; };
}); });
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js"; import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
} from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js"; import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
import { CatalogStore } from "./events.js"; import type { CatalogStore } from "./events.js";
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js"; import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js"; import type { ReducedState, StructuredEvent, TranscriptRow } from "./types.js";
/** Just enough of a WebSocket for the connection to drive (mirrors /** Just enough of a WebSocket for the connection to drive (mirrors
* events.test.ts's FakeSocket, adapted for JSON structured events). */ * events.test.ts's FakeSocket, adapted for JSON structured events). */
@@ -44,6 +52,22 @@ function row(id: string, kind: TranscriptRow["kind"], data: unknown): Transcript
return { id, kind, data }; return { id, kind, data };
} }
function toolRow(id: string, text: string): TranscriptRow {
return row(id, "tool_call", {
title: `Read(${id})`,
status: "completed",
content: [{ type: "content", content: { type: "text", text } }],
});
}
function diffRow(id: string, newText: string): TranscriptRow {
return row(id, "tool_diff", {
title: `Edit(${id})`,
status: "completed",
content: [{ type: "diff", path: "file.ts", oldText: "before", newText }],
});
}
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } }; const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
const allApprovalOptions = [ const allApprovalOptions = [
@@ -73,6 +97,53 @@ function mount(opts: {
describe("structured connection: data flow", () => { describe("structured connection: data flow", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("converts long pasted text into a removable file and sends it without reply text", async () => {
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [], getData: () => "a".repeat(4000),
} });
input.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(true);
expect(input.value).toBe("");
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
conn.dispose();
});
it("leaves short pastes alone", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
conn.el.querySelector("textarea")!.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(false);
conn.dispose();
});
it("retains failed uploads for retry and lets the operator remove them", async () => {
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
input.value = "Read this";
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [new File(["content"], "notes.txt")], getData: () => "",
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(input.disabled).toBe(false));
expect(input.value).toBe("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
expect(remove).not.toBeNull();
remove.click();
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
conn.dispose();
});
it("renders every row from the initial snapshot", () => { it("renders every row from the initial snapshot", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -93,6 +164,205 @@ describe("structured connection: data flow", () => {
conn.dispose(); conn.dispose();
}); });
it("shows turn details only while that footer is the conversation's final item", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const turnMeta = (id: string, elapsedMs: number) =>
row(id, "notice", {
maestroTurnMeta: { elapsedMs, contextTokens: 12_000, outcome: "completed", model: "codex" },
});
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 4,
rows: [
row("user:1", "user", { content: { type: "text", text: "first" } }),
turnMeta("turn:1", 1_000),
row("user:2", "user", { content: { type: "text", text: "second" } }),
turnMeta("turn:2", 2_000),
],
},
});
expect(conn.el.querySelectorAll(".st-turn-meta")).toHaveLength(1);
expect(conn.el.querySelector(".st-turn-meta")?.textContent).toContain("2s");
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("user:3", "user", { content: { type: "text", text: "third" } }),
},
state: idleState,
});
expect(conn.el.querySelector(".st-turn-meta")).toBeNull();
conn.dispose();
});
it("keeps state-only plan rows out of the transcript", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 3,
rows: [
row("user:1", "user", { content: { type: "text", text: "before plan" } }),
row("plan", "plan", { entries: [{ content: "ship it", status: "pending" }] }),
row("agent:1", "agent_text", { content: { type: "text", text: "after plan" } }),
],
},
});
ws.emit({
type: "reduced_state",
state: {
...idleState,
plan: { entries: [{ content: "ship it", priority: "high", status: "pending" }] },
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
expect(transcript.querySelectorAll(".st-row")).toHaveLength(2);
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
expect(transcript.textContent).toContain("before plan");
expect(transcript.textContent).toContain("after plan");
expect(transcript.textContent).not.toContain("[plan]");
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
it("renders advertised session settings and sends changed values", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "reduced_state",
state: {
...idleState,
config_options: {
configOptions: [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "opus",
options: [
{ value: "opus", name: "Opus" },
{ value: "sonnet", name: "Sonnet" },
],
},
{
id: "effort",
name: "Effort",
category: "thought_level",
type: "select",
currentValue: "high",
options: [{ value: "high", name: "High" }],
},
],
},
},
});
expect(conn.el.querySelector(".structured-config-trigger")?.getAttribute("aria-label")).toBe(
"Session settings. Model: Opus. Effort: High",
);
(conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click();
const selects = conn.el.querySelectorAll<HTMLSelectElement>(".structured-config-select");
expect(selects).toHaveLength(2);
selects[0].value = "sonnet";
selects[0].dispatchEvent(new Event("change"));
expect(setStructuredConfig).toHaveBeenCalledWith("s1", {
config_id: "model",
value: "sonnet",
});
conn.dispose();
});
it("applies plan append, patch, and remove without disturbing surrounding rows", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [row("user:1", "user", { content: { type: "text", text: "keep me" } })],
},
});
ws.emit({ type: "reduced_state", state: idleState });
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("plan", "plan", { entries: [{ content: "first", status: "pending" }] }),
},
state: {
...idleState,
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
},
});
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("agent:1", "agent_text", { content: { type: "text", text: "still here" } }),
},
state: {
...idleState,
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
},
});
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("plan", "plan", { entries: [{ content: "updated", status: "completed" }] }),
},
state: {
...idleState,
plan: { entries: [{ content: "updated", priority: "high", status: "completed" }] },
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"user:1",
"agent:1",
]);
expect(transcript.textContent).not.toContain("updated");
ws.emit({
type: "projection_update",
transcript: { type: "remove", id: "plan" },
state: idleState,
});
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("agent:1", "agent_text", { content: { type: "text", text: "patched neighbor" } }),
},
state: idleState,
});
ws.emit({
type: "projection_update",
transcript: { type: "remove", id: "user:1" },
state: idleState,
});
expect(conn.el.querySelector(".structured-rail")).toBeNull();
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"agent:1",
]);
expect(transcript.textContent).toContain("patched neighbor");
conn.dispose();
});
it("appends and patches rows via projection_update without duplicating them", () => { it("appends and patches rows via projection_update without duplicating them", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -104,6 +374,7 @@ describe("structured connection: data flow", () => {
transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) }, transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
state: idleState, state: idleState,
}); });
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.classList).toContain("st-row-enter");
ws.emit({ ws.emit({
type: "projection_update", type: "projection_update",
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) }, transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) },
@@ -111,11 +382,37 @@ describe("structured connection: data flow", () => {
}); });
const rows = conn.el.querySelectorAll(".st-row-agent"); const rows = conn.el.querySelectorAll(".st-row-agent");
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
expect(rows[0].classList).not.toContain("st-row-enter");
expect(conn.el.textContent).toContain("partial done"); expect(conn.el.textContent).toContain("partial done");
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
conn.dispose(); conn.dispose();
}); });
it("groups multiple tools in one user turn but keeps separate turns separate", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 6,
rows: [
row("user:1", "user", { content: { type: "text", text: "first" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("user:2", "user", { content: { type: "text", text: "second" } }),
toolRow("tool:3", "three"),
],
},
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.getAttribute("aria-hidden")).toBe("true");
expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => { it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -227,6 +524,142 @@ describe("structured connection: data flow", () => {
describe("structured connection: turn/composer gating", () => { describe("structured connection: turn/composer gating", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
const commandNotice = (commands: Array<{ name: string; description: string; hint?: string }>) =>
row("commands", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: commands.map(({ name, description, hint }) => ({
name,
description,
...(hint ? { input: { hint } } : {}),
})),
});
it("autocompletes the live ACP command catalog with keyboard navigation", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
commandNotice([
{ name: "yolo", description: "Implement directly" },
{ name: "blitz", description: "Complete a milestone", hint: "<milestone>" },
]),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const menu = conn.el.querySelector<HTMLElement>(".structured-command-menu")!;
input.value = "/";
input.dispatchEvent(new Event("input"));
expect(menu.hidden).toBe(false);
expect([...menu.querySelectorAll(".structured-command-name")].map((node) => node.textContent)).toEqual([
"/blitz",
"/yolo",
]);
expect(menu.querySelector(".structured-command-hint")!.textContent).toBe("<milestone>");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(input.value).toBe("/yolo ");
expect(menu.hidden).toBe(true);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "/yolo ");
conn.dispose();
});
it("filters commands as the operator types and accepts a touch selection", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
commandNotice([
{ name: "hourlog", description: "Reconcile hours" },
{ name: "humanizer", description: "Rewrite prose" },
{ name: "blitz", description: "Complete a milestone" },
]),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.value = "/hum";
input.dispatchEvent(new Event("input"));
const options = conn.el.querySelectorAll<HTMLElement>(".structured-command-option");
expect(options).toHaveLength(1);
expect(options[0].textContent).toContain("/humanizer");
options[0].click();
expect(input.value).toBe("/humanizer ");
expect(document.activeElement).toBe(input);
conn.dispose();
});
it("completes the slash token under the caret without replacing surrounding text", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
commandNotice([
{ name: "security-review", description: "Review pending changes" },
{ name: "status", description: "Show status" },
{ name: "blitz", description: "Complete a milestone" },
]),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.value = "asd /s dsa";
input.setSelectionRange(6, 6);
input.dispatchEvent(new Event("input"));
const options = conn.el.querySelectorAll<HTMLElement>(".structured-command-option");
expect([...options].map((option) => option.querySelector(".structured-command-name")!.textContent)).toEqual([
"/security-review",
"/status",
]);
options[0].click();
expect(input.value).toBe("asd /security-review dsa");
expect(input.selectionStart).toBe(input.value.indexOf("dsa"));
expect(input.selectionEnd).toBe(input.selectionStart);
conn.dispose();
});
it("replaces the rest of a partially edited slash token after the caret", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [commandNotice([{ name: "status", description: "Show status" }])],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.value = "before /stale after";
input.setSelectionRange(9, 9);
input.dispatchEvent(new Event("input"));
conn.el.querySelector<HTMLElement>(".structured-command-option")!.click();
expect(input.value).toBe("before /status after");
conn.dispose();
});
it("disables the composer while a turn is active and re-enables when idle", () => { it("disables the composer while a turn is active and re-enables when idle", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -237,9 +670,46 @@ describe("structured connection: turn/composer gating", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(true); expect(input.disabled).toBe(true);
expect(cancel.hidden).toBe(false); expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true); expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
conn.dispose();
});
it("shows live thinking time, tool activity, and context usage", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
const activity = conn.el.querySelector<HTMLElement>(".structured-activity")!;
ws.emit({
type: "reduced_state",
state: {
turn: {
responding: true,
thinking: true,
active_tool_calls: [],
started_at: new Date().toISOString(),
started_context_tokens: 10_000,
},
usage: { used: 12_400, size: 200_000 },
},
});
expect(activity.hidden).toBe(false);
expect(activity.textContent).toContain("Thinking · 0s · 12.4k context / 200k context");
ws.emit({
type: "reduced_state",
state: {
turn: { responding: true, thinking: false, active_tool_calls: ["bash-1", "read-1"] },
usage: { used: 12_800, size: 200_000 },
},
});
expect(activity.textContent).toContain("Running 2 tools");
ws.emit({ type: "reduced_state", state: idleState });
expect(activity.hidden).toBe(true);
conn.dispose(); conn.dispose();
}); });
@@ -535,6 +1005,110 @@ describe("structured connection: reconnection/error/loading", () => {
conn.dispose(); conn.dispose();
}); });
it("does not mount plan rows from a reconnect snapshot", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
first.open();
first.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [row("agent:old", "agent_text", { content: { type: "text", text: "old" } })],
},
});
first.drop();
vi.advanceTimersByTime(1000);
second.open();
second.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 4,
rows: [
row("user:new", "user", { content: { type: "text", text: "new" } }),
row("plan", "plan", { entries: [{ content: "reconnected", status: "in_progress" }] }),
],
},
});
second.emit({
type: "reduced_state",
state: {
...idleState,
plan: { entries: [{ content: "reconnected", priority: "high", status: "in_progress" }] },
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
expect(transcript.textContent).toBe("new");
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
it("keeps surviving expanded rows open across a reconnect snapshot and leaves fresh rows collapsed", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
first.open();
first.emit({
type: "transcript_snapshot",
snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
first.drop();
vi.advanceTimersByTime(1000);
second.open();
second.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 3,
rows: [toolRow("tool:1", "after"), diffRow("diff:1", "second"), toolRow("tool:2", "new")],
},
});
expect(
conn.el.querySelector('[data-row-id="tool:1"] .st-tool-head')?.getAttribute("aria-expanded"),
).toBe("true");
expect(
conn.el.querySelector('[data-row-id="diff:1"] .st-tool-head')?.getAttribute("aria-expanded"),
).toBe("true");
expect(
conn.el.querySelector('[data-row-id="tool:2"] .st-tool-head')?.getAttribute("aria-expanded"),
).toBe("false");
expect(conn.el.querySelector('[data-row-id="tool:1"] .st-tool-out')?.textContent).toContain("after");
conn.dispose();
});
it("prunes expanded IDs removed or made non-expandable by a snapshot", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 3,
rows: [row("tool:plain", "agent_text", { content: { type: "text", text: "not a tool" } })],
},
});
ws.emit({
type: "transcript_snapshot",
snapshot: { cursor: 5, rows: [toolRow("tool:gone", "returned"), toolRow("tool:plain", "returned")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) {
expect(head.getAttribute("aria-expanded")).toBe("false");
}
conn.dispose();
});
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => { it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
const first = new FakeSocket(); const first = new FakeSocket();
@@ -697,9 +1271,297 @@ describe("structured connection: reconnection/error/loading", () => {
}); });
}); });
describe("structured connection: transcript scrolling", () => {
let frames: Map<number, FrameRequestCallback>;
let nextFrame: number;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
frames = new Map();
nextFrame = 1;
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
const id = nextFrame++;
frames.set(id, cb);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
frames.delete(id);
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
function metrics(conn: ReturnType<typeof mount>, height: number, clientHeight: number, top = 0) {
const scroll = conn.el.querySelector<HTMLElement>(".structured-scroll")!;
const values = { height, clientHeight, top };
Object.defineProperties(scroll, {
scrollHeight: { configurable: true, get: () => values.height },
clientHeight: { configurable: true, get: () => values.clientHeight },
scrollTop: {
configurable: true,
get: () => values.top,
set: (value: number) => { values.top = value; },
},
});
return { scroll, values };
}
function runFrames(): void {
const pending = [...frames.entries()];
frames.clear();
for (const [, cb] of pending) cb(0);
}
it("shows the bottom after the initial transcript snapshot is laid out", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { values } = metrics(conn, 900, 300, 40);
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
row("agent:1", "agent_text", { content: { type: "text", text: "latest" } }),
],
},
});
expect(values.top).toBe(40);
runFrames();
expect(values.top).toBe(600);
conn.dispose();
});
it("keeps pinned appends and patches at the bottom after layout", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 600, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.height = 720;
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }),
},
state: idleState,
});
runFrames();
expect(values.top).toBe(520);
expect(scroll.style.scrollBehavior).toBe("smooth");
values.height = 780;
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("agent:1", "agent_text", {
content: { type: "text", text: "a longer completion" },
}),
},
state: idleState,
});
runFrames();
expect(values.top).toBe(580);
expect(scroll.style.scrollBehavior).toBe("smooth");
conn.dispose();
});
it("keeps follow-to-bottom immediate when reduced motion is requested", () => {
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 600, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.height = 720;
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("agent:1", "agent_text", { content: { type: "text", text: "quietly" } }),
},
state: idleState,
});
runFrames();
expect(values.top).toBe(520);
expect(scroll.style.scrollBehavior).toBe("");
conn.dispose();
});
it.each([
{ position: "pinned", top: 800, expected: 880, manuallyScrolled: false },
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
])("preserves the $position position when the composer grows", ({ top, expected, manuallyScrolled }) => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 1000, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.top = top;
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.value = "one\ntwo\nthree";
input.dispatchEvent(new Event("input"));
values.height = 1080;
runFrames();
expect(input.style.height).toBe("120px");
expect(values.top).toBe(expected);
conn.dispose();
});
it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 1000, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [toolRow("tool:1", "output")] } });
runFrames();
values.top = 180;
scroll.dispatchEvent(new Event("scroll"));
values.height = 1120;
ws.emit({
type: "projection_update",
transcript: { type: "append", row: row("agent:2", "agent_text", { content: { type: "text", text: "new" } }) },
state: idleState,
});
runFrames();
expect(values.top).toBe(180);
values.height = 1180;
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions }),
},
state: {
...idleState,
pending_approval: {
nonce: "n1",
request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions },
},
},
});
runFrames();
expect(values.top).toBe(180);
conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!.click();
values.height = 1260;
runFrames();
expect(values.top).toBe(180);
conn.dispose();
});
it("resumes following when the user scrolls back near the bottom", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 1000, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.top = 750;
scroll.dispatchEvent(new Event("scroll"));
values.height = 1100;
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("agent:1", "agent_text", { content: { type: "text", text: "follow" } }),
},
state: idleState,
});
runFrames();
expect(values.top).toBe(900);
conn.dispose();
});
it("preserves a user-scrolled position across a reconnect snapshot", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
const { scroll, values } = metrics(conn, 1000, 200);
first.open();
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.top = 160;
scroll.dispatchEvent(new Event("scroll"));
first.drop();
vi.advanceTimersByTime(1000);
second.open();
values.height = 1200;
second.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
row("agent:1", "agent_text", {
content: { type: "text", text: "reconnected" },
}),
],
},
});
runFrames();
expect(values.top).toBe(160);
conn.dispose();
});
it("cancels a pending transcript frame when disposed or parked", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { values } = metrics(conn, 900, 300, 25);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
expect(frames.size).toBe(1);
conn.onUnmount();
expect(frames.size).toBe(0);
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.dispatchEvent(new Event("input"));
expect(frames.size).toBe(0);
conn.onMount({ focus: false });
expect(frames.size).toBe(2);
conn.dispose();
expect(frames.size).toBe(0);
runFrames();
expect(values.top).toBe(25);
});
});
describe("structured connection: pane lifecycle", () => { describe("structured connection: pane lifecycle", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("keeps the composer outside the transcript scroll region", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const main = conn.el.querySelector(".structured-main")!;
const scroll = conn.el.querySelector(".structured-scroll")!;
const composer = conn.el.querySelector(".structured-composer")!;
expect(main.contains(scroll)).toBe(true);
expect(main.contains(composer)).toBe(true);
expect(scroll.contains(composer)).toBe(false);
conn.dispose();
});
it("sendInput is a no-op — there is no PTY behind a structured session", () => { it("sendInput is a no-op — there is no PTY behind a structured session", () => {
const conn = mount({ sockets: [new FakeSocket()] }); const conn = mount({ sockets: [new FakeSocket()] });
expect(() => conn.sendInput("\x0b")).not.toThrow(); expect(() => conn.sendInput("\x0b")).not.toThrow();
@@ -737,41 +1599,12 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose(); conn.dispose();
}); });
it("dispose closes the socket and unsubscribes from the store", () => { it("dispose closes the socket", () => {
const store = new CatalogStore({ socket: () => new FakeSocket() as unknown as WebSocket });
const unsubscribe = vi.spyOn(store, "subscribe");
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store }); const conn = mount({ sockets: [ws] });
expect(unsubscribe).toHaveBeenCalled();
conn.dispose(); conn.dispose();
expect(ws.closed).toBe(true); expect(ws.closed).toBe(true);
}); });
it("reflects live worktree/blocked-work data from the store in the rail", () => {
const catalogWs = new FakeSocket();
const store = new CatalogStore({ socket: () => catalogWs as unknown as WebSocket });
store.start();
const session: Session = {
id: "s1",
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
blocked_work: { uncommitted_files: 1, untracked_files: 0, unpushed_commits: 2 },
};
catalogWs.emit({ type: "Snapshot", sessions: [session] } as never);
const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.textContent).toContain("feat/x");
conn.dispose();
});
}); });
describe("structured connection: deferred mount focus", () => { describe("structured connection: deferred mount focus", () => {
+735 -111
View File
File diff suppressed because it is too large Load Diff
+220 -109
View File
@@ -2,14 +2,18 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
branchFromWorktreePath, buildToolGroup,
buildTranscriptRow, buildTranscriptRow,
renderInlineMarkdown, renderInlineMarkdown,
renderRail, renderMarkdown,
} from "./structuredRender.js"; } from "./structuredRender.js";
import type { Session, TranscriptRow } from "./types.js"; import type { TranscriptRow } from "./types.js";
function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow { function row<K extends TranscriptRow["kind"]>(
kind: K,
data: unknown,
id = "r1",
): TranscriptRow & { kind: K } {
return { id, kind, data }; return { id, kind, data };
} }
@@ -20,6 +24,7 @@ describe("buildTranscriptRow: user/agent/thought", () => {
); );
expect(el.className).toContain("st-row-user"); expect(el.className).toContain("st-row-user");
expect(el.textContent).toContain("hi <b>there</b>"); expect(el.textContent).toContain("hi <b>there</b>");
expect(el.querySelector(".st-who")).toBeNull();
// never actual markup from user input // never actual markup from user input
expect(el.querySelector("b")).toBeNull(); expect(el.querySelector("b")).toBeNull();
}); });
@@ -35,10 +40,15 @@ describe("buildTranscriptRow: user/agent/thought", () => {
expect(ps[0].innerHTML).toContain("<code>three</code>"); expect(ps[0].innerHTML).toContain("<code>three</code>");
}); });
it("renders a thought row as plain text", () => { it("trims ACP framing whitespace and renders thought-row inline markdown", () => {
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } })); const el = buildTranscriptRow(
row("agent_thought", {
content: { type: "text", text: "\n\n**Planning comply tool commentary update**\n" },
}),
);
expect(el.className).toContain("st-row-thought"); expect(el.className).toContain("st-row-thought");
expect(el.textContent).toBe("hmm"); expect(el.textContent).toBe("Planning comply tool commentary update");
expect(el.querySelector("strong")?.textContent).toBe("Planning comply tool commentary update");
}); });
it("falls back to a labelled placeholder for non-text content, never crashing", () => { it("falls back to a labelled placeholder for non-text content, never crashing", () => {
@@ -51,6 +61,21 @@ describe("buildTranscriptRow: user/agent/thought", () => {
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } })); const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
expect(el.textContent).toContain(long); expect(el.textContent).toContain(long);
}); });
it("copies message blocks on click and supports keyboard activation", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
const el = buildTranscriptRow(
row("agent_text", { content: { type: "text", text: "copy **this** exactly" } }),
);
expect(el.getAttribute("role")).toBe("button");
el.click();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("copy **this** exactly");
el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await Promise.resolve();
expect(writeText).toHaveBeenCalledTimes(2);
});
}); });
describe("renderInlineMarkdown", () => { describe("renderInlineMarkdown", () => {
@@ -63,6 +88,24 @@ describe("renderInlineMarkdown", () => {
}); });
}); });
describe("renderMarkdown", () => {
it("renders GFM tables as semantic table markup", () => {
const html = renderMarkdown("| Model | State |\n| --- | --- |\n| Opus | ready |");
expect(html).toContain("<table>");
expect(html).toContain("<th>Model</th>");
expect(html).toContain("<td>Opus</td>");
});
it("removes executable HTML and unsafe URLs", () => {
const html = renderMarkdown(
'<img src=x onerror="alert(1)"><script>alert(2)</script>[bad](javascript:alert(3))',
);
expect(html).not.toContain("<img");
expect(html).not.toContain("<script");
expect(html).not.toContain('href="javascript:');
});
});
describe("buildTranscriptRow: tool calls", () => { describe("buildTranscriptRow: tool calls", () => {
it("shows glyph, name, argument and no meta for a completed non-diff call", () => { it("shows glyph, name, argument and no meta for a completed non-diff call", () => {
const el = buildTranscriptRow( const el = buildTranscriptRow(
@@ -115,7 +158,10 @@ describe("buildTranscriptRow: tool calls", () => {
expect(head.getAttribute("aria-expanded")).toBe("true"); expect(head.getAttribute("aria-expanded")).toBe("true");
expect(out.textContent).toContain("match found"); expect(out.textContent).toContain("match found");
head.click(); head.click();
expect(out.hidden).toBe(true); expect(out.hidden).toBe(false);
expect(out.getAttribute("aria-hidden")).toBe("true");
expect(out.inert).toBe(true);
expect(out.classList.contains("is-expanded")).toBe(false);
}); });
it("does not expand when there is nothing to show", () => { it("does not expand when there is nothing to show", () => {
@@ -124,6 +170,134 @@ describe("buildTranscriptRow: tool calls", () => {
head.click(); head.click();
expect(head.getAttribute("aria-expanded")).toBe("false"); expect(head.getAttribute("aria-expanded")).toBe("false");
}); });
it("renders command fields and keeps the exact JSON available", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Bash(test)",
rawInput: { command: "printf ok", cwd: "/workspace", timeout_ms: 1000 },
rawOutput: { formatted_output: "ok", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
expect(el.querySelector(".st-tool-fact")!.textContent).toBe("Exit code0");
const raw = el.querySelector(".st-tool-raw") as HTMLDetailsElement;
expect(raw.open).toBe(false);
expect(raw.textContent).toContain('"timeout_ms": 1000');
expect(raw.textContent).toContain('"exit_code": 0');
});
it("renders MCP arguments and results without hiding the source payload", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "mcp.github.search",
rawInput: { server: "github", tool: "search", arguments: { query: "ACP" } },
rawOutput: { result: { count: 2 }, error: null },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const labels = [
...el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
].map((node) => node.querySelector(".st-tool-detail-label")?.textContent);
expect(labels).toEqual(["Arguments", "Result"]);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"server": "github"');
});
it("renders a recognized web search query", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Search the web",
rawInput: { type: "webSearch", query: "Agent Client Protocol", action: { type: "search" } },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail-label")!.textContent).toBe("Query");
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("Agent Client Protocol");
});
it("falls back to raw JSON for an unknown provider shape", () => {
const el = buildTranscriptRow(
row("tool_call", { title: "Custom", rawInput: { peculiar: [1, 2] }, rawOutput: "done" }),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(
el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
).toHaveLength(0);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"peculiar"');
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"done"');
});
it("renders standardized content before provider-specific and raw data", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Bash(test)",
content: [
{ type: "content", content: { type: "text", text: "typed result" } },
{ type: "content", content: { type: "resource_link", name: "report", uri: "file:///tmp/report" } },
{ type: "terminal", terminalId: "term-1" },
],
rawInput: { command: "test" },
rawOutput: { formatted_output: "typed result", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const inner = el.querySelector(".st-tool-out > .st-disclosure-clip > .st-disclosure-inner")!;
expect([...inner.children].map((node) => node.className)).toEqual([
"st-tool-text st-copyable",
"st-tool-resource",
"st-tool-terminal",
"st-tool-detail",
"st-tool-fact",
"st-tool-raw",
]);
expect(el.querySelector(".st-tool-resource")!.textContent).toContain("file:///tmp/report");
expect(el.querySelector(".st-tool-terminal")!.textContent).toBe("Terminal · term-1");
});
it("renders safe typed image and audio blocks as media controls", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Media",
content: [
{ type: "content", content: { type: "image", mimeType: "image/png", data: "aW1hZ2U=" } },
{ type: "content", content: { type: "audio", mimeType: "audio/mpeg", data: "YXVkaW8=" } },
],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect((el.querySelector(".st-tool-image") as HTMLImageElement).src).toBe("data:image/png;base64,aW1hZ2U=");
expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe("data:audio/mpeg;base64,YXVkaW8=");
});
});
describe("buildToolGroup", () => {
it("starts collapsed, then reveals independently expandable tool calls", () => {
const rows = [
row("tool_call", { title: "Read(a.ts)", content: [{ type: "content", content: { type: "text", text: "a" } }] }, "t1"),
row("tool_call", { title: "Bash(test)", content: [{ type: "content", content: { type: "text", text: "ok" } }] }, "t2"),
];
const children = rows.map((tool) => buildTranscriptRow(tool));
const onExpanded = vi.fn();
const group = buildToolGroup("turn:u1", rows, children, false, onExpanded);
const head = group.querySelector<HTMLButtonElement>(".st-tool-group-head")!;
const body = group.querySelector<HTMLElement>(".st-tool-group-body")!;
expect(head.textContent).toContain("2 tool calls");
expect(body.getAttribute("aria-hidden")).toBe("true");
expect(body.inert).toBe(true);
head.click();
expect(body.getAttribute("aria-hidden")).toBe("false");
expect(body.classList.contains("is-expanded")).toBe(true);
expect(onExpanded).toHaveBeenCalledWith(true);
const toolHeads = group.querySelectorAll<HTMLButtonElement>(".st-tool-head");
toolHeads[1].click();
expect(toolHeads[0].getAttribute("aria-expanded")).toBe("false");
expect(toolHeads[1].getAttribute("aria-expanded")).toBe("true");
});
}); });
describe("buildTranscriptRow: tool diffs", () => { describe("buildTranscriptRow: tool diffs", () => {
@@ -155,7 +329,7 @@ describe("buildTranscriptRow: tool diffs", () => {
(el.querySelector(".st-tool-head") as HTMLButtonElement).click(); (el.querySelector(".st-tool-head") as HTMLButtonElement).click();
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the // jsdom/happy-dom doesn't compute layout CSS, so assert the class the
// stylesheet keys `overflow-x: auto` off, not a computed style. // stylesheet keys `overflow-x: auto` off, not a computed style.
expect(el.querySelector(".st-diff")!.className).toBe("st-diff"); expect(el.querySelector(".st-diff")!.classList).toContain("st-diff");
}); });
it("renders a visible marker for a removed final newline", () => { it("renders a visible marker for a removed final newline", () => {
@@ -275,105 +449,42 @@ describe("buildTranscriptRow: approval", () => {
}); });
describe("buildTranscriptRow: notice / unknown kinds", () => { describe("buildTranscriptRow: notice / unknown kinds", () => {
it("renders a plan or notice row without crashing", () => { it("renders durable per-turn duration and context usage", () => {
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow(); const el = buildTranscriptRow(
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow(); row("notice", {
}); maestroTurnMeta: {
}); elapsedMs: 72_000,
contextTokens: 18_400,
describe("branchFromWorktreePath", () => { contextDelta: 2_350,
it("takes everything after the last /worktrees/ segment", () => { outcome: "completed",
expect(branchFromWorktreePath("/home/n/tea/maestro/worktrees/acp-bridge")).toBe("acp-bridge"); model: "opus",
}); },
}, "turn:1"),
it("keeps slashes in a branch name that contains them", () => { )!;
expect(branchFromWorktreePath("/repo/worktrees/feature/login")).toBe("feature/login");
}); expect(el.classList.contains("st-turn-meta")).toBe(true);
expect(el.textContent).toContain("1m 12s · +2.4k context tokens · opus");
it("falls back to the basename when there is no /worktrees/ marker", () => { expect(el.getAttribute("aria-label")).toBe("Copy turn details");
expect(branchFromWorktreePath("/some/other/path")).toBe("path"); });
});
}); it("renders notice and unknown row kinds safely", () => {
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
function session(overrides: Partial<Session> = {}): Session { "[notice]",
return { );
id: "s1", const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow;
cwd: "/repo", expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]");
command: "claude", });
env: {},
metadata: {}, it("omits command-catalog and session-info notices used only as UI state", () => {
status: "Running", expect(
created_at: "2026-01-01T00:00:00Z", buildTranscriptRow(
...overrides, row("notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
}; ),
} ).toBeNull();
expect(buildTranscriptRow(row("notice", { sessionUpdate: "session_info_update" }))).toBeNull();
describe("renderRail", () => { });
it("omits every section that has nothing to show", () => {
const el = document.createElement("div"); it("returns no transcript element for a plan row", () => {
renderRail(el, { expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull();
plan: null,
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session(),
});
// Turn always renders (it has a state and a count); the rest are absent.
expect(el.querySelectorAll("h3")).toHaveLength(1);
expect(el.querySelector("h3")!.textContent).toBe("Turn");
});
it("renders the plan with status marks", () => {
const el = document.createElement("div");
renderRail(el, {
plan: {
entries: [
{ content: "done thing", priority: "high", status: "completed" },
{ content: "now thing", priority: "high", status: "in_progress" },
{ content: "later thing", priority: "low", status: "pending" },
],
},
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session(),
});
const items = el.querySelectorAll(".st-plan li");
expect(items).toHaveLength(3);
expect(items[0].className).toBe("st-plan-done");
expect(items[1].className).toBe("st-plan-now");
expect(items[2].className).toBe("st-plan-todo");
});
it("renders context usage and a meter proportional to used/size", () => {
const el = document.createElement("div");
renderRail(el, {
plan: null,
turn: { responding: true, thinking: false, active_tool_calls: ["a"] },
toolRowCount: 3,
usage: { used: 50_000, size: 200_000 },
session: session(),
});
expect(el.querySelector(".st-meter i")!.getAttribute("style")).toContain("25%");
});
it("renders the worktree section only when the session has one", () => {
const el = document.createElement("div");
renderRail(el, {
plan: null,
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session({
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
blocked_work: { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3 },
}),
});
const headers = [...el.querySelectorAll("h3")].map((h) => h.textContent);
expect(headers).toContain("Worktree");
const rows = [...el.querySelectorAll(".st-kv-row")].map((r) => r.textContent);
expect(rows.some((t) => t?.includes("feat/x"))).toBe(true);
expect(rows.some((t) => t?.includes("3"))).toBe(true); // ahead
expect(rows.some((t) => t?.includes("dirty") && t.includes("3"))).toBe(true); // 2+1
}); });
}); });
+484 -179
View File
@@ -4,18 +4,16 @@
// unit-testable without a WebSocket. // unit-testable without a WebSocket.
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { marked, Renderer } from "marked";
import { computeDiffLines, diffStats } from "./structuredDiff.js"; import { computeDiffLines, diffStats } from "./structuredDiff.js";
import type { import type {
AcpContentBlock, AcpContentBlock,
AcpPermissionOption, AcpPermissionOption,
AcpPlan,
AcpToolCallContent, AcpToolCallContent,
AcpToolCallLike, AcpToolCallLike,
AcpUsage,
InTheWay,
Session,
StructuredApprovalDecision, StructuredApprovalDecision,
TranscriptRow, TranscriptRow,
TurnMeta,
} from "./types.js"; } from "./types.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -98,10 +96,7 @@ function contentBlockText(block: AcpContentBlock | undefined): string {
} }
} }
/** A tiny, dependency-free inline renderer: `**bold**`, `*italic*`, and /** Small inline renderer retained for compact labels and focused tests. */
* `` `code` `` become real markup; everything else stays escaped plain
* text. No block-level markdown (lists, headings) — agent prose is
* paragraphs, not documents. */
export function renderInlineMarkdown(text: string): string { export function renderInlineMarkdown(text: string): string {
const escaped = escapeHtml(text); const escaped = escapeHtml(text);
return escaped return escaped
@@ -110,15 +105,88 @@ export function renderInlineMarkdown(text: string): string {
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>"); .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
} }
/** Render assistant output as GFM, then sanitize the complete result before it
* enters the DOM. This gives transcripts proper tables, lists, code blocks and
* links without trusting model-authored HTML or URL attributes. */
export function renderMarkdown(text: string): string {
const renderer = new Renderer();
renderer.html = ({ text: raw }) => escapeHtml(raw);
renderer.image = ({ text: alt }) => escapeHtml(alt ? `[image: ${alt}]` : "[image]");
renderer.link = ({ href, title, tokens }) => {
const label = renderer.parser.parseInline(tokens);
const scheme = /^([a-z][a-z\d+.-]*):/i.exec(href.trim())?.[1]?.toLowerCase();
if (scheme && !["http", "https", "mailto"].includes(scheme)) return label;
const safeTitle = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(href)}"${safeTitle}>${label}</a>`;
};
return marked.parse(text, { async: false, gfm: true, renderer });
}
function renderProse(container: HTMLElement, text: string): void { function renderProse(container: HTMLElement, text: string): void {
const paragraphs = text.split(/\n{2,}/); container.innerHTML = renderMarkdown(text);
for (const para of paragraphs) {
if (!para) continue; for (const table of Array.from(container.querySelectorAll("table"))) {
const p = document.createElement("p"); const scroller = el("div", "st-markdown-table-wrap");
p.innerHTML = renderInlineMarkdown(para); table.replaceWith(scroller);
container.appendChild(p); scroller.appendChild(table);
} }
if (container.children.length === 0) container.textContent = "";
for (const link of container.querySelectorAll("a")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}
async function writeClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand?.("copy") ?? false;
textarea.remove();
if (!copied) throw new Error("clipboard unavailable");
}
}
function makeCopyable(node: HTMLElement, text: string, label: string): void {
if (!text) return;
node.classList.add("st-copyable");
node.tabIndex = 0;
node.setAttribute("role", "button");
node.setAttribute("aria-label", `Copy ${label}`);
node.title = `Copy ${label}`;
const copy = () => {
void writeClipboard(text).then(
() => {
node.dataset.copyState = "copied";
node.setAttribute("aria-label", `${label} copied`);
window.setTimeout(() => {
delete node.dataset.copyState;
node.setAttribute("aria-label", `Copy ${label}`);
}, 1200);
},
() => {
node.dataset.copyState = "error";
},
);
};
node.addEventListener("click", (event) => {
if ((event.target as Element | null)?.closest("a")) return;
copy();
});
node.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copy();
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -128,21 +196,21 @@ function renderProse(container: HTMLElement, text: string): void {
function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement { function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement {
const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined; const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined;
const text = contentBlockText(data?.content); const text = contentBlockText(data?.content);
const displayText = variant === "thought" ? text.trim() : text;
const wrap = el("div", `st-row st-row-${variant}`); const wrap = el("div", `st-row st-row-${variant}`);
const body = el("div", "st-body"); const body = el("div", "st-body");
if (variant === "user") { if (variant === "user") {
const who = el("div", "st-who");
who.textContent = "You";
body.appendChild(who);
const line = el("div", "st-user-text"); const line = el("div", "st-user-text");
line.textContent = text; line.textContent = displayText;
body.appendChild(line); body.appendChild(line);
} else if (variant === "agent") { } else if (variant === "agent") {
renderProse(body, text); renderProse(body, displayText);
} else { } else {
body.textContent = text; body.innerHTML = renderInlineMarkdown(displayText);
} }
wrap.appendChild(body); wrap.appendChild(body);
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
makeCopyable(wrap, displayText, label);
return wrap; return wrap;
} }
@@ -151,6 +219,7 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
const label = el("div", "st-diff-path"); const label = el("div", "st-diff-path");
label.textContent = diff.path; label.textContent = diff.path;
label.title = diff.path; label.title = diff.path;
makeCopyable(label, diff.path, "path");
wrap.appendChild(label); wrap.appendChild(label);
const scroller = el("div", "st-diff"); const scroller = el("div", "st-diff");
const pre = document.createElement("pre"); const pre = document.createElement("pre");
@@ -162,10 +231,253 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
pre.appendChild(document.createTextNode("\n")); pre.appendChild(document.createTextNode("\n"));
} }
scroller.appendChild(pre); scroller.appendChild(pre);
makeCopyable(scroller, pre.textContent ?? "", "diff");
wrap.appendChild(scroller); wrap.appendChild(scroller);
return wrap; return wrap;
} }
function printable(value: unknown): string | null {
if (value === undefined || value === null) return null;
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function jsonPrintable(value: unknown): string | null {
if (value === undefined) return null;
try {
return JSON.stringify(value, null, 2) ?? null;
} catch {
return printable(value);
}
}
function record(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function appendToolDetail(container: HTMLElement, label: string, text: string): void {
const detail = el("div", "st-tool-detail");
const heading = el("div", "st-tool-detail-label");
heading.textContent = label;
const value = document.createElement("pre");
value.textContent = text;
makeCopyable(value, text, label.toLowerCase());
detail.append(heading, value);
container.appendChild(detail);
}
function appendToolValue(container: HTMLElement, label: string, value: unknown, duplicates?: Set<string>): void {
const text = printable(value);
if (text !== null && text !== "" && !duplicates?.has(text)) appendToolDetail(container, label, text);
}
function appendExitCode(container: HTMLElement, value: unknown): void {
if (typeof value !== "number") return;
const line = el("div", "st-tool-fact");
const label = el("span", "st-tool-fact-label");
label.textContent = "Exit code";
const code = el("code", value === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed");
code.textContent = String(value);
line.append(label, code);
container.appendChild(line);
}
function appendCommandPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (typeof input.command !== "string") return false;
appendToolDetail(container, "Command", input.command);
appendToolValue(container, "Working directory", input.cwd);
const result = record(output);
if (result) {
appendToolValue(container, "Output", result.formatted_output ?? result.stdout ?? result.output, contentText);
appendExitCode(container, result.exit_code);
}
return true;
}
function appendMcpPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (typeof input.server !== "string" || typeof input.tool !== "string" || !("arguments" in input)) return false;
appendToolValue(container, "Arguments", input.arguments);
const result = record(output);
if (result) {
appendToolValue(container, "Result", result.result, contentText);
appendToolValue(container, "Error", result.error);
}
return true;
}
function appendSearchPayload(
container: HTMLElement,
input: Record<string, unknown>,
tool: AcpToolCallLike,
): boolean {
if (typeof input.query !== "string" || (tool.kind !== "search" && input.type !== "webSearch")) return false;
appendToolDetail(container, "Query", input.query);
appendToolValue(container, "Action", input.action);
return true;
}
function appendFunctionPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (!("arguments" in input)) return false;
appendToolValue(container, "Arguments", input.arguments);
const result = record(output);
if (result) appendToolValue(container, "Result", result.output, contentText);
return true;
}
function appendKnownToolPayload(
container: HTMLElement,
tool: AcpToolCallLike,
content: AcpToolCallContent[],
): void {
const input = record(tool.rawInput);
if (!input) return;
const contentText = new Set(
content
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
.filter((block) => block.content.type === "text")
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
);
if (appendCommandPayload(container, input, tool.rawOutput, contentText)) return;
if (appendMcpPayload(container, input, tool.rawOutput, contentText)) return;
if (appendSearchPayload(container, input, tool)) return;
appendFunctionPayload(container, input, tool.rawOutput, contentText);
}
function safeMediaSource(
mimeType: string | undefined,
data: string | undefined,
family: "image" | "audio",
): string | null {
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
return `data:${mimeType};base64,${data}`;
}
function appendContentBlock(container: HTMLElement, block: AcpContentBlock): void {
if (block.type === "text") {
if (!block.text) return;
const text = el("div", "st-tool-text");
text.textContent = block.text;
makeCopyable(text, block.text, "output");
container.appendChild(text);
return;
}
if (block.type === "image") {
const source = safeMediaSource(block.mimeType, block.data, "image");
if (source) {
const image = document.createElement("img");
image.className = "st-tool-image";
image.src = source;
image.alt = "Tool output";
image.loading = "lazy";
container.appendChild(image);
return;
}
appendToolValue(container, "Image", block.uri ?? block.mimeType ?? "Unavailable");
return;
}
if (block.type === "audio") {
const source = safeMediaSource(block.mimeType, block.data, "audio");
if (source) {
const audio = document.createElement("audio");
audio.className = "st-tool-audio";
audio.controls = true;
audio.src = source;
container.appendChild(audio);
return;
}
appendToolValue(container, "Audio", block.mimeType ?? "Unavailable");
return;
}
if (block.type === "resource_link") {
const resource = el("div", "st-tool-resource");
const name = el("span", "st-tool-resource-name");
name.textContent = block.title ?? block.name ?? "Resource";
resource.appendChild(name);
if (block.uri) {
const uri = el("code", "st-tool-resource-uri");
uri.textContent = block.uri;
makeCopyable(uri, block.uri, "resource URI");
resource.appendChild(uri);
}
if (block.description) {
const description = el("span", "st-tool-resource-description");
description.textContent = block.description;
resource.appendChild(description);
}
container.appendChild(resource);
return;
}
const resource = block.resource;
if (typeof resource?.text === "string") {
appendToolDetail(container, resource.uri ? `Resource · ${resource.uri}` : "Resource", resource.text);
} else {
appendToolValue(container, "Resource", resource);
}
}
function appendStandardContent(container: HTMLElement, content: AcpToolCallContent[]): void {
for (const block of content) {
if (block.type === "content") appendContentBlock(container, block.content);
if (block.type === "diff") container.appendChild(buildDiffBlock(block));
if (block.type === "terminal") {
const note = el("div", "st-tool-terminal");
note.textContent = block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal";
container.appendChild(note);
}
}
}
function appendRawPayload(container: HTMLElement, tool: AcpToolCallLike): void {
const input = jsonPrintable(tool.rawInput);
const output = jsonPrintable(tool.rawOutput);
if (input === null && output === null) return;
const disclosure = document.createElement("details");
disclosure.className = "st-tool-raw";
const summary = document.createElement("summary");
summary.textContent = "Raw JSON";
disclosure.appendChild(summary);
const body = el("div", "st-tool-raw-body");
if (input !== null) appendToolDetail(body, "Input", input);
if (output !== null) appendToolDetail(body, "Output", output);
disclosure.appendChild(body);
container.appendChild(disclosure);
}
function setDisclosure(element: HTMLElement, expanded: boolean, animate = false): void {
element.hidden = false;
if (animate && expanded && !element.classList.contains("is-expanded")) {
// Establish the collapsed grid track before opening it in the same event
// turn. Without this read, a freshly lazy-built tool body has no painted
// starting state and the browser quite reasonably skips the transition.
void element.offsetHeight;
}
element.classList.toggle("is-expanded", expanded);
element.setAttribute("aria-hidden", String(!expanded));
element.inert = !expanded;
}
/** `+added -removed` across every diff block in a tool call's content — /** `+added -removed` across every diff block in a tool call's content —
* real derived data, never a fabricated duration. */ * real derived data, never a fabricated duration. */
function toolDiffMeta(content: AcpToolCallContent[]): string { function toolDiffMeta(content: AcpToolCallContent[]): string {
@@ -187,7 +499,11 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
return null; return null;
} }
function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement { function buildToolRow(
row: TranscriptRow,
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
): HTMLElement {
const tool = (row.data ?? {}) as AcpToolCallLike; const tool = (row.data ?? {}) as AcpToolCallLike;
const content = tool.content ?? []; const content = tool.content ?? [];
const hasDiff = content.some((c) => c.type === "diff"); const hasDiff = content.some((c) => c.type === "diff");
@@ -224,55 +540,142 @@ function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
} }
card.appendChild(head); card.appendChild(head);
const out = el("div", "st-tool-out"); const out = el("div", "st-tool-out st-disclosure");
const outClip = el("div", "st-disclosure-clip");
const outInner = el("div", "st-disclosure-inner");
outClip.appendChild(outInner);
out.appendChild(outClip);
out.hidden = true; out.hidden = true;
let outBuilt = false; let outBuilt = false;
const buildOut = () => { const buildOut = () => {
if (outBuilt) return; if (outBuilt) return;
outBuilt = true; outBuilt = true;
const texts = content appendStandardContent(outInner, content);
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content") appendKnownToolPayload(outInner, tool, content);
.map((c) => contentBlockText(c.content)) appendRawPayload(outInner, tool);
.filter(Boolean);
if (texts.length > 0) {
const textPanel = el("div", "st-tool-text");
textPanel.textContent = texts.join("\n");
out.appendChild(textPanel);
}
for (const block of content) {
if (block.type === "diff") out.appendChild(buildDiffBlock(block));
if (block.type === "terminal") {
const note = el("div", "st-tool-text");
note.textContent = "[embedded terminal]";
out.appendChild(note);
}
}
if (out.children.length === 0) out.hidden = true;
}; };
card.appendChild(out); card.appendChild(out);
const setExpanded = (nextExpanded: boolean) => { const setExpanded = (nextExpanded: boolean, animate = false) => {
if (nextExpanded) buildOut(); if (nextExpanded) buildOut();
if (out.children.length === 0) nextExpanded = false; if (outInner.children.length === 0) nextExpanded = false;
head.setAttribute("aria-expanded", String(nextExpanded)); head.setAttribute("aria-expanded", String(nextExpanded));
out.hidden = !nextExpanded; if (outInner.children.length === 0) {
out.hidden = true;
} else {
setDisclosure(out, nextExpanded, animate);
}
}; };
head.addEventListener("click", () => { head.addEventListener("click", () => {
setExpanded(head.getAttribute("aria-expanded") !== "true"); setExpanded(head.getAttribute("aria-expanded") !== "true", true);
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
}); });
setExpanded(expanded); setExpanded(expanded);
return wrap; return wrap;
} }
export function buildToolGroup(
id: string,
rows: TranscriptRow[],
children: HTMLElement[],
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
): HTMLElement {
const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id;
const head = document.createElement("button");
head.type = "button";
head.className = "st-tool-group-head";
head.setAttribute("aria-expanded", String(expanded));
const glyph = el("span", "st-tool-group-glyph");
glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title");
title.textContent = `${rows.length} tool calls`;
head.append(glyph, title);
const body = el("div", "st-tool-group-body st-disclosure");
const bodyClip = el("div", "st-disclosure-clip");
const bodyInner = el("div", "st-disclosure-inner");
bodyInner.append(...children);
bodyClip.appendChild(bodyInner);
body.appendChild(bodyClip);
setDisclosure(body, expanded);
head.addEventListener("click", () => {
expanded = !expanded;
head.setAttribute("aria-expanded", String(expanded));
setDisclosure(body, expanded, true);
onExpandedChange?.(expanded);
});
wrap.append(head, body);
return wrap;
}
function buildPlainRow(row: TranscriptRow): HTMLElement { function buildPlainRow(row: TranscriptRow): HTMLElement {
const wrap = el("div", "st-row st-row-notice"); const wrap = el("div", "st-row st-row-notice");
const body = el("div", "st-body"); const body = el("div", "st-body");
body.textContent = `[${row.kind}]`; body.textContent = `[${row.kind}]`;
wrap.appendChild(body); wrap.appendChild(body);
makeCopyable(wrap, body.textContent, "notice");
return wrap; return wrap;
} }
function compactNumber(value: number): string {
if (Math.abs(value) < 1_000) return String(value);
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
}
export function formatElapsed(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
}
export function formatContextTokens(value: number): string {
return `${compactNumber(value)} context`;
}
function buildTurnMetaRow(meta: TurnMeta): HTMLElement {
const wrap = el("div", `st-turn-meta st-turn-meta-${meta.outcome}`);
wrap.setAttribute("aria-label", `Turn ${meta.outcome}`);
const parts = [formatElapsed(meta.elapsedMs)];
if (typeof meta.contextDelta === "number") {
const sign = meta.contextDelta > 0 ? "+" : "";
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
} else if (typeof meta.contextTokens === "number") {
parts.push(formatContextTokens(meta.contextTokens));
}
if (meta.model) parts.push(meta.model);
if (meta.outcome !== "completed") parts.push(meta.outcome);
const dot = el("span", "st-turn-meta-dot");
dot.setAttribute("aria-hidden", "true");
const text = el("span");
text.textContent = parts.join(" · ");
wrap.append(dot, text);
makeCopyable(wrap, text.textContent, "turn details");
return wrap;
}
function turnMeta(row: TranscriptRow): TurnMeta | null {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
if (!value || typeof value !== "object") return null;
const meta = value as Partial<TurnMeta>;
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
return meta as TurnMeta;
}
function isStateOnlyNotice(row: TranscriptRow): boolean {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
return update === "available_commands_update" || update === "session_info_update";
}
export interface ApprovalRowState { export interface ApprovalRowState {
pending: boolean; pending: boolean;
/** Resolves once the decision has been submitted; rejects when the POST /** Resolves once the decision has been submitted; rejects when the POST
@@ -362,13 +765,39 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
return wrap; return wrap;
} }
/** Build the DOM for one transcript row. `approval` is consulted only for an /** Build the DOM for one transcript row, or `null` for state-only plan rows.
* `approval` row; pass it whenever the row might be that kind. */ * `approval` is consulted only for an `approval` row; tool
* expansion is supplied and reported explicitly. */
export function buildTranscriptRow(
row: TranscriptRow & { kind: "plan" },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): null;
export function buildTranscriptRow(
row: TranscriptRow & { kind: "notice" },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null;
export function buildTranscriptRow(
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan" | "notice"> },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement;
export function buildTranscriptRow(
row: TranscriptRow,
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null;
export function buildTranscriptRow( export function buildTranscriptRow(
row: TranscriptRow, row: TranscriptRow,
approval?: ApprovalRowState, approval?: ApprovalRowState,
toolExpanded = false, toolExpanded = false,
): HTMLElement { onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null {
let built: HTMLElement; let built: HTMLElement;
switch (row.kind) { switch (row.kind) {
case "user": case "user":
@@ -382,11 +811,17 @@ export function buildTranscriptRow(
break; break;
case "tool_call": case "tool_call":
case "tool_diff": case "tool_diff":
built = buildToolRow(row, toolExpanded); built = buildToolRow(row, toolExpanded, onToolExpandedChange);
break; break;
case "approval": case "approval":
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() }); built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
break; break;
case "plan":
return null;
case "notice":
if (isStateOnlyNotice(row)) return null;
built = turnMeta(row) ? buildTurnMetaRow(turnMeta(row)!) : buildPlainRow(row);
break;
default: default:
built = buildPlainRow(row); built = buildPlainRow(row);
break; break;
@@ -394,133 +829,3 @@ export function buildTranscriptRow(
built.dataset.rowId = row.id; built.dataset.rowId = row.id;
return built; return built;
} }
// ---------------------------------------------------------------------------
// rail
// ---------------------------------------------------------------------------
const PLAN_MARK: Record<string, string> = { completed: "✓", in_progress: "▸", pending: "·" };
const PLAN_CLASS: Record<string, string> = { completed: "done", in_progress: "now", pending: "todo" };
function renderPlanSection(plan: AcpPlan | null | undefined): HTMLElement | null {
if (!plan || plan.entries.length === 0) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Plan";
section.appendChild(h);
const list = document.createElement("ul");
list.className = "st-plan";
for (const entry of plan.entries) {
const li = document.createElement("li");
li.className = `st-plan-${PLAN_CLASS[entry.status] ?? "todo"}`;
li.setAttribute("aria-label", `${entry.status.replace("_", " ")}: ${entry.content}`);
const mark = el("span", "st-plan-mark");
mark.textContent = PLAN_MARK[entry.status] ?? "·";
mark.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.textContent = entry.content;
li.append(mark, text);
list.appendChild(li);
}
section.appendChild(list);
return section;
}
function kv(label: string, value: string): HTMLElement {
const row = el("div", "st-kv-row");
const k = el("span", "st-kv-k");
k.textContent = label;
const v = el("span", "st-kv-v");
v.textContent = value;
row.append(k, v);
return row;
}
function renderTurnSection(
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] },
toolRowCount: number,
usage: AcpUsage | null | undefined,
): HTMLElement {
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Turn";
section.appendChild(h);
const box = el("div", "st-kv");
const activity = turn.thinking ? "thinking" : turn.responding ? "responding" : "idle";
box.appendChild(kv("state", activity));
box.appendChild(kv("active tools", String(turn.active_tool_calls.length)));
box.appendChild(kv("tool calls", String(toolRowCount)));
if (usage?.cost) box.appendChild(kv("cost", `$${usage.cost.amount.toFixed(2)}`));
section.appendChild(box);
return section;
}
function renderContextSection(usage: AcpUsage | null | undefined): HTMLElement | null {
if (!usage) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Context";
section.appendChild(h);
const box = el("div", "st-kv");
box.appendChild(kv("used", formatTokenCount(usage.used)));
box.appendChild(kv("window", formatTokenCount(usage.size)));
section.appendChild(box);
const meter = el("div", "st-meter");
const fill = el("i");
const pct = usage.size > 0 ? Math.min(100, (usage.used / usage.size) * 100) : 0;
fill.style.width = `${pct}%`;
meter.appendChild(fill);
section.appendChild(meter);
return section;
}
function formatTokenCount(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
/** `SessionWorktree.path` is `<repo>/worktrees/<branch>` (docs/WORKTREES.md);
* the branch is not carried anywhere else on the wire, so derive it here. */
export function branchFromWorktreePath(path: string): string {
const marker = "/worktrees/";
const i = path.indexOf(marker);
if (i >= 0) return path.slice(i + marker.length);
return path.split("/").filter(Boolean).pop() ?? path;
}
function renderWorktreeSection(session: Session | undefined): HTMLElement | null {
const worktree = session?.worktree;
if (!worktree) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Worktree";
section.appendChild(h);
const box = el("div", "st-kv");
box.appendChild(kv("branch", branchFromWorktreePath(worktree.path)));
const blocked: InTheWay | null | undefined = session?.blocked_work;
box.appendChild(kv("ahead", String(blocked?.unpushed_commits ?? 0)));
const dirty = (blocked?.uncommitted_files ?? 0) + (blocked?.untracked_files ?? 0);
box.appendChild(kv("dirty", String(dirty)));
section.appendChild(box);
return section;
}
export interface RailContext {
plan: AcpPlan | null | undefined;
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] };
toolRowCount: number;
usage: AcpUsage | null | undefined;
session: Session | undefined;
}
/** Rebuild the rail wholesale from the latest state — rails are small, so a
* full rebuild per update is simpler and cheap enough that no incremental
* patching is worth the complexity. */
export function renderRail(container: HTMLElement, ctx: RailContext): void {
const sections = [
renderPlanSection(ctx.plan),
renderTurnSection(ctx.turn, ctx.toolRowCount, ctx.usage),
renderContextSection(ctx.usage),
renderWorktreeSection(ctx.session),
].filter((s): s is HTMLElement => s !== null);
container.replaceChildren(...sections);
}
+812 -136
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { splitCommand, toolIconSvg } from "./toolIcon.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js";
import commandResolutionFixture from "../../fixtures/command-resolution.json";
interface CommandResolutionCase {
command: string;
program: string;
tool: string | null;
}
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => { describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
it("recognises the agents and shells sessions are spawned with", () => { it("recognises the agents and shells sessions are spawned with", () => {
@@ -46,6 +53,14 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
expect(splitCommand("").tool).toBeNull(); expect(splitCommand("").tool).toBeNull();
}); });
it("resolves every case in the shared command-resolution fixture (#199)", () => {
const cases = commandResolutionFixture.cases as CommandResolutionCase[];
expect(cases.length).toBeGreaterThan(0);
for (const { command, program, tool } of cases) {
expect(splitCommand(command), command).toEqual({ tool, program });
}
});
it("gives every recognised tool a labelled mark", () => { it("gives every recognised tool a labelled mark", () => {
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) { for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
const svg = toolIconSvg(tool); const svg = toolIconSvg(tool);
+428 -23
View File
@@ -9,10 +9,11 @@
* as text, so the fragment never renders a mark that stands for the wrong tool. * as text, so the fragment never renders a mark that stands for the wrong tool.
*/ */
/** Command wrappers that run another program: the tool is what follows. */ /** Program name (already basenamed and lowercased) → icon id.
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]); *
* `claude-code-acp` — the daemon's actual structured-session child — is
/** Program name (already basenamed and lowercased) → icon id. */ * deliberately absent: the daemon launches it directly and ignores the
* session's command, so it is never itself a valid user-facing launch form. */
const BY_PROGRAM: Record<string, string> = { const BY_PROGRAM: Record<string, string> = {
claude: "claude", claude: "claude",
"claude-code": "claude", "claude-code": "claude",
@@ -53,39 +54,443 @@ export interface CommandParts {
program: string; program: string;
} }
/** Split a command line into the tool its icon stands for and the program name. /** Split a command line into the tool its icon stands for and the program
* name — basenamed and stripped of its arguments. Leading `FOO=bar`
* assignments and wrapper forms (`sudo`, `env -i`, `npx`, `pnpm dlx`, …) are
* looked through, and quoting/escaping is honoured (never evaluated — no
* `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a shell would need real
* grammar for (pipelines, `&&`/`;`, redirections, substitution, an
* unterminated quote) falls back to the naive first word rather than
* guessing through it (#199). Mirrors the Rust `command_program`/
* `command_tool` in `crates/maestro-protocol/src/display.rs`.
* *
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised * `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps `hx` as the program with no tool. */ * `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts { export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean); const words = tokenize(command);
let i = 0; if (words === null) {
// Leading `FOO=bar` assignments and wrappers stand in front of the real const program = legacyFirstWord(command);
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped. return { tool: null, program };
while (i < words.length) {
const word = words[i];
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
i += 1;
continue;
}
const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(wrapper)) {
i += 1;
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue;
}
break;
} }
const i = programIndex(words);
if (i >= words.length) return { tool: null, program: command.trim() }; if (i >= words.length) return { tool: null, program: command.trim() };
const program = basename(words[i]); const program = basename(words[i].text);
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program }; return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
} }
/** The naive "first whitespace word, basenamed" reading used only when
* {@link tokenize} rejects the line outright — still something sane to
* show, never a wrapper/tool match to act on. */
function legacyFirstWord(command: string): string {
const first = command.trim().split(/\s+/).filter(Boolean)[0];
return first === undefined ? command.trim() : basename(first);
}
/** One token from {@link tokenize}: its dequoted text, and — when both the
* name and `=` were entirely unquoted/unescaped in the source — the index of
* the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
* makes the word an executable named `FOO=1`; quoting/escaping only the value
* preserves the assignment, matching Bash's assignment-word grammar (#199). */
interface Word {
text: string;
plainEq: number | null;
}
/** `FOO=bar`, the shell's own prefix for one command's environment —
* recognised only when the `=` came from plain, unquoted/unescaped source
* text (#199). */
function isEnvAssignment(word: Word): boolean {
if (word.plainEq === null) return false;
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(word.text.slice(0, word.plainEq));
}
/** Walk `words` past leading `FOO=bar` assignments and known wrapper forms
* (interleaved in any order/count) to the index of the actual program. An
* assignment run is only consumed at the very start of the line or right
* after a wrapper whose own semantics accept one there ({@link Wrapper.acceptsAssignment}) —
* `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
* skipping to `claude`, since `command`/`nohup` just exec their next argument
* as the program, they don't setenv from an assignment-shaped one (#199). */
function programIndex(words: Word[]): number {
const texts = words.map((w) => w.text);
let i = 0;
let assignmentAllowed = true;
for (;;) {
if (assignmentAllowed) {
while (i < words.length && isEnvAssignment(words[i])) {
i += 1;
}
}
if (i >= words.length) break;
const wrapper = basename(texts[i]).toLowerCase();
const w = WRAPPER_RESOLVERS[wrapper];
if (!w) break;
const next = w.resolve(texts, i);
// Not a form of the wrapper we recognise (an unknown option, or — for
// `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it): the wrapper
// word itself is the program, not a launcher.
if (next === null || next <= i) break;
i = next;
assignmentAllowed = w.acceptsAssignment;
}
return i;
}
function basename(word: string): string { function basename(word: string): string {
const cut = word.lastIndexOf("/"); const cut = word.lastIndexOf("/");
return cut >= 0 ? word.slice(cut + 1) : word; return cut >= 0 ? word.slice(cut + 1) : word;
} }
/** Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
* `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
* `null` the moment it needs real shell grammar this resolver doesn't
* attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
* substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
* separator, like `;` — #199), or an unterminated quote/escape. Never
* expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
* backslash immediately before a newline, quoted or not, is a line
* continuation: removed entirely rather than embedding the newline or
* ending the word (#199). */
function tokenize(command: string): Word[] | null {
const words: Word[] = [];
let current = "";
let hasCurrent = false;
let plainEq: number | null = null;
let assignmentNameIsPlain = true;
let i = 0;
const n = command.length;
while (i < n) {
const c = command[i];
if (c === " " || c === "\t" || c === "\r") {
if (hasCurrent) {
words.push({ text: current, plainEq });
current = "";
hasCurrent = false;
plainEq = null;
assignmentNameIsPlain = true;
}
i += 1;
} else if (c === "\n") {
return null;
} else if ("|&;<>()`".includes(c)) {
return null;
} else if (c === "$" && command[i + 1] === "(") {
return null;
} else if (c === "'") {
hasCurrent = true;
if (plainEq === null) assignmentNameIsPlain = false;
i += 1;
const close = command.indexOf("'", i);
if (close === -1) return null; // unterminated single quote
current += command.slice(i, close);
i = close + 1;
} else if (c === '"') {
hasCurrent = true;
if (plainEq === null) assignmentNameIsPlain = false;
i += 1;
for (;;) {
if (i >= n) return null; // unterminated double quote
const c2 = command[i];
if (c2 === '"') {
i += 1;
break;
}
if (c2 === "\\") {
const escaped = command[i + 1];
if (escaped === undefined) return null; // trailing backslash
if (escaped === "\n") {
i += 2; // line continuation, removed entirely
continue;
}
current += '"\\$`'.includes(escaped) ? escaped : "\\" + escaped;
i += 2;
} else {
current += c2;
i += 1;
}
}
} else if (c === "\\") {
const escaped = command[i + 1];
if (escaped === undefined) return null; // trailing backslash
if (escaped === "\n") {
i += 2; // line continuation, removed entirely
continue;
}
hasCurrent = true;
if (plainEq === null) assignmentNameIsPlain = false;
current += escaped;
i += 2;
} else {
hasCurrent = true;
if (c === "=" && plainEq === null && assignmentNameIsPlain) {
plainEq = current.length;
}
current += c;
i += 1;
}
}
if (hasCurrent) words.push({ text: current, plainEq });
return words;
}
/** A wrapper's known `-`/`--` options: which take no value, and which
* consume either a following word or a `--flag=value` as theirs. A
* `-`-prefixed word this table doesn't know halts resolution rather than
* guessing past it (#199) — callers read that as {@link consumeOptions}
* returning `null`. */
interface OptionTable {
flags: Set<string>;
valued: Set<string>;
/** Whether this wrapper's short options may be combined (`-iv`) or take
* an attached value (`-uroot`), the way real getopt-based tools do
* (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/`dlx`)
* don't get this — guessing at bundling they don't actually support
* would resolve through options they'd reject outright. */
bundlesShortOptions: boolean;
}
/** `-iv` (bundled boolean short flags) or `-uroot` (a valued short option
* with its value attached) — getopt's traditional short-option forms.
* `false` the moment a character isn't a short option this table knows, or
* a valued one ends the word with no attached value left to be its operand
* (#199: rejected rather than guessed to come from the next word instead). */
function matchesShortCluster(opts: OptionTable, word: string): boolean {
let idx = 1;
while (idx < word.length) {
const short = "-" + word[idx];
if (opts.flags.has(short)) {
idx += 1;
continue;
}
if (opts.valued.has(short)) {
return idx + 1 < word.length;
}
return false;
}
return true;
}
/** Consume `opts`'s options starting at `words[i]`, returning the index just
* past them (which may equal `i` if there are none) — or `null` on an
* option this table doesn't recognise, or a valued option missing its
* operand. */
function consumeOptions(opts: OptionTable, words: string[], i: number): number | null {
for (;;) {
const word = words[i];
if (word === undefined) return i;
if (word === "--") return i + 1;
if (word === "-" || !word.startsWith("-")) return i;
const eq = word.indexOf("=");
if (eq >= 0) {
if (opts.valued.has(word.slice(0, eq)) && word.slice(eq + 1).length > 0) {
i += 1;
continue;
}
return null;
}
if (opts.flags.has(word)) {
i += 1;
continue;
}
if (opts.valued.has(word)) {
const value = words[i + 1];
if (value === undefined || value.length === 0 || value.startsWith("-")) return null;
i += 2;
continue;
}
if (
opts.bundlesShortOptions &&
!word.startsWith("--") &&
word.length > 2 &&
matchesShortCluster(opts, word)
) {
i += 1;
continue;
}
return null;
}
}
const NO_OPTIONS: OptionTable = { flags: new Set(), valued: new Set(), bundlesShortOptions: false };
const ENV_OPTS: OptionTable = {
flags: new Set(["-i", "--ignore-environment", "-0", "--null", "-v", "--debug"]),
valued: new Set(["-u", "--unset", "-C", "--chdir"]),
bundlesShortOptions: true,
};
const SUDO_OPTS: OptionTable = {
flags: new Set([
"-i",
"--login",
"-n",
"--non-interactive",
"-E",
"--preserve-env",
"-H",
"--set-home",
"-A",
"--askpass",
"-b",
"--background",
"-k",
"--reset-timestamp",
"-K",
"--remove-timestamp",
"-S",
"--stdin",
]),
valued: new Set(["-u", "--user", "-g", "--group"]),
bundlesShortOptions: true,
};
/** `-v`/`-V` only print what would run — they never execute it — so
* resolving through them would claim a program runs that `command` never
* launches (#199). Only `-p`, which still executes normally, is safe to
* look through. */
const COMMAND_BUILTIN_OPTS: OptionTable = {
flags: new Set(["-p"]),
valued: new Set(),
bundlesShortOptions: true,
};
/** The bash *keyword* `time` only ever parses `-p` (POSIX output format);
* `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
* `/usr/bin/time`, a different program a bare `time` word doesn't
* necessarily name. Assuming them here would be guessing which `time`
* resolves (#199). */
function resolveTime(words: string[], i: number): number | null {
let next = i + 1;
if (words[next] === "-p") next += 1;
if (words[next] === "--") next += 1;
const command = words[next];
return command?.startsWith("-") ? null : next;
}
/** `-c`/`--call` embeds a whole shell command string rather than naming the
* program positionally — resolving past it would mean guessing at a
* program buried inside that string, so it's left unrecognised and
* resolution stops at `npx` (#199). */
const NPX_OPTS: OptionTable = {
flags: new Set(["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"]),
valued: new Set(["-p", "--package", "--shell"]),
bundlesShortOptions: false,
};
const PNPM_OPTS: OptionTable = {
flags: new Set(["--silent", "-s", "--stream", "--aggregate-output", "--no-progress"]),
// `--package`/`--package=value` is documented in this pre-`dlx` position
// (`pnpm --package=x dlx y`), alongside the identical flag `dlx` itself
// also accepts (`PNPM_DLX_OPTS`).
valued: new Set(["-C", "--dir", "--filter", "--package"]),
bundlesShortOptions: false,
};
const YARN_OPTS: OptionTable = {
flags: new Set(["--silent", "-s", "--no-progress", "--non-interactive"]),
valued: new Set(["--cwd"]),
bundlesShortOptions: false,
};
const PNPM_DLX_OPTS: OptionTable = {
flags: new Set(["-s", "--silent"]),
valued: new Set(["--package"]),
bundlesShortOptions: false,
};
const YARN_DLX_OPTS: OptionTable = {
flags: new Set(["-q", "--quiet"]),
valued: new Set(["-p", "--package"]),
bundlesShortOptions: false,
};
const BUN_X_OPTS: OptionTable = {
flags: new Set(["--bun", "--no-install", "--verbose", "--silent"]),
valued: new Set(["-p", "--package"]),
bundlesShortOptions: false,
};
/** `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
* — without it, the next word is a `package.json` script name, not a
* package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
* itself takes launcher-specific options too, consumed before the executable. */
function resolveDlx(
opts: OptionTable,
dlxOpts: OptionTable,
words: string[],
i: number,
): number | null {
const j = consumeOptions(opts, words, i + 1);
if (j === null) return null;
if (words[j] !== "dlx") return null;
return consumeOptions(dlxOpts, words, j + 1);
}
/** `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
* (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
* subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
* the executable. */
function resolveBun(words: string[], i: number): number | null {
if (words[i + 1] !== "x") return null;
return consumeOptions(BUN_X_OPTS, words, i + 2);
}
/** A resolver for one wrapper form: given the tokenized command and the
* index of the wrapper word itself, the index of the word after it (past
* its options and any subcommand it requires), or `null` if this occurrence
* isn't a form we resolve through. */
type WrapperResolver = (words: string[], i: number) => number | null;
/** One recognised wrapper form: how to walk past it, and whether an
* assignment word is a valid follow-on once it's been walked past. */
interface Wrapper {
resolve: WrapperResolver;
/** `true` only for wrappers that themselves interpret a following
* `FOO=bar` as an environment assignment for the command they launch
* (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
* grammar for a brand new simple command (`time`). Every other wrapper here
* is a plain executable that execs its next argument as the program
* outright — an assignment-shaped one names a program literally called
* `FOO=1`, not an env var (#199). */
acceptsAssignment: boolean;
}
const WRAPPER_RESOLVERS: Record<string, Wrapper> = {
env: { resolve: (words, i) => consumeOptions(ENV_OPTS, words, i + 1), acceptsAssignment: true },
sudo: {
resolve: (words, i) => consumeOptions(SUDO_OPTS, words, i + 1),
acceptsAssignment: true,
},
command: {
resolve: (words, i) => consumeOptions(COMMAND_BUILTIN_OPTS, words, i + 1),
acceptsAssignment: false,
},
nohup: {
resolve: (words, i) => consumeOptions(NO_OPTIONS, words, i + 1),
acceptsAssignment: false,
},
time: { resolve: resolveTime, acceptsAssignment: true },
npx: {
resolve: (words, i) => consumeOptions(NPX_OPTS, words, i + 1),
acceptsAssignment: false,
},
pnpm: {
resolve: (words, i) => resolveDlx(PNPM_OPTS, PNPM_DLX_OPTS, words, i),
acceptsAssignment: false,
},
yarn: {
resolve: (words, i) => resolveDlx(YARN_OPTS, YARN_DLX_OPTS, words, i),
acceptsAssignment: false,
},
bun: { resolve: resolveBun, acceptsAssignment: false },
bunx: {
resolve: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
acceptsAssignment: false,
},
uvx: { resolve: (_words, i) => i + 1, acceptsAssignment: false },
};
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */ /** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
function burst(): string { function burst(): string {
const rays = Array.from({ length: 8 }, (_, k) => { const rays = Array.from({ length: 8 }, (_, k) => {
+62 -7
View File
@@ -56,7 +56,7 @@ export interface Session {
* how the command bar's empty query sorts a never-focused session after every * how the command bar's empty query sorts a never-focused session after every
* session that has one (`docs/COMMAND-BAR.md`). */ * session that has one (`docs/COMMAND-BAR.md`). */
last_focused_at?: IsoTimestamp | null; last_focused_at?: IsoTimestamp | null;
/** The ACP session id the `claude-code-acp` adapter returned from /** The ACP session id the selected provider adapter returned from
* `session/new` (#134). Only a `Structured` session whose handshake * `session/new` (#134). Only a `Structured` session whose handshake
* succeeded has one; a respawn passes it to `session/load` (#135). */ * succeeded has one; a respawn passes it to `session/load` (#135). */
acp_session_id?: string | null; acp_session_id?: string | null;
@@ -422,6 +422,8 @@ export interface TurnState {
responding: boolean; responding: boolean;
thinking: boolean; thinking: boolean;
active_tool_calls: string[]; active_tool_calls: string[];
started_at?: string | null;
started_context_tokens?: number | null;
} }
/** A whole-session view derived from ACP notifications. `plan`/`usage`/ /** A whole-session view derived from ACP notifications. `plan`/`usage`/
@@ -436,6 +438,7 @@ export interface ReducedState {
context?: AcpUsage | null; context?: AcpUsage | null;
model?: string | null; model?: string | null;
mode?: string | null; mode?: string | null;
config_options?: { configOptions: AcpSessionConfigOption[] } | null;
} }
/** Server messages on `WS /api/structured/:id`. */ /** Server messages on `WS /api/structured/:id`. */
@@ -449,20 +452,61 @@ export interface StructuredPromptRequest {
prompt: string; prompt: string;
} }
export interface StructuredConfigRequest {
config_id: string;
value: string | boolean;
}
export interface AcpSessionConfigChoice {
value: string;
name: string;
description?: string | null;
}
export interface AcpSessionConfigGroup {
group: string;
name: string;
options: AcpSessionConfigChoice[];
}
export interface AcpSessionConfigOption {
id: string;
name: string;
description?: string | null;
category?: string | null;
type: "select" | "boolean" | string;
currentValue: string | boolean;
options?: Array<AcpSessionConfigChoice | AcpSessionConfigGroup>;
}
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON /** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
* string (a bracketless Rust enum), not `{decision: ...}`. */ * string (a bracketless Rust enum), not `{decision: ...}`. */
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny"; export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ---- // ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a export interface AcpEmbeddedResource {
* generic placeholder rather than being dropped silently. */ uri?: string;
mimeType?: string;
text?: string;
blob?: string;
}
/** `ContentBlock`, including the fields needed to render typed tool output. */
export type AcpContentBlock = export type AcpContentBlock =
| { type: "text"; text: string } | { type: "text"; text: string }
| { type: "image"; mimeType?: string } | { type: "image"; data?: string; mimeType?: string; uri?: string | null }
| { type: "audio"; mimeType?: string } | { type: "audio"; data?: string; mimeType?: string }
| { type: "resource_link"; uri?: string; name?: string } | {
| { type: "resource"; resource?: unknown }; type: "resource_link";
uri?: string;
name?: string;
title?: string | null;
description?: string | null;
mimeType?: string | null;
size?: number | null;
}
| { type: "resource"; resource?: AcpEmbeddedResource };
/** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */ /** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */
export interface AcpContentChunk { export interface AcpContentChunk {
@@ -543,6 +587,17 @@ export interface AcpUsage {
cost?: { amount: number; currency: string } | null; cost?: { amount: number; currency: string } | null;
} }
/** Daemon-owned durable footer appended when one prompt turn settles. ACP
* only reports context occupancy, so `contextDelta` is not a billing-token
* count and may be negative when the provider compacts the context. */
export interface TurnMeta {
elapsedMs: number;
contextTokens?: number | null;
contextDelta?: number | null;
outcome: "completed" | "cancelled" | "failed" | string;
model?: string | null;
}
export interface AcpPermissionOption { export interface AcpPermissionOption {
optionId: string; optionId: string;
name: string; name: string;
+8
View File
@@ -0,0 +1,8 @@
{
// The nix derivation's src is `./web`, so the shared fixtures/ directory at
// the repo root does not exist there and `*.test.ts` cannot resolve its
// imports. Tests are not part of the shipped bundle; `pnpm typecheck` still
// checks them from a full checkout.
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}