Compare commits

..
Author SHA1 Message Date
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
30 changed files with 1391 additions and 305 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());
}
+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();
+66 -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 {
@@ -1250,14 +1261,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 +1280,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 +1689,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 +2991,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 +3026,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."}}}
+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
+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
+3 -1
View File
@@ -3053,17 +3053,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;
+60 -16
View File
@@ -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,
@@ -256,7 +256,7 @@ pub struct DraftPane {
/// 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.
/// when the resolved command is Claude Code or Codex.
pub structured_view: bool,
/// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
@@ -673,7 +673,7 @@ impl DraftPane {
}
fn supports_structured_view(&self) -> bool {
command_tool(self.command()) == Some("claude")
matches!(command_tool(self.command()), Some("claude" | "codex"))
}
fn clear_unsupported_structured_view(&mut self) {
@@ -1888,11 +1888,28 @@ mod tests {
}
#[test]
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
fn structured_view_is_default_off_and_visible_for_supported_agents() {
let mut draft = DraftPane {
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(),
},
SpawnPreset {
id: "opencode".into(),
label: "OpenCode".into(),
command: "opencode".into(),
},
]);
assert!(!draft.structured_view);
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
@@ -1901,6 +1918,12 @@ mod tests {
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!(
!draft.structured_view,
@@ -1928,9 +1951,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 +1975,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),
@@ -1999,7 +2032,7 @@ mod tests {
/// 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 +2047,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(),
@@ -2027,7 +2065,13 @@ mod tests {
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 +2080,11 @@ mod tests {
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from Claude clears an armed
/// 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 +2093,22 @@ mod tests {
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("claude");
draft.custom_command = crate::text_input::TextInput::with_value("codex");
draft.toggle_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);
}
+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
+33 -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.
@@ -37,30 +37,26 @@ ACP handle instead of a tmux pane).
## 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 +65,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 +92,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
+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;
}
+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({
+9 -2
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";
}
@@ -369,7 +376,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let commandTouched = false;
function updateStructuredAvailability(): void {
const supported = splitCommand(command).tool === "claude";
const supported = supportsStructuredView(command);
structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false;
}
+25 -11
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-off and shown for resolved Claude Code and Codex commands", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
@@ -59,26 +59,33 @@ describe("spawn form structured view", () => {
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(false);
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);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(true);
});
it("clears a checked value when the command changes away from Claude Code", async () => {
it("clears a checked value 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]")!;
checkbox.click();
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 +93,7 @@ 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 selected %s command", async (tool) => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
@@ -95,13 +102,20 @@ describe("spawn form structured view", () => {
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.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit();
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({
command: "claude",
command: tool,
view: "Structured",
});
});
+67 -2
View File
@@ -115,6 +115,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 +325,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 +333,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 +359,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 +1291,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 +1307,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 +1322,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();
});
+68 -11
View File
@@ -114,6 +114,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;
@@ -148,6 +149,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 +173,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 +194,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 +215,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 +246,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 +303,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 +364,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 +379,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 +415,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 +436,7 @@ export function createStructuredConnection(
}
rebuildTranscript();
syncAvailableCommands();
});
}, false, true);
}
function sessionConfigOptions(): AcpSessionConfigOption[] {
@@ -703,7 +751,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();
@@ -912,6 +960,7 @@ export function createStructuredConnection(
pendingScroll = {
top: parkedScrollTop ?? scrollEl.scrollTop,
follow: followingBottom,
smooth: false,
};
parkedScrollTop = null;
finishTranscriptLayout();
@@ -927,6 +976,7 @@ export function createStructuredConnection(
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
scrollWorkActive = false;
cancelScrollFrame();
stopSmoothFollow();
cancelFocusFrame();
},
focusTerminal() {
@@ -936,6 +986,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 +997,7 @@ export function createStructuredConnection(
mountToken++;
scrollWorkActive = false;
cancelScrollFrame();
stopSmoothFollow();
cancelFocusFrame();
cancelReconnect();
closeCurrentSocket();
@@ -950,6 +1005,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);
+189 -26
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;
@@ -3015,6 +3169,15 @@ input.leaf-rename:focus {
}
@media (max-width: 520px) {
.structured-composer {
margin: var(--sp-1) 0 0;
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: var(--r-2) var(--r-2) 0 0;
}
.structured-config-menu {
right: calc(-1 * (var(--control-h) + var(--sp-2)));
}
@@ -3117,18 +3280,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 {