Compare commits

...
Author SHA1 Message Date
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 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 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
44 changed files with 2245 additions and 362 deletions
Generated
+1
View File
@@ -1467,6 +1467,7 @@ dependencies = [
"maestro-cli",
"maestro-daemon",
"maestro-tui",
"tempfile",
"tokio",
"tracing-subscriber",
]
+1 -1
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
repository.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]
agent-client-protocol.workspace = true
+106 -37
View File
@@ -1,5 +1,5 @@
//! ACP client role for the daemon (#134): one `claude-code-acp` child per
//! structured session.
//! ACP client role for the daemon: one provider adapter child per structured
//! session.
//!
//! 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
@@ -9,10 +9,10 @@
//!
//! **The handshake declares almost nothing.** `initialize` reports
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the
//! daemon to act on its behalf — exactly what already happens in the
//! terminal view. `session/request_permission` is the one client method that
//! gets a real implementation, in #139; here it is a stub that declines.
//! adapter uses the provider's own file and command tools instead of asking
//! the daemon to act on its behalf — exactly what already happens in the
//! terminal view. `session/request_permission` is the one client method with
//! a real implementation.
//!
//! Every `session/update` notification the adapter sends lands on an
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
@@ -58,17 +58,46 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema;
/// The adapter binary, resolved on `PATH`.
pub const ADAPTER_BIN: &str = "claude-code-acp";
/// ACP adapter selected from the structured session's resolved command.
#[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
/// operator staring at a failed session knows what to run.
impl AcpAdapter {
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 =
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
/// How long `initialize` + `session/new` may take before the spawn is
/// declared failed. The real adapter boots a node process and the Claude
/// Code SDK behind it; a slow machine takes seconds, never a minute.
/// declared failed. The real adapters boot a node process and their provider
/// client behind it; a slow machine takes seconds, never a minute.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
@@ -166,6 +195,8 @@ pub struct SpawnConfig {
/// instead of opening a fresh one. The adapter must advertise
/// `loadSession`; the handshake fails otherwise.
pub load_session: Option<String>,
/// The provider-specific adapter to launch.
pub adapter: AcpAdapter,
}
impl SpawnConfig {
@@ -175,6 +206,7 @@ impl SpawnConfig {
env,
handshake_timeout: HANDSHAKE_TIMEOUT,
load_session: None,
adapter: AcpAdapter::ClaudeCode,
}
}
@@ -183,6 +215,11 @@ impl SpawnConfig {
self.load_session = Some(session_id.into());
self
}
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
self.adapter = adapter;
self
}
}
/// A blocked ACP permission request delivered to the daemon.
@@ -198,31 +235,39 @@ pub struct PermissionRequest {
pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command
/// so the failed session is actionable rather than silently dead.
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")]
AdapterMissing,
#[error("failed to start {ADAPTER_BIN}: {0}")]
Spawn(#[source] std::io::Error),
#[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
AdapterMissing { adapter: AcpAdapter },
#[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
Spawn {
adapter: AcpAdapter,
#[source]
source: std::io::Error,
},
/// The adapter answered the handshake with an error.
#[error("ACP handshake failed: {0}")]
Handshake(String),
/// The adapter went away before the handshake finished. Carries the
/// tail of its stderr, which is where a missing Claude login shows up.
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")]
ExitedEarly { status: String, stderr: String },
#[error("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
ExitedEarly {
adapter: AcpAdapter,
status: String,
stderr: String,
},
#[error("ACP handshake timed out after {0:?}")]
HandshakeTimeout(Duration),
}
/// 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.
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") {
Some(p) => OsString::from(p),
None => std::env::var_os("PATH")?,
};
std::env::split_paths(&path)
.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))
}
@@ -248,6 +293,12 @@ enum Command {
Close,
}
struct ConnectionConfig {
cwd: PathBuf,
load_session: Option<String>,
adapter: AcpAdapter,
}
enum ChildCommand {
Signal(libc::c_int),
Reap(oneshot::Sender<bool>),
@@ -435,7 +486,9 @@ impl Drop for AcpSession {
/// Fails without a running child: on any error the process is already
/// signalled, so a failed session never leaves a stray adapter behind.
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);
cmd.current_dir(&config.cwd)
@@ -463,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 stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
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 (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 (permission_tx, permission_rx) = mpsc::unbounded_channel();
@@ -483,8 +545,11 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
tokio::spawn(run_connection(
transport,
config.cwd.clone(),
config.load_session.clone(),
ConnectionConfig {
cwd: config.cwd.clone(),
load_session: config.load_session.clone(),
adapter: provider,
},
updates_tx,
permission_tx,
commands_rx,
@@ -517,6 +582,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
.to_string()
};
AcpError::ExitedEarly {
adapter: provider,
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
stderr: if stderr.is_empty() {
String::new()
@@ -551,12 +617,13 @@ async fn supervise_child(
pid: u32,
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
exit_tx: watch::Sender<Option<ExitStatus>>,
adapter: AcpAdapter,
) {
loop {
tokio::select! {
status = child.wait() => {
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);
return;
}
@@ -570,7 +637,7 @@ async fn supervise_child(
Some(ChildCommand::Reap(done)) => match child.try_wait() {
Ok(Some(status)) => {
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));
return;
}
@@ -578,7 +645,7 @@ async fn supervise_child(
let _ = done.send(false);
}
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);
}
},
@@ -588,7 +655,7 @@ async fn supervise_child(
// a reused PID.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
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);
return;
}
@@ -704,8 +771,7 @@ fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> S
async fn run_connection<T>(
transport: T,
cwd: PathBuf,
load_session: Option<String>,
config: ConnectionConfig,
updates_tx: mpsc::UnboundedSender<SessionNotification>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
@@ -779,11 +845,13 @@ async fn run_connection<T>(
return Ok(());
}
};
let (session_id, modes, initial_options, models) = match load_session {
let (session_id, modes, initial_options, models) = match config.load_session {
Some(existing) => {
if !initialized.agent_capabilities.load_session {
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(());
}
@@ -793,7 +861,7 @@ async fn run_connection<T>(
if let Err(e) = cx
.send_request(ExtendedLoadSessionRequest {
session_id: SessionId::new(existing.as_str()),
cwd,
cwd: config.cwd,
mcp_servers: vec![],
})
.on_receiving_result(move |result| {
@@ -824,7 +892,7 @@ async fn run_connection<T>(
}
None => match cx
.send_request(ExtendedNewSessionRequest {
cwd,
cwd: config.cwd,
mcp_servers: vec![],
})
.block_task()
@@ -1004,10 +1072,11 @@ async fn drain_stderr(
stderr: tokio::process::ChildStderr,
pid: u32,
tail: Arc<Mutex<VecDeque<u8>>>,
adapter: AcpAdapter,
) {
let mut lines = tokio::io::BufReader::new(stderr).lines();
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");
tail.extend(line.as_bytes());
tail.push_back(b'\n');
+63 -9
View File
@@ -1,12 +1,12 @@
//! `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::path::{Path, PathBuf};
use std::time::{Duration, Instant};
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;
fn fake_agent() -> PathBuf {
@@ -25,6 +25,7 @@ impl Fake {
let bin = dir.path().join("bin");
std::fs::create_dir_all(&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 }
}
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await
.expect_err("no adapter on PATH");
assert!(matches!(err, AcpError::AdapterMissing));
assert!(matches!(
err,
AcpError::AdapterMissing {
adapter: AcpAdapter::ClaudeCode
}
));
let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{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]
async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new();
@@ -241,7 +268,7 @@ async fn advertised_settings_are_normalized_and_mutable() {
async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new();
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
.expect("spawn");
let pid = session.pid();
@@ -282,7 +309,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
let fake = Fake::new();
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());
let err = maestro_acp::spawn(config).await.expect_err("init fails");
@@ -341,7 +368,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err {
AcpError::ExitedEarly { status, stderr } => {
AcpError::ExitedEarly { status, stderr, .. } => {
assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}");
}
@@ -377,7 +404,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
let fake = Fake::new();
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());
let session = maestro_acp::spawn(config).await.expect("spawn");
@@ -410,7 +440,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
async fn load_session_requires_the_load_capability() {
let fake = Fake::new();
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());
let err = maestro_acp::spawn(config)
@@ -425,7 +458,10 @@ async fn load_session_requires_the_load_capability() {
async fn load_session_error_fails_the_spawn() {
let fake = Fake::new();
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());
let err = maestro_acp::spawn(config).await.expect_err("load fails");
@@ -449,3 +485,21 @@ async fn real_adapter_handshake() {
.await;
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());
}
+8
View File
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
/// there.
#[arg(long)]
pub existing_branch: bool,
/// Run a Claude Code or Codex session in a plain terminal instead of the
/// structured (ACP) view it otherwise gets by default.
#[arg(long, conflicts_with = "structured")]
pub terminal: bool,
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
/// Codex commands have an adapter; the daemon refuses anything else.
#[arg(long)]
pub structured: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
pub attach: bool,
+74 -2
View File
@@ -3,7 +3,7 @@
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, SessionView, WorktreeSpec,
@@ -51,7 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
};
let body = CreateSessionRequest {
view: Default::default(),
view: spawn_view(&args),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
@@ -61,6 +61,22 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
client.create_session(&body).await
}
/// The view a `maestro spawn` asks for: structured whenever the command
/// resolves to an agent with an ACP adapter, since that is the richer session
/// and the one both other clients now default to. `--terminal` opts out;
/// `--structured` forces the request through even for a command this side
/// does not recognise, leaving the refusal to the daemon.
fn spawn_view(args: &SpawnArgs) -> SessionView {
if args.terminal {
return SessionView::Terminal;
}
if args.structured || supports_structured_view(&args.command) {
SessionView::Structured
} else {
SessionView::Terminal
}
}
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where
/// the work went.
@@ -239,6 +255,62 @@ mod tests {
use super::DaemonInfo;
use clap::Parser;
use crate::cli::{Cli, Command};
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
argv.extend_from_slice(extra);
let cli = Cli::try_parse_from(argv).expect("parses");
let Command::Spawn(args) = cli.command else {
panic!("expected spawn")
};
args
}
/// The CLI follows the two interactive clients: a command with an ACP
/// adapter spawns structured unless `--terminal` says otherwise, and
/// `--structured` still forces the request for a command this side does
/// not recognise.
#[test]
fn spawn_defaults_to_structured_for_supported_agents() {
use super::spawn_view;
assert_eq!(
spawn_view(&spawn_args(&["claude"])),
SessionView::Structured
);
assert_eq!(
spawn_view(&spawn_args(&["npx codex exec"])),
SessionView::Structured
);
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
assert_eq!(
spawn_view(&spawn_args(&["claude", "--terminal"])),
SessionView::Terminal
);
assert_eq!(
spawn_view(&spawn_args(&["opencode", "--structured"])),
SessionView::Structured
);
}
#[test]
fn spawn_refuses_both_view_flags_at_once() {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"claude",
"--structured",
"--terminal",
])
.unwrap_err();
assert!(err.to_string().contains("--terminal"), "{err}");
}
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
view: Default::default(),
+46 -1
View File
@@ -12,7 +12,8 @@
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post};
@@ -70,6 +71,10 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(
routes::patterns::STRUCTURED_ATTACHMENT,
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
)
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route(
@@ -432,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(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
+52
View File
@@ -573,6 +573,58 @@ 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();
+96 -30
View File
@@ -15,7 +15,8 @@ use std::task::{Context, Poll};
use std::time::Duration;
use chrono::Utc;
use maestro_acp::{AcpSession, PermissionRequest, SpawnConfig};
use maestro_acp::{AcpAdapter, AcpSession, PermissionRequest, SpawnConfig};
use maestro_protocol::display::command_tool;
use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
@@ -59,6 +60,16 @@ pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// actually go.
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
fn structured_adapter(command: &str) -> Result<AcpAdapter, String> {
match command_tool(command) {
Some("claude") => Ok(AcpAdapter::ClaudeCode),
Some("codex") => Ok(AcpAdapter::Codex),
_ => Err(format!(
"structured view supports Claude Code or Codex commands; cannot select an ACP adapter for {command:?}"
)),
}
}
/// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)]
pub struct SessionRecord {
@@ -593,6 +604,36 @@ impl SessionManager {
Ok(Some(replay))
}
/// Store attachment bytes for a live structured session.
pub async fn store_acp_attachment(
&self,
id: Uuid,
name: &str,
bytes: &[u8],
) -> Result<Option<std::path::PathBuf>, SessionError> {
{
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(None);
}
}
let dir = self.inner.sidecar.dir().join(format!("{id}.attachments"));
tokio::fs::create_dir_all(&dir).await?;
let dir = tokio::fs::canonicalize(dir).await?;
let path = dir.join(format!("{}-{name}", Uuid::new_v4()));
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.await?;
use tokio::io::AsyncWriteExt;
file.write_all(bytes).await?;
file.sync_all().await?;
Ok(Some(path))
}
/// Subscribe to the two projections of a structured session's ACP log.
///
/// The transcript snapshot and whole reduced state are folded once by the
@@ -1250,14 +1291,18 @@ impl SessionManager {
// up still yields a session — Dead, with the reason on it — so the
// operator sees what to fix instead of a silently missing session.
let (acp, acp_failure) = match req.view {
SessionView::Structured => {
match maestro_acp::spawn(SpawnConfig::new(
&effective_cwd,
session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
))
SessionView::Structured => match structured_adapter(&req.command) {
Err(error) => (None, Some(error)),
Ok(adapter) => match maestro_acp::spawn(
SpawnConfig::new(
&effective_cwd,
session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)
.adapter(adapter),
)
.await
{
Ok(acp) => (Some(Arc::new(acp)), None),
@@ -1265,8 +1310,8 @@ impl SessionManager {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
(None, Some(e.to_string()))
}
}
}
},
},
SessionView::Terminal => {
let spawned = {
let tmux = self.inner.tmux.clone();
@@ -1674,23 +1719,30 @@ impl SessionManager {
let (acp, failure) = if recoverable {
match session.acp_session_id.clone() {
Some(acp_session_id) => {
let config = SpawnConfig::new(
&session.cwd,
session_env
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
)
.load_session(acp_session_id);
match maestro_acp::spawn(config).await {
Ok(acp) => (Some(Arc::new(acp)), None),
Err(error) => {
tracing::warn!(%id, %error, "structured session: adapter failed to resume");
(None, Some(error.to_string()))
Some(acp_session_id) => match structured_adapter(&session.command) {
Err(error) => {
tracing::warn!(%id, %error, "structured session: unsupported command during recovery");
(None, Some(error))
}
Ok(adapter) => {
let config = SpawnConfig::new(
&session.cwd,
session_env
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
)
.load_session(acp_session_id)
.adapter(adapter);
match maestro_acp::spawn(config).await {
Ok(acp) => (Some(Arc::new(acp)), None),
Err(error) => {
tracing::warn!(%id, %error, "structured session: adapter failed to resume");
(None, Some(error.to_string()))
}
}
}
}
},
None => (
None,
Some("structured session has no stored ACP session id".into()),
@@ -2969,15 +3021,18 @@ mod tests {
use std::task::Poll;
use std::time::Duration;
use maestro_acp::schema::{
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
ToolCallUpdate, ToolCallUpdateFields,
use maestro_acp::{
schema::{
PermissionOption, PermissionOptionKind, RequestPermissionOutcome,
RequestPermissionRequest, ToolCallUpdate, ToolCallUpdateFields,
},
AcpAdapter,
};
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionView};
use super::{
apply_structured_activity, cancel_pending_approvals, permission_outcome,
record_permission_request, run_structured_activity_pass,
record_permission_request, run_structured_activity_pass, structured_adapter,
sync_structured_activity_from_stream, AcpDispatchState, AcpEventStream, ApprovalDecision,
RecordOutcome, SessionManager,
};
@@ -3001,6 +3056,17 @@ mod tests {
)
}
#[test]
fn structured_adapter_uses_the_shared_command_resolution() {
assert_eq!(structured_adapter("claude"), Ok(AcpAdapter::ClaudeCode));
assert_eq!(
structured_adapter("sudo -u agent -- /opt/bin/codex"),
Ok(AcpAdapter::Codex)
);
assert!(structured_adapter("opencode").is_err());
assert!(structured_adapter("claude && codex").is_err());
}
fn stream(log: EventLog) -> AcpEventStream {
let (live, _live_rx) = tokio::sync::broadcast::channel(16);
let (projected_live, _projected_rx) = tokio::sync::broadcast::channel(16);
+47 -9
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 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::sidecar::{EventCursor, SidecarStore};
use maestro_daemon::worktree::WorktreeManager;
@@ -38,6 +38,7 @@ impl Fake {
let bin = dir.path().join("bin");
std::fs::create_dir_all(&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 }
}
@@ -96,10 +97,18 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
}
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 {
view: SessionView::Structured,
cwd: cwd.to_path_buf(),
command: "claude".into(),
command: command.into(),
env,
metadata: HashMap::new(),
worktree: None,
@@ -130,7 +139,11 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let session = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(
cwd.path(),
env,
"codex --dangerously-bypass-approvals-and-sandbox",
))
.await
.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());
let session = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(cwd.path(), env, "codex"))
.await
.unwrap();
@@ -570,7 +583,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let session = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("create");
let acp_id = session.acp_session_id.clone().expect("acp id");
@@ -684,7 +697,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
command: "codex".into(),
env: fake.env(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
@@ -779,15 +792,18 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
let session = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("a failed session, not an error");
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
assert!(detail.contains(ADAPTER_BIN), "{detail}");
assert!(detail.contains(INSTALL_COMMAND), "{detail}");
assert!(detail.contains(AcpAdapter::Codex.binary()), "{detail}");
assert!(
detail.contains(AcpAdapter::Codex.install_command()),
"{detail}"
);
assert_eq!(session.acp_session_id, None);
// It is a real session: listed, on disk, and dismissable like any other
@@ -801,6 +817,28 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
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]
async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
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."}}}
+54
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]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
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
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md.
/// `git worktree add -b` alone fails outright when the branch already exists,
/// 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]
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 = repo_dir.path().to_path_buf();
init_repo(&repo);
// pre-create the branch
// pre-create the branch, unused by any worktree.
let status = Command::new("git")
.arg("-C")
.arg(&repo)
@@ -425,9 +427,17 @@ fn add_new_suffixes_when_branch_already_exists() {
let wt_root = TempDir::new().unwrap();
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.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`
+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");
}
/// #66: a second spawn onto an *existing* branch that already has a worktree
/// is rejected with a structured worktree error; the first worktree stays
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9
/// suffixes the new branch name instead, see
/// `colliding_branch_name_gets_suffixed_too`.)
/// #66/#77: a second spawn onto the same branch used to be rejected outright;
/// since the branch-collision suffixing landed, it succeeds instead — the
/// second worktree lands on `<branch>-2` and the first stays untouched.
#[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 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))
.await
.expect("first spawn");
let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", false))
.await;
match dup {
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:?}"),
}
let second = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await
.expect("second spawn suffixes instead of rejecting");
// Exactly one registration, one directory, still usable.
// Count branch records only: the worktree path now contains the branch
// name too (`worktrees/feature/dup`), so a substring match over every line
// would count the same tree twice.
let holders = git_worktree_list(base.path())
.iter()
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
.count();
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
assert_eq!(
second.cwd, suffixed,
"second worktree must land on the suffixed path"
);
assert!(suffixed.is_dir());
// Both registrations exist: the original branch, untouched, and the
// 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!(
base.path()
.join("worktrees")
@@ -431,7 +434,8 @@ async fn second_spawn_on_same_branch_is_rejected() {
);
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
+19
View File
@@ -226,6 +226,15 @@ pub fn command_tool(command: &str) -> Option<&'static str> {
.map(|(_, tool)| *tool)
}
/// Whether a command line resolves to an agent Maestro can run as a
/// structured (ACP) session — Claude Code or Codex, the two adapters
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
/// control on this, and the daemon rejects a `Structured` request for
/// anything else, so the answer has to come from one place.
pub fn supports_structured_view(command: &str) -> bool {
matches!(command_tool(command), Some("claude" | "codex"))
}
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
/// of the word that is the actual program (past every assignment/wrapper it
/// resolved through).
@@ -1064,4 +1073,14 @@ mod tests {
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
}
#[test]
fn structured_view_support_follows_the_resolved_program() {
assert!(supports_structured_view("claude --resume"));
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
assert!(supports_structured_view("npx claude-code"));
assert!(!supports_structured_view("opencode"));
assert!(!supports_structured_view("bash -l"));
assert!(!supports_structured_view("claude-code-acp"));
}
}
+1 -1
View File
@@ -123,7 +123,7 @@ pub struct Session {
/// daemon that predates the field keeps working untouched.
#[serde(default)]
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
/// one, and only once its handshake succeeded; a later respawn passes it
/// to `session/load` so the conversation continues (#135). Distinct from
+6
View File
@@ -86,6 +86,11 @@ pub fn structured_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/prompt")
}
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
pub fn structured_attachment(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/attachments")
}
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel")
@@ -113,6 +118,7 @@ pub mod patterns {
pub const STRUCTURED: &str = "/api/structured/:id";
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
+99 -1
View File
@@ -11,6 +11,7 @@
//! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
@@ -54,6 +55,36 @@ enum OverlayDecision {
Pass,
}
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
let encoded = base64_encode(text.as_bytes());
let mut output = std::io::stdout();
write!(output, "\x1b]52;c;{encoded}\x07")?;
output.flush()
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[((bits >> 6) & 0x3f) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(bits & 0x3f) as usize] as char
} else {
'='
});
}
output
}
pub struct App {
cfg: TuiConfig,
home_dir: String,
@@ -340,6 +371,10 @@ impl App {
return Ok(false);
}
if self.handle_structured_mouse(&key) {
return Ok(false);
}
if let Some(effects) = self.handle_structured_key(&key) {
self.perform(effects).await;
return Ok(false);
@@ -423,6 +458,61 @@ impl App {
true
}
/// Transcript blocks are pointer targets: click selects the whole block,
/// gives it the selection surface, and copies its complete text.
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
let InputKey::Mouse {
kind,
region: Region::Pane,
row,
..
} = key
else {
return false;
};
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
return false;
}
let Some(sid) = self.state.active_session() else {
return false;
};
let Some(handle) = self.structured.get(&sid) else {
return false;
};
let Some(pane) = self.region.pane else {
return false;
};
let offset = usize::from(row.saturating_sub(pane.y));
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
if offset >= usize::from(transcript_height) {
return true;
}
let mut state = handle.state.lock().expect("structured state mutex");
let rendered_line = state.scroll.saturating_add(offset);
if !state.select_rendered_line(rendered_line) {
return true;
}
let Some(id) = state.selected.as_deref() else {
return true;
};
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
return true;
};
if *kind == MouseKind::Hover {
return true;
}
let text = crate::structured_pane::copy_text(transcript);
drop(state);
if text.is_empty() {
return true;
}
match copy_to_terminal_clipboard(&text) {
Ok(()) => self.state.toast("copied transcript block"),
Err(error) => self.state.toast(format!("copy failed: {error}")),
}
true
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_payload_uses_standard_base64() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
}
use crate::action::Action;
use crate::draft::DraftField;
use crate::keymap::Mode;
@@ -3053,17 +3149,19 @@ mod tests {
let request = |view| CreateSessionRequest {
view,
cwd: std::path::PathBuf::from("/srv/work"),
command: "claude".into(),
command: "codex".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
let structured_id = Uuid::from_u128(188);
app.state.mode = Mode::Insert;
app.submit_spawn(request(SessionView::Structured)).await;
assert!(app.catalog.snapshot().sessions.is_empty());
assert!(app.structured.contains_key(&structured_id));
assert!(app.emulators.get(structured_id).is_none());
assert_eq!(app.state.mode, Mode::Normal);
let terminal_id = Uuid::from_u128(187);
app.submit_spawn(request(SessionView::Terminal)).await;
+132 -32
View File
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::{abbreviate_dir, command_tool};
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::{
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
};
@@ -91,7 +91,7 @@ pub enum DraftField {
/// Disclosure collapsing the optional view and worktree controls below.
/// Closed by default; Enter / Space toggles.
Advanced,
/// ACP-backed transcript instead of a terminal. Claude Code only.
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
StructuredToggle,
/// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle,
@@ -255,9 +255,14 @@ pub struct DraftPane {
/// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target.
pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Off by default and supported only
/// when the resolved command is Claude Code.
/// ACP-backed structured transcript. Supported only when the resolved
/// command is Claude Code or Codex, and on by default whenever it is —
/// until the operator works the toggle themselves.
pub structured_view: bool,
/// Set once the operator toggles structured view by hand. From then on a
/// command change never re-enables what they turned off, the same guard
/// `name_touched` gives the name field.
pub structured_view_touched: bool,
/// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
pub base_repo: Option<PathBuf>,
@@ -298,7 +303,7 @@ impl Default for DraftPane {
// while its data loads, and stay usable when the daemon reports none.
let mut presets = builtin_presets();
presets.push(custom_preset());
Self {
let mut draft = Self {
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
presets,
selected_preset: 0,
@@ -319,6 +324,7 @@ impl Default for DraftPane {
worktree_enabled: true,
advanced_collapsed: true,
structured_view: false,
structured_view_touched: false,
base_repo: None,
probed_cwd: None,
probe_seq: 0,
@@ -337,7 +343,11 @@ impl Default for DraftPane {
// straight away, and Tab from here reaches the command tiles.
focused_field: DraftField::DirPicker,
home_dir: String::new(),
}
};
// The built-in tile the form opens on decides whether structured view
// starts ticked, exactly as a later preset list would.
draft.sync_structured_view();
draft
}
}
@@ -356,7 +366,7 @@ impl DraftPane {
.position(|p| !p.command.is_empty())
.unwrap_or(0);
self.apply_seed_command();
self.clear_unsupported_structured_view();
self.sync_structured_view();
}
/// Land a seeded command on the tile that runs it: its own preset when the
@@ -673,18 +683,24 @@ impl DraftPane {
}
fn supports_structured_view(&self) -> bool {
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() {
self.structured_view = false;
} else if !self.structured_view_touched {
self.structured_view = true;
}
}
pub fn toggle_structured_view(&mut self) {
if self.supports_structured_view() {
self.structured_view = !self.structured_view;
self.structured_view_touched = true;
}
}
@@ -827,7 +843,7 @@ impl DraftPane {
}
self.selected_preset =
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
self.clear_unsupported_structured_view();
self.sync_structured_view();
}
/// Step-one boxes surviving the type-to-filter, in display order. A
@@ -1130,7 +1146,7 @@ impl DraftPane {
DraftField::Preset | DraftField::Submit => {}
DraftField::CustomCommand => {
self.custom_command.handle_key(other);
self.clear_unsupported_structured_view();
self.sync_structured_view();
}
// Typing filters the list; the highlight resets to the top
// so a narrowed search always starts at its best match.
@@ -1888,18 +1904,39 @@ mod tests {
}
#[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 {
advanced_collapsed: false,
..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));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(rendered.contains("[ ] structured view"), "{rendered}");
assert!(rendered.contains("[x] structured view"), "{rendered}");
draft.toggle_structured_view();
assert!(draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "codex");
assert!(
draft.structured_view,
"Codex keeps the structured selection"
);
draft.cycle_preset(1);
assert_eq!(draft.command(), "opencode");
assert!(
@@ -1928,9 +1965,9 @@ mod tests {
/// 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, so the toggle stays
/// offered and the request carries `Structured`; an unrelated program
/// does not.
/// 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)] = &[
@@ -1952,6 +1989,16 @@ mod tests {
("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),
@@ -1973,16 +2020,16 @@ mod tests {
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value(command);
draft.sync_structured_view();
assert_eq!(
draft.field_order().contains(&DraftField::StructuredToggle),
supported,
"toggle visibility for {command:?}"
);
draft.toggle_structured_view();
assert_eq!(
draft.structured_view, supported,
"toggle takes effect for {command:?}"
"default selection for {command:?}"
);
let view = draft.build_request().unwrap().view;
assert_eq!(
@@ -1994,12 +2041,18 @@ mod tests {
},
"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
/// through a wrapper still offers structured view.
/// Code or Codex through a wrapper still offers structured view.
#[test]
fn structured_view_follows_command_resolution_for_presets() {
let mut draft = DraftPane {
@@ -2014,6 +2067,11 @@ mod tests {
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(),
@@ -2023,11 +2081,16 @@ mod tests {
assert_eq!(draft.command(), "npx claude-code --resume");
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
draft.toggle_structured_view();
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching to a preset that does not resolve to Claude clears the
// 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");
@@ -2036,11 +2099,48 @@ mod tests {
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from Claude clears an armed
/// 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_claude() {
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,
@@ -2049,22 +2149,22 @@ mod tests {
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("claude");
draft.toggle_structured_view();
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(), "claude -p");
assert!(draft.structured_view, "still Claude, selection survives");
assert_eq!(draft.command(), "codex -p");
assert!(draft.structured_view, "still Codex, selection survives");
for _ in 0.."claude -p".chars().count() {
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 claude clears the stale selection"
"editing the program away from Codex clears the stale selection"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
+7
View File
@@ -55,6 +55,7 @@ pub enum MouseKind {
Click,
DblClick,
Drag,
Hover,
ScrollUp,
ScrollDown,
}
@@ -148,6 +149,7 @@ impl InputKey {
MouseKind::Click => "click",
MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag",
MouseKind::Hover => "hover",
MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown",
};
@@ -178,6 +180,7 @@ impl InputKey {
"click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag,
"hover" => MouseKind::Hover,
"scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())),
@@ -297,6 +300,7 @@ where
let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::Moved => MouseKind::Hover,
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None,
@@ -681,6 +685,9 @@ mod tests {
let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
let s = "mouse:hover(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
}
#[test]
+83 -6
View File
@@ -308,11 +308,12 @@ fn fallback_after_removal(
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
let mut out = Vec::new();
for row in &state.rows {
for (index, row) in state.rows.iter().enumerate() {
if row.kind == TranscriptRowKind::Plan {
continue;
}
let row_width = width.saturating_sub(1).max(1);
let start = out.len();
match row.kind {
TranscriptRowKind::User => push_wrapped(
&mut out,
@@ -356,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
),
TranscriptRowKind::Plan => {}
}
if out.len() > start
&& state.rows[index + 1..]
.iter()
.any(|next| next.kind != TranscriptRowKind::Plan)
{
// A blank surface line is the vertical padding that keeps adjacent
// transcript blocks readable at a glance.
out.push(VisualLine {
text: String::new(),
row_id: None,
rail: false,
tone: Tone::Normal,
});
}
}
out
}
/// Text supplied to the terminal clipboard when a transcript block is
/// clicked. It preserves the full tool detail rather than its compact preview.
pub fn copy_text(row: &TranscriptRow) -> String {
match row.kind {
TranscriptRowKind::User
| TranscriptRowKind::AgentText
| TranscriptRowKind::AgentThought => content_text(row),
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
let (name, argument) = tool_title(row);
let mut lines = vec![if argument.is_empty() {
name
} else {
format!("{name} {argument}")
}];
lines.extend(tool_content_lines(row, None));
lines.join("\n")
}
TranscriptRowKind::Approval => row
.data
.get("toolCall")
.and_then(|tool| tool.get("title"))
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
}
fn push_wrapped(
out: &mut Vec<VisualLine>,
row: &TranscriptRow,
@@ -1000,6 +1044,39 @@ mod tests {
);
}
#[test]
fn transcript_blocks_have_a_padding_line_between_them() {
let mut state = StructuredState::default();
state.rows = vec![
row(
"one",
TranscriptRowKind::User,
json!({"content":{"type":"text","text":"first"}}),
),
row(
"two",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"second"}}),
),
];
let lines = build_lines(&state, 80);
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].text, "");
assert!(lines[1].row_id.is_none());
}
#[test]
fn copying_a_tool_keeps_its_full_detail() {
let tool = row(
"t",
TranscriptRowKind::ToolCall,
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
);
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
}
#[test]
fn tool_is_compact_until_selected_row_expands() {
let mut state = StructuredState::default();
@@ -1420,7 +1497,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1439,7 +1516,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1459,7 +1536,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1478,7 +1555,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
@@ -1497,7 +1574,7 @@ mod tests {
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
state.scroll = 20;
render_transcript_lines(&pane, &mut state, 30, 5);
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
+29
View File
@@ -199,6 +199,18 @@ impl StructuredState {
self.follow_selection = true;
}
/// Select the transcript block under a rendered transcript line. Pointer
/// input uses the same row identity map as scroll reconciliation, so a
/// wrapped block behaves as one target.
pub fn select_rendered_line(&mut self, line: usize) -> bool {
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
return false;
};
self.selected = Some(id);
self.follow_selection = false;
true
}
pub fn toggle_selected(&mut self) {
let Some(id) = self.selected.clone() else {
return;
@@ -657,6 +669,23 @@ mod tests {
assert_eq!(state.composer.value(), "hi");
}
#[test]
fn selecting_a_rendered_line_selects_its_whole_block() {
let mut state = StructuredState::default();
state.line_rows = vec![
Some("one".into()),
Some("one".into()),
None,
Some("two".into()),
];
assert!(state.select_rendered_line(1));
assert_eq!(state.selected.as_deref(), Some("one"));
assert!(!state.follow_selection);
assert!(!state.select_rendered_line(2));
assert_eq!(state.selected.as_deref(), Some("one"));
}
#[test]
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
let mut state = StructuredState::default();
+3
View File
@@ -29,3 +29,6 @@ tracing-subscriber.workspace = true
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
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
`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
daemon resolves the adapter binary, `claude-code-acp`, on its `PATH` at spawn
time. Install it once per host:
resolved session command selects the adapter binary, which must be on the
session's `PATH` when the spawn request sets one, or the daemon's `PATH`
otherwise. Install the adapter for each provider you use:
```sh
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
`PATH` (a systemd user unit does not read your shell profile; set
That puts `claude-code-acp` and `codex-acp` in `~/.local/bin`, which must be on
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
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
with the install command above as its activity detail, so the sidebar says
what to run. The adapter is a child of the daemon: it ends with a `DELETE
with the provider-specific install command as its activity detail, so clients
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
itself. A daemon restart ends an in-flight structured turn, but preserves its
worktree, session record, and event-log transcript. The replacement daemon
+69 -29
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
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
ACP **agent** — it wraps Claude Code's own SDK and translates the model's
tool calls into ACP `session/update` notifications instead of drawing them to
a pane. `crates/maestro-acp` owns the daemon's side of that connection;
daemon is the ACP **client**; the provider adapter process is the ACP
**agent**. It translates the provider's events into ACP `session/update`
notifications instead of drawing them to a pane. `crates/maestro-acp` owns the
daemon's side of that connection;
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two
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
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
`ADAPTER_BIN` (`claude-code-acp`) is resolved by walking `PATH` — the
session's own `PATH` env var if the spawn request set one, the daemon's
otherwise — and taking the first executable file named `claude-code-acp` on
it. Install it once per host:
The resolved structured command selects a provider adapter. Its binary is
resolved by walking the session's `PATH` when the spawn request sets one, or
the daemon's `PATH` otherwise, and taking the first executable with that name.
```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
```
| Command | Adapter binary | Install command | Provider detail |
| --- | --- | --- | --- |
| `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
is baked into `AcpError::AdapterMissing`'s message. When the adapter cannot
be found, spawn still creates the session — **Dead**, with that install
command as its `activity_detail` — so the sidebar names the fix instead of
the session silently never appearing (see `docs/INSTALL.md` for the full
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 selected install command is baked into `AcpError::AdapterMissing`. When an
adapter cannot be found, spawn still creates a **Dead** session with that
command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
caveat). Provider authentication failures surface through the adapter's stderr
tail in the same field.
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
long enough to hang a spawn indefinitely. Any handshake failure — missing
adapter, early exit, a protocol error, or the timeout — leaves no orphaned
process: `spawn` closes and reaps the child itself before returning the
error.
slow machine to boot an adapter and its provider process, never long enough
to hang a spawn indefinitely. Any handshake failure — missing adapter, early
exit, a protocol error, or the timeout — leaves no orphaned process: `spawn`
closes and reaps the child itself before returning the error.
## The ACP handshake
@@ -69,17 +81,17 @@ declares, deliberately, almost nothing:
| 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.writeTextFile` | `false` | Same reasoning, for Write. |
| `terminal` | `false` | The adapter runs Claude Code's own Bash tool rather than asking the daemon to open a terminal for it. |
| `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
| `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
| `terminal` | `false` | Providers run commands through their own tool 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
routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because `claude-code-acp` has
never asked for it, not because it was weighed and refused.
default; the daemon does not advertise support because neither supported
adapter needs it, not because it was weighed and refused.
The one client method that *is* implemented for real is
`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
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
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
@@ -298,6 +318,26 @@ pending decision, entirely on the daemon side:
## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
be removed before sending; failed sends retain the draft and attachments.
Pastes also stay inline when all ten attachment slots are occupied or the text
exceeds the 20 MiB file limit.
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
Only live structured sessions accept uploads. Filenames are limited to 180
bytes and cannot contain directory separators or control characters. Files get unique names and private
file permissions in `<id>.attachments` beside the session sidecar. They persist
across daemon restarts; removing a composer chip does not delete uploaded data.
There is currently no automatic attachment cleanup.
The prompt includes each filename and host path so the adapter can read it
through its own file tools, including images when the provider supports them.
These references are persisted in the ordinary user transcript row. Attachment
bytes are not embedded in ACP messages or transcript history.
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
against each other through one per-session `operation` lock
(`AcpEventStream::operation`), because ACP permission callbacks carry no
+1 -1
View File
@@ -148,7 +148,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
};
buildPhase = ''
+1 -1
View File
@@ -6,7 +6,7 @@
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -b tsconfig.build.json && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
+9
View File
@@ -184,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
);
}
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/octet-stream" },
body: file,
}));
}
/** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError(
+21 -2
View File
@@ -64,6 +64,7 @@ export function createAttachConnection(
<div class="attach-body"></div>
`;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
@@ -323,6 +324,9 @@ export function createAttachConnection(
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
// Follow it once per socket; later scrolling remains under the operator's control.
let awaitingReplay = true;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
@@ -342,6 +346,7 @@ export function createAttachConnection(
};
function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
@@ -362,10 +367,20 @@ export function createAttachConnection(
return;
}
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) {
// 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
// bar already take.
sendInput,
updateInfo(next: AttachInfo) {
titleEl.innerHTML = paneTitleHtml(sessionId, next);
titleEl.title = paneTitleAttr(sessionId, next);
},
dispose() {
if (disposed) return;
disposed = true;
+1
View File
@@ -27,6 +27,7 @@ function fakeConn(kind: string, id: string) {
sendInput(data: string) {
sent.push({ id, data });
},
updateInfo() {},
dispose() {
disposed.push(`${kind}:${id}`);
},
+1
View File
@@ -110,6 +110,7 @@ export class ConnectionPool {
} else {
this.cache.delete(id);
this.cache.set(id, conn); // LRU refresh
if (info) conn.updateInfo(info);
}
} else {
conn = this.create(id, info);
+3
View File
@@ -4,6 +4,7 @@
// transcript (structured.ts).
import type { Uuid } from "./types.js";
import type { AttachInfo } from "./paneTitle.js";
export interface PaneConnection {
readonly sessionId: Uuid;
@@ -21,6 +22,8 @@ export interface PaneConnection {
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
* has no PTY, so this is a no-op there. */
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. */
dispose(): void;
}
+4
View File
@@ -47,6 +47,10 @@ export function structuredPrompt(id: Uuid): string {
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. */
export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`;
+15 -9
View File
@@ -26,15 +26,21 @@ describe("buildSpawnRequest", () => {
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
});
it("requests structured view only for Claude Code", () => {
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({
req: {
cwd: base.cwd,
command: "claude",
metadata: { name: "amber-fox" },
view: "Structured",
},
});
it.each(["claude", "FOO=1 /usr/local/bin/codex", "env -i codex"])(
"requests structured view for resolved %s commands",
(command) => {
expect(buildSpawnRequest({ ...base, command, structuredView: true }, null)).toEqual({
req: {
cwd: base.cwd,
command,
metadata: { name: "amber-fox" },
view: "Structured",
},
});
},
);
it("does not request structured view for unsupported commands", () => {
expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({
+20 -3
View File
@@ -15,6 +15,13 @@ import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } 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
* from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing";
@@ -68,7 +75,7 @@ export function buildSpawnRequest(
metadata,
};
if (values.structuredView && splitCommand(values.command).tool === "claude") {
if (values.structuredView && supportsStructuredView(values.command)) {
req.view = "Structured";
}
@@ -368,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// operator has already touched (#76).
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 {
const supported = splitCommand(command).tool === "claude";
const supported = supportsStructuredView(command);
structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false;
else if (!structuredTouched) structuredView.checked = true;
}
structuredView.addEventListener("change", emit);
structuredView.addEventListener("change", () => {
structuredTouched = true;
emit();
});
cmdRaw.addEventListener("input", () => {
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();
});
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();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
@@ -57,28 +57,55 @@ describe("spawn form structured view", () => {
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(disclosure.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();
expect(wrap.hidden).toBe(true);
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click();
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input"));
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();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
expect(checkbox.checked).toBe(true);
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);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "opencode";
command.dispatchEvent(new Event("input"));
@@ -86,7 +113,33 @@ describe("spawn form structured view", () => {
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();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
@@ -100,9 +153,6 @@ describe("spawn form structured view", () => {
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({
command: "claude",
view: "Structured",
});
expect(JSON.parse(String(requests[0].body)).view).toBeUndefined();
});
});
+116 -2
View File
@@ -6,6 +6,7 @@ vi.mock("./api.js", async () => {
return {
...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
@@ -16,6 +17,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
} from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
@@ -95,6 +97,53 @@ function mount(opts: {
describe("structured connection: data flow", () => {
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", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -115,6 +164,42 @@ describe("structured connection: data flow", () => {
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] });
@@ -289,6 +374,7 @@ describe("structured connection: data flow", () => {
transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
state: idleState,
});
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.classList).toContain("st-row-enter");
ws.emit({
type: "projection_update",
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) },
@@ -296,6 +382,7 @@ describe("structured connection: data flow", () => {
});
const rows = conn.el.querySelectorAll(".st-row-agent");
expect(rows).toHaveLength(1);
expect(rows[0].classList).not.toContain("st-row-enter");
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
conn.dispose();
@@ -321,7 +408,7 @@ describe("structured connection: data flow", () => {
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")!.hidden).toBe(true);
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();
});
@@ -1253,7 +1340,7 @@ describe("structured connection: transcript scrolling", () => {
it("keeps pinned appends and patches at the bottom after layout", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { values } = metrics(conn, 600, 200);
const { scroll, values } = metrics(conn, 600, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
@@ -1269,6 +1356,7 @@ describe("structured connection: transcript scrolling", () => {
});
runFrames();
expect(values.top).toBe(520);
expect(scroll.style.scrollBehavior).toBe("smooth");
values.height = 780;
ws.emit({
@@ -1283,6 +1371,32 @@ describe("structured connection: transcript scrolling", () => {
});
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();
});
+174 -19
View File
@@ -10,6 +10,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
structuredWsUrl,
} from "./api.js";
@@ -90,7 +91,12 @@ export function createStructuredConnection(
<span class="structured-activity-text">Thinking</span>
</div>
<form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
</button>
<input class="structured-file-input" type="file" multiple hidden>
<div class="structured-input-wrap">
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
role="listbox" hidden></div>
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
@@ -114,6 +120,7 @@ export function createStructuredConnection(
`;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const configEl = el.querySelector(".structured-config") as HTMLDivElement;
const configTrigger = el.querySelector(".structured-config-trigger") as HTMLButtonElement;
const configMenu = el.querySelector(".structured-config-menu") as HTMLDivElement;
@@ -127,6 +134,79 @@ export function createStructuredConnection(
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
function renderAttachments(): void {
attachmentEl.replaceChildren();
attachmentEl.hidden = attachments.length === 0;
for (const attachment of attachments) {
const chip = document.createElement("span");
chip.className = "structured-attachment";
const label = document.createElement("span");
label.textContent = attachment.file.name;
label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = input.disabled;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
});
chip.append(label, remove);
attachmentEl.append(chip);
}
}
function addFiles(files: File[]): void {
if (input.disabled || disposed) return;
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
} else if (attachments.length >= 10) {
showError("Attach up to 10 files per message.");
} else {
attachments.push({ file });
}
}
renderAttachments();
}
attachBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
addFiles(Array.from(fileInput.files ?? []));
fileInput.value = "";
});
composerForm.addEventListener("dragover", (event) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
});
composerForm.addEventListener("drop", (event) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || input.disabled) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < 4000 && text.split("\n").length < 40) return;
if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]);
input.setRangeText("", input.selectionStart, input.selectionEnd, "end");
autoResize();
});
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
@@ -148,6 +228,7 @@ export function createStructuredConnection(
const rowEls = new Map<string, HTMLElement>();
const expandedRowIds = new Set<string>();
const expandedGroupIds = new Set<string>();
const enteringRowIds = new Set<string>();
const groupEls = new Map<string, HTMLElement>();
let state: ReducedState = { turn: idleTurn() };
let pendingApprovalRowId: string | null = null;
@@ -171,7 +252,20 @@ export function createStructuredConnection(
let scrollWorkActive = true;
let parkedScrollTop: number | null = null;
let scrollFrame: number | null = null;
let pendingScroll: { top: number; follow: boolean } | null = null;
let pendingScroll: { top: number; follow: boolean; smooth: boolean } | null = null;
let smoothScrollTimer: number | null = null;
let smoothFollowing = false;
function reduceMotion(): boolean {
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
}
function stopSmoothFollow(): void {
if (smoothScrollTimer !== null) window.clearTimeout(smoothScrollTimer);
smoothScrollTimer = null;
smoothFollowing = false;
scrollEl.style.removeProperty("scroll-behavior");
}
function cancelScrollFrame(): void {
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
@@ -179,15 +273,17 @@ export function createStructuredConnection(
pendingScroll = null;
}
function beginTranscriptLayout(forceBottom = false): void {
function beginTranscriptLayout(forceBottom = false, smooth = false): void {
if (!scrollWorkActive || disposed) return;
if (pendingScroll) {
if (forceBottom) pendingScroll.follow = true;
if (smooth) pendingScroll.smooth = true;
return;
}
pendingScroll = {
top: scrollEl.scrollTop,
follow: forceBottom || followingBottom,
smooth,
};
}
@@ -198,19 +294,30 @@ export function createStructuredConnection(
const pending = pendingScroll;
pendingScroll = null;
if (!pending || disposed || !scrollWorkActive) return;
scrollEl.scrollTop = pending.follow
const target = pending.follow
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
: pending.top;
if (pending.follow && pending.smooth && !reduceMotion()) {
smoothFollowing = true;
scrollEl.style.scrollBehavior = "smooth";
scrollEl.scrollTop = target;
if (smoothScrollTimer !== null) window.clearTimeout(smoothScrollTimer);
smoothScrollTimer = window.setTimeout(() => stopSmoothFollow(), 220);
} else {
stopSmoothFollow();
scrollEl.scrollTop = target;
}
});
}
function updateTranscriptLayout(update: () => void, forceBottom = false): void {
beginTranscriptLayout(forceBottom);
function updateTranscriptLayout(update: () => void, forceBottom = false, smooth = false): void {
beginTranscriptLayout(forceBottom, smooth);
update();
finishTranscriptLayout();
}
function onTranscriptScroll(): void {
if (smoothFollowing) return;
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
cancelScrollFrame();
@@ -218,12 +325,19 @@ export function createStructuredConnection(
function onTranscriptClick(event: Event): void {
if ((event.target as Element | null)?.closest(".st-tool-head, .st-tool-group-head")) {
beginTranscriptLayout();
beginTranscriptLayout(false, true);
}
}
function settleDisclosureLayout(): void {
finishTranscriptLayout();
window.setTimeout(() => updateTranscriptLayout(() => {}, false, true), 190);
}
scrollEl.addEventListener("scroll", onTranscriptScroll);
scrollEl.addEventListener("click", onTranscriptClick, true);
scrollEl.addEventListener("wheel", stopSmoothFollow, { passive: true });
scrollEl.addEventListener("touchstart", stopSmoothFollow, { passive: true });
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
@@ -268,10 +382,11 @@ export function createStructuredConnection(
(expanded) => {
if (expanded) expandedRowIds.add(row.id);
else expandedRowIds.delete(row.id);
finishTranscriptLayout();
settleDisclosureLayout();
},
);
if (!el) return null;
if (enteringRowIds.has(row.id)) el.classList.add("st-row-enter");
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
expandedRowIds.delete(row.id);
}
@@ -328,9 +443,12 @@ export function createStructuredConnection(
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
finishTranscriptLayout();
settleDisclosureLayout();
},
);
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
continue;
@@ -340,7 +458,15 @@ export function createStructuredConnection(
rowEls.set(row.id, rowEl);
children.push(rowEl);
}
rowsEl.replaceChildren(...children);
// A completed turn's footer is useful as the conversation's current
// tail, but repeating duration/model/token summaries throughout history
// makes them read like transcript content. Keep only a footer that is the
// final visible item; appending the next turn naturally retires it.
const visibleChildren = children.filter(
(child, index) => !child.classList.contains("st-turn-meta") || index === children.length - 1,
);
rowsEl.replaceChildren(...visibleChildren);
enteringRowIds.clear();
if (restoreToolFocus && focusedRowId) {
rowEls.get(focusedRowId)?.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
} else if (restoreGroupFocus && focusedGroupId) {
@@ -368,6 +494,7 @@ export function createStructuredConnection(
if (!delta) return;
updateTranscriptLayout(() => {
if (delta.type === "append") {
if (receivedSnapshot) enteringRowIds.add(delta.row.id);
rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row);
} else if (delta.type === "patch") {
@@ -388,7 +515,7 @@ export function createStructuredConnection(
}
rebuildTranscript();
syncAvailableCommands();
});
}, false, true);
}
function sessionConfigOptions(): AcpSessionConfigOption[] {
@@ -479,9 +606,12 @@ export function createStructuredConnection(
}
function renderComposerState(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = active;
attachBtn.disabled = active;
fileInput.disabled = active;
renderAttachments();
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
cancelBtn.hidden = !projectedActive;
@@ -703,7 +833,7 @@ export function createStructuredConnection(
input.style.overflowY = "hidden";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
if (input.scrollHeight > 160) input.style.overflowY = "auto";
});
}, false, true);
}
input.addEventListener("input", () => {
autoResize();
@@ -749,7 +879,9 @@ export function createStructuredConnection(
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if (!text.trim() || input.disabled) return;
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
const submittedAttachments = attachments;
uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
@@ -759,13 +891,26 @@ export function createStructuredConnection(
autoResize();
clearError();
renderComposerState();
sendStructuredPrompt(sessionId, text).then(
const send = async () => {
for (const attachment of submittedAttachments) {
if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
}
if (disposed) return;
const references = submittedAttachments.map(({ file, path }) =>
`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => {
if (
disposed
|| generation !== submissionGeneration
|| awaitingBeginGeneration !== generation
) return;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
attachments = [];
renderAttachments();
}
renderComposerState();
if (awaitingBeginGeneration !== generation) return;
const acceptanceSocketGeneration = socketGeneration;
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
// A replacement socket may have captured idle before the POST claimed
@@ -773,6 +918,7 @@ export function createStructuredConnection(
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
},
(e: unknown) => {
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (input.value === "") {
@@ -912,6 +1058,7 @@ export function createStructuredConnection(
pendingScroll = {
top: parkedScrollTop ?? scrollEl.scrollTop,
follow: followingBottom,
smooth: false,
};
parkedScrollTop = null;
finishTranscriptLayout();
@@ -927,6 +1074,7 @@ export function createStructuredConnection(
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
scrollWorkActive = false;
cancelScrollFrame();
stopSmoothFollow();
cancelFocusFrame();
},
focusTerminal() {
@@ -936,6 +1084,10 @@ export function createStructuredConnection(
// No PTY behind a structured session — the leader's literal-byte
// escape hatch has nothing to do here.
},
updateInfo(next: AttachInfo) {
titleEl.innerHTML = paneTitleHtml(sessionId, next);
titleEl.title = paneTitleAttr(sessionId, next);
},
dispose() {
if (disposed) return;
disposed = true;
@@ -943,6 +1095,7 @@ export function createStructuredConnection(
mountToken++;
scrollWorkActive = false;
cancelScrollFrame();
stopSmoothFollow();
cancelFocusFrame();
cancelReconnect();
closeCurrentSocket();
@@ -950,6 +1103,8 @@ export function createStructuredConnection(
expandedGroupIds.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true);
scrollEl.removeEventListener("wheel", stopSmoothFollow);
scrollEl.removeEventListener("touchstart", stopSmoothFollow);
document.removeEventListener("pointerdown", closeConfigOnOutsidePress);
el.remove();
},
+110 -12
View File
@@ -40,10 +40,15 @@ describe("buildTranscriptRow: user/agent/thought", () => {
expect(ps[0].innerHTML).toContain("<code>three</code>");
});
it("renders a thought row as plain text", () => {
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } }));
it("trims ACP framing whitespace and renders thought-row inline markdown", () => {
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.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", () => {
@@ -153,7 +158,10 @@ describe("buildTranscriptRow: tool calls", () => {
expect(head.getAttribute("aria-expanded")).toBe("true");
expect(out.textContent).toContain("match found");
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", () => {
@@ -163,19 +171,107 @@ describe("buildTranscriptRow: tool calls", () => {
expect(head.getAttribute("aria-expanded")).toBe("false");
});
it("shows raw command, input, and output as separately copyable details", () => {
it("renders command fields and keeps the exact JSON available", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Bash(test)",
rawInput: { command: "printf ok", timeout_ms: 1000 },
rawOutput: { stdout: "ok", exit_code: 0 },
rawInput: { command: "printf ok", cwd: "/workspace", timeout_ms: 1000 },
rawOutput: { formatted_output: "ok", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const labels = [...el.querySelectorAll(".st-tool-detail-label")].map((node) => node.textContent);
expect(labels).toEqual(["Command", "Input", "Output"]);
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
expect(el.querySelectorAll(".st-tool-detail pre[role=button]")).toHaveLength(3);
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=");
});
});
@@ -191,9 +287,11 @@ describe("buildToolGroup", () => {
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.hidden).toBe(true);
expect(body.getAttribute("aria-hidden")).toBe("true");
expect(body.inert).toBe(true);
head.click();
expect(body.hidden).toBe(false);
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();
+247 -47
View File
@@ -196,20 +196,21 @@ function makeCopyable(node: HTMLElement, text: string, label: string): void {
function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement {
const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined;
const text = contentBlockText(data?.content);
const displayText = variant === "thought" ? text.trim() : text;
const wrap = el("div", `st-row st-row-${variant}`);
const body = el("div", "st-body");
if (variant === "user") {
const line = el("div", "st-user-text");
line.textContent = text;
line.textContent = displayText;
body.appendChild(line);
} else if (variant === "agent") {
renderProse(body, text);
renderProse(body, displayText);
} else {
body.textContent = text;
body.innerHTML = renderInlineMarkdown(displayText);
}
wrap.appendChild(body);
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
makeCopyable(wrap, text, label);
makeCopyable(wrap, displayText, label);
return wrap;
}
@@ -245,16 +246,18 @@ function printable(value: unknown): string | null {
}
}
function toolInput(tool: AcpToolCallLike): { command: string | null; input: string | null } {
const raw = tool.rawInput;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { command: null, input: printable(raw) };
function jsonPrintable(value: unknown): string | null {
if (value === undefined) return null;
try {
return JSON.stringify(value, null, 2) ?? null;
} catch {
return printable(value);
}
const record = raw as Record<string, unknown>;
const command = typeof record.command === "string" ? record.command : null;
if (!command) return { command: null, input: printable(raw) };
const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== "command"));
return { command, input: Object.keys(rest).length > 0 ? printable(rest) : null };
}
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 {
@@ -268,6 +271,213 @@ function appendToolDetail(container: HTMLElement, label: string, text: string):
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 —
* real derived data, never a fabricated duration. */
function toolDiffMeta(content: AcpToolCallContent[]): string {
@@ -330,48 +540,34 @@ function buildToolRow(
}
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;
let outBuilt = false;
const buildOut = () => {
if (outBuilt) return;
outBuilt = true;
const { command, input } = toolInput(tool);
if (command) appendToolDetail(out, "Command", command);
if (input !== null) appendToolDetail(out, "Input", input);
const rawOutput = printable(tool.rawOutput);
if (rawOutput !== null) appendToolDetail(out, "Output", rawOutput);
const texts = content
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
.map((c) => contentBlockText(c.content))
.filter(Boolean);
const contentText = texts.join("\n");
if (contentText && contentText !== rawOutput) {
const textPanel = el("div", "st-tool-text");
textPanel.textContent = contentText;
makeCopyable(textPanel, contentText, "output");
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;
appendStandardContent(outInner, content);
appendKnownToolPayload(outInner, tool, content);
appendRawPayload(outInner, tool);
};
card.appendChild(out);
const setExpanded = (nextExpanded: boolean) => {
const setExpanded = (nextExpanded: boolean, animate = false) => {
if (nextExpanded) buildOut();
if (out.children.length === 0) nextExpanded = false;
if (outInner.children.length === 0) nextExpanded = false;
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", () => {
setExpanded(head.getAttribute("aria-expanded") !== "true");
setExpanded(head.getAttribute("aria-expanded") !== "true", true);
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
});
setExpanded(expanded);
@@ -407,13 +603,17 @@ export function buildToolGroup(
.join(" · ");
head.append(glyph, title, names);
const body = el("div", "st-tool-group-body");
body.hidden = !expanded;
body.append(...children);
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));
body.hidden = !expanded;
setDisclosure(body, expanded, true);
onExpandedChange?.(expanded);
});
wrap.append(head, body);
+254 -43
View File
@@ -2533,7 +2533,7 @@ input.leaf-rename:focus {
}
.st-copyable::after {
content: "copy";
content: none;
position: absolute;
top: var(--sp-1);
right: var(--sp-1);
@@ -2549,27 +2549,30 @@ input.leaf-rename:focus {
transition: opacity var(--dur-fast) var(--ease-out);
}
.st-copyable:hover,
.st-copyable:focus-visible {
background: var(--surface-2);
outline: none;
}
.st-copyable:hover::after,
.st-copyable:focus-visible::after,
.st-copyable[data-copy-state="copied"]::after,
.st-copyable[data-copy-state="error"]::after {
opacity: 1;
}
@media (hover: hover) {
.st-copyable:hover {
background: var(--surface-2);
}
.st-copyable[data-copy-state="copied"]::after {
content: "copied";
color: var(--live);
}
.st-copyable:hover::after {
content: "copy";
opacity: 1;
}
.st-copyable[data-copy-state="error"]::after {
content: "copy failed";
color: var(--danger);
.st-copyable[data-copy-state="copied"]:hover::after {
content: "copied";
color: var(--live);
}
.st-copyable[data-copy-state="error"]:hover::after {
content: "copy failed";
color: var(--danger);
}
}
/* tool rows: bordered, compact, deliberately not lifted */
@@ -2593,6 +2596,7 @@ input.leaf-rename:focus {
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
min-width: 0;
transition: background var(--dur-fast) var(--ease-out);
}
.st-tool-head:hover {
@@ -2642,11 +2646,38 @@ input.leaf-rename:focus {
}
.st-tool-out {
border-top: 1px solid var(--border);
padding: var(--sp-2) var(--sp-3);
border-top: 1px solid transparent;
}
.st-disclosure {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows var(--dur) var(--ease-out),
opacity var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
.st-disclosure.is-expanded {
grid-template-rows: 1fr;
opacity: 1;
}
.st-disclosure-clip {
min-height: 0;
overflow: hidden;
}
.st-tool-out.is-expanded {
border-top-color: var(--border);
}
.st-tool-out .st-disclosure-inner {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
}
/* `display: flex` above outranks the UA sheet's `[hidden]` (same pitfall as
@@ -2689,6 +2720,109 @@ input.leaf-rename:focus {
overflow-x: auto;
}
.st-tool-fact {
display: flex;
align-items: baseline;
gap: var(--sp-2);
color: var(--faint);
font-size: var(--fs-11);
}
.st-tool-fact code {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.st-tool-exit-ok {
color: var(--live);
}
.st-tool-exit-failed {
color: var(--danger);
}
.st-tool-image {
display: block;
width: auto;
max-width: min(100%, 720px);
max-height: 480px;
border: 1px solid var(--border);
border-radius: var(--r-1);
object-fit: contain;
}
.st-tool-audio {
display: block;
width: min(100%, 480px);
min-height: var(--control-h);
}
.st-tool-resource {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: baseline;
gap: var(--sp-1) var(--sp-2);
min-width: 0;
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
background: var(--bg);
font-size: var(--fs-12);
}
.st-tool-resource-name {
color: var(--ink);
font-weight: 500;
}
.st-tool-resource-uri {
min-width: 0;
overflow-wrap: anywhere;
color: var(--muted);
}
.st-tool-resource-description {
grid-column: 1 / -1;
color: var(--muted);
}
.st-tool-terminal {
color: var(--muted);
font-family: var(--font-mono);
font-size: var(--fs-12);
}
.st-tool-raw {
color: var(--faint);
font-size: var(--fs-11);
}
.st-tool-raw summary {
width: fit-content;
padding-block: var(--sp-1);
cursor: pointer;
user-select: none;
transition: color var(--dur-fast) var(--ease-out);
}
.st-tool-raw summary:hover,
.st-tool-raw summary:focus-visible {
color: var(--ink);
}
.st-tool-raw summary:focus-visible {
border-radius: var(--r-1);
outline: 2px solid var(--accent-ring);
outline-offset: 2px;
}
.st-tool-raw-body {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding-top: var(--sp-1);
}
.st-tool-group {
min-width: 0;
border: 1px solid var(--border);
@@ -2715,6 +2849,7 @@ input.leaf-rename:focus {
gap: var(--sp-2);
padding: 0;
min-width: 0;
transition: color var(--dur-fast) var(--ease-out);
}
.st-tool-group-head:hover {
@@ -2745,12 +2880,16 @@ input.leaf-rename:focus {
}
.st-tool-group-body {
padding-top: var(--sp-2);
display: flex;
flex-direction: column;
padding-top: 0;
}
.st-tool-group-body > .st-row-tool {
.st-tool-group-body .st-disclosure-inner {
display: flex;
flex-direction: column;
padding-top: var(--sp-2);
}
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
padding: var(--sp-1) 0;
}
@@ -2758,6 +2897,21 @@ input.leaf-rename:focus {
display: none;
}
/* Live transcript rows resolve into the stream through a short occlusion,
matching data becoming concrete without replaying on token patches. */
.st-row-enter {
animation: structured-row-enter var(--dur) var(--ease-out) both;
}
@keyframes structured-row-enter {
from {
opacity: 0.2;
transform: translateY(3px);
clip-path: inset(0 0 18% 0);
filter: blur(1px);
}
}
.st-diff-block {
display: flex;
flex-direction: column;
@@ -2908,31 +3062,24 @@ input.leaf-rename:focus {
}
/* The composer is a sibling of the scrolling transcript, not part of it: it
stays reachable at the pane edge without changing transcript scroll math. */
stays reachable at the pane edge without changing transcript scroll math.
It is a flush band at the foot of the pane rather than a floating card: its
own `--surface` fill is what separates it from the transcript, over a
hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */
.structured-composer {
flex: 0 0 auto;
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
padding: var(--sp-1);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: flex-end;
gap: var(--sp-1);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
var(--shadow-1);
transition:
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
transition: border-color var(--dur-fast) var(--ease-out);
}
.structured-composer:focus-within {
border-color: var(--accent);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
0 0 0 3px var(--accent-tint),
var(--shadow-1);
border-top-color: var(--accent);
}
.structured-input-wrap {
@@ -2941,6 +3088,45 @@ input.leaf-rename:focus {
min-width: 0;
}
.structured-attachments {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-1);
}
.structured-attachments[hidden] { display: none; }
.structured-attachment {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding-left: var(--sp-2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
font-size: var(--fs-11);
}
.structured-attachment > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structured-attachment button {
flex-shrink: 0;
min-width: var(--tap);
min-height: var(--tap);
border: 0;
background: transparent;
color: var(--muted);
}
.structured-attachment button:hover { color: var(--ink); }
.structured-attachment button:disabled { opacity: 0.5; }
.structured-input {
display: block;
width: 100%;
@@ -2958,6 +3144,10 @@ input.leaf-rename:focus {
line-height: 20px;
}
.structured-input:focus-visible {
outline: none;
}
.structured-composer .structured-send,
.structured-composer .structured-cancel {
height: 40px;
@@ -3015,8 +3205,29 @@ input.leaf-rename:focus {
}
@media (max-width: 520px) {
/* One row cannot hold a draft plus three controls at phone width — the
draft gets a line of its own and the controls drop below it. */
.structured-composer {
flex-wrap: wrap;
justify-content: flex-end;
padding-left: var(--sp-2);
padding-right: var(--sp-2);
}
/* `order` keeps the draft on the first line: without it the attach button,
which precedes it in the DOM, wraps onto a line of its own. */
.structured-input-wrap {
flex: 1 1 100%;
order: -1;
}
.structured-composer .structured-config {
margin-right: auto;
}
.structured-config-menu {
right: calc(-1 * (var(--control-h) + var(--sp-2)));
left: 0;
right: auto;
}
}
@@ -3117,18 +3328,18 @@ input.leaf-rename:focus {
.act,
.st-copyable,
.st-copyable::after,
.st-disclosure,
.st-tool-head,
.st-tool-group-glyph {
transition-duration: 0.01ms;
}
.structured-activity-pulse {
.st-row-enter {
animation: none;
}
}
@media (hover: none) {
.st-copyable::after {
opacity: 0.7;
.structured-activity-pulse {
animation: none;
}
}
+21 -7
View File
@@ -56,7 +56,7 @@ export interface Session {
* how the command bar's empty query sorts a never-focused session after every
* session that has one (`docs/COMMAND-BAR.md`). */
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
* succeeded has one; a respawn passes it to `session/load` (#135). */
acp_session_id?: string | null;
@@ -485,14 +485,28 @@ export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "d
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a
* generic placeholder rather than being dropped silently. */
export interface AcpEmbeddedResource {
uri?: string;
mimeType?: string;
text?: string;
blob?: string;
}
/** `ContentBlock`, including the fields needed to render typed tool output. */
export type AcpContentBlock =
| { type: "text"; text: string }
| { type: "image"; mimeType?: string }
| { type: "audio"; mimeType?: string }
| { type: "resource_link"; uri?: string; name?: string }
| { type: "resource"; resource?: unknown };
| { type: "image"; data?: string; mimeType?: string; uri?: string | null }
| { type: "audio"; data?: string; mimeType?: string }
| {
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. */
export interface AcpContentChunk {
+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"]
}