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 0c46fb993a fix: recover PWA after backgrounding 2026-09-06 18:02:11 +01:00
Miguel Palhas be554d0ede feat: render structured responses as markdown 2026-09-06 17:31:15 +01:00
Miguel Palhas 4fda393378 polish: refine structured composer 2026-09-06 17:06:40 +01:00
Miguel Palhas a1f3690aaf feat: show structured turn activity 2026-09-06 16:50:46 +01:00
Miguel Palhas 4ce432c227 fix: move session picker to composer 2026-09-06 16:38:50 +01:00
Miguel Palhas ba7fd68004 feat: add structured session picker 2026-09-06 16:18:33 +01:00
Miguel Palhas c3d008d07f fix: autocomplete at composer caret 2026-09-06 15:52:04 +01:00
Miguel Palhas c355f67133 feat: autocomplete structured commands 2026-09-06 15:37:49 +01:00
Miguel Palhas b5c9970e28 fix: extend transcript hover surfaces 2026-09-06 15:26:31 +01:00
Miguel Palhas 22d740a36b feat: streamline structured transcript 2026-09-06 15:17:40 +01:00
Miguel Palhas ccdfec8257 fix(daemon): persist structured user prompts 2026-09-06 14:19:45 +01:00
Miguel Palhas 30f91176ae fix(daemon): coalesce anonymous ACP text chunks 2026-09-06 14:10:11 +01:00
Miguel Palhas a771d29210 fix(web): separate transcript scrolling from composer 2026-09-06 14:01:43 +01:00
Miguel Palhas 913421e244 fix(web): anchor structured composer on mobile 2026-09-05 18:15:38 +01:00
Miguel Palhas 2e7c5f7f58 fix: recover PWA after backgrounding
ci / web (push) Successful in 48s
ci / flake (push) Successful in 2m56s
ci / rust (push) Successful in 6m4s
2026-09-05 18:10:21 +01:00
Miguel Palhas dba3d8c249 Merge issue #199: align structured command resolution 2026-09-05 14:28:12 +01:00
Miguel Palhas 3529947497 Merge branch 'fix/codex-attach-scroll'
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 3m3s
ci / rust (push) Successful in 7m35s
2026-09-05 03:49:35 +01:00
Miguel Palhas 4851ad9101 fix(maestro): follow initial attach replay 2026-09-05 03:48:35 +01:00
Miguel Palhas 3cb4bebd1e fix(maestro): update second stale worktree test for branch suffixing
ci / web (push) Successful in 54s
ci / flake (push) Successful in 3m8s
ci / rust (push) Successful in 8m30s
second_spawn_on_same_branch_is_rejected asserted the same pre-#77
outright-rejection contract as the unit test fixed in 0e20baf, just at
the e2e/session level — also broken by 49ab7e9's branch-collision
suffixing and also already failing on main.

Renamed to second_spawn_on_same_branch_gets_suffixed and rewritten to
assert the actual behavior: the second spawn succeeds on a `-2`
suffixed branch and directory, and the first worktree is untouched.
2026-09-04 20:08:16 +01:00
Miguel Palhas 0e20baf7c2 fix(maestro): update stale worktree test for branch-collision suffixing
ci / web (push) Failing after 38s
ci / flake (push) Successful in 4m17s
ci / rust (push) Failing after 7m40s
add_fails_when_branch_already_exists asserted the pre-#77 behavior:
that a branch collision errors outright. Since 49ab7e9, add_new
sidesteps a branch collision with a numeric suffix instead, so the
test was failing CI on main regardless of any given change.

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

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

A malformed --config file makes cli/daemon/tui fail identically at
config load, before touching tmux, the network, or a terminal. That
turns "clap parsed it but the handler never ran" (a usage error, exit
2) into a distinguishable outcome from "it dispatched into the real
crate" (a `toml decode` runtime error via main's error printer, exit
1). The absence-side tests are feature-gated to only exist in a
single-feature build, so they run under CI's existing per-feature
matrix rather than the default all-features build.
2026-09-04 19:47:32 +01:00
Miguel Palhas 1ad7845f51 fix(maestro): refresh attach header when a session is renamed
Header name/command were baked into innerHTML at connection creation
and never revisited. ConnectionPool.mount() reused the cached
connection on later calls but dropped the fresh info, so a header
never picked up haiku autoname's rename.
2026-09-04 19:38:09 +01:00
44 changed files with 4035 additions and 870 deletions
Generated
+2
View File
@@ -1467,6 +1467,7 @@ dependencies = [
"maestro-cli", "maestro-cli",
"maestro-daemon", "maestro-daemon",
"maestro-tui", "maestro-tui",
"tempfile",
"tokio", "tokio",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -1477,6 +1478,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"libc", "libc",
"serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
+2 -1
View File
@@ -5,11 +5,12 @@ edition.workspace = true
license.workspace = true license.workspace = true
repository.workspace = true repository.workspace = true
publish.workspace = true publish.workspace = true
description = "ACP client role: one claude-code-acp child per structured session" description = "ACP client role: one provider adapter child per structured session"
[dependencies] [dependencies]
agent-client-protocol.workspace = true agent-client-protocol.workspace = true
libc.workspace = true libc.workspace = true
serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true tokio.workspace = true
+415 -48
View File
@@ -1,5 +1,5 @@
//! ACP client role for the daemon (#134): one `claude-code-acp` child per //! ACP client role for the daemon: one provider adapter child per structured
//! structured session. //! session.
//! //!
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the //! The daemon is the client; the adapter is the agent. [`spawn`] starts the
//! adapter as a child process with stdio pipes, in the session's `cwd` with //! adapter as a child process with stdio pipes, in the session's `cwd` with
@@ -9,10 +9,10 @@
//! //!
//! **The handshake declares almost nothing.** `initialize` reports //! **The handshake declares almost nothing.** `initialize` reports
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the //! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the //! adapter uses the provider's own file and command tools instead of asking
//! daemon to act on its behalf — exactly what already happens in the //! the daemon to act on its behalf — exactly what already happens in the
//! terminal view. `session/request_permission` is the one client method that //! terminal view. `session/request_permission` is the one client method with
//! gets a real implementation, in #139; here it is a stub that declines. //! a real implementation.
//! //!
//! Every `session/update` notification the adapter sends lands on an //! Every `session/update` notification the adapter sends lands on an
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing //! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
@@ -42,30 +42,62 @@ use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use agent_client_protocol::schema::v1::{ use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, Implementation, CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionNotification, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, SetSessionModeRequest,
}; };
use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client}; use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncBufReadExt; use tokio::io::AsyncBufReadExt;
use tokio::sync::{mpsc, oneshot, watch}; use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema; pub use agent_client_protocol::schema::v1 as schema;
/// The adapter binary, resolved on `PATH`. /// ACP adapter selected from the structured session's resolved command.
pub const ADAPTER_BIN: &str = "claude-code-acp"; #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
ClaudeCode,
Codex,
}
/// How to install the adapter when it is missing. Named in the error so an impl AcpAdapter {
/// operator staring at a failed session knows what to run. pub const fn binary(self) -> &'static str {
match self {
Self::ClaudeCode => "claude-code-acp",
Self::Codex => "codex-acp",
}
}
pub const fn install_command(self) -> &'static str {
match self {
Self::ClaudeCode => "npm install -g --prefix ~/.local @zed-industries/claude-code-acp",
Self::Codex => "npm install -g --prefix ~/.local @agentclientprotocol/codex-acp",
}
}
}
impl std::fmt::Display for AcpAdapter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::ClaudeCode => "Claude Code",
Self::Codex => "Codex",
})
}
}
/// Compatibility aliases for Claude Code callers.
pub const ADAPTER_BIN: &str = "claude-code-acp";
pub const INSTALL_COMMAND: &str = pub const INSTALL_COMMAND: &str =
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp"; "npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
/// How long `initialize` + `session/new` may take before the spawn is /// How long `initialize` + `session/new` may take before the spawn is
/// declared failed. The real adapter boots a node process and the Claude /// declared failed. The real adapters boot a node process and their provider
/// Code SDK behind it; a slow machine takes seconds, never a minute. /// client behind it; a slow machine takes seconds, never a minute.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60); pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
/// How long [`AcpSession::terminate`] waits after `session/cancel` before /// How long [`AcpSession::terminate`] waits after `session/cancel` before
@@ -78,6 +110,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2);
/// Bytes of the child's stderr kept for the failure message. /// Bytes of the child's stderr kept for the failure message.
const STDERR_TAIL_BYTES: usize = 4096; const STDERR_TAIL_BYTES: usize = 4096;
const LEGACY_MODEL_CONFIG_ID: &str = "__maestro_legacy_model";
const LEGACY_MODE_CONFIG_ID: &str = "__maestro_legacy_mode";
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/new", response = ExtendedNewSessionResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedNewSessionRequest {
cwd: PathBuf,
mcp_servers: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedNewSessionResponse {
session_id: SessionId,
#[serde(default)]
modes: Option<SessionModeState>,
#[serde(default)]
config_options: Option<Vec<SessionConfigOption>>,
#[serde(default)]
models: Option<LegacyModelState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/load", response = ExtendedLoadSessionResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedLoadSessionRequest {
session_id: SessionId,
cwd: PathBuf,
mcp_servers: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedLoadSessionResponse {
#[serde(default)]
modes: Option<SessionModeState>,
#[serde(default)]
config_options: Option<Vec<SessionConfigOption>>,
#[serde(default)]
models: Option<LegacyModelState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyModelState {
current_model_id: String,
available_models: Vec<LegacyModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyModel {
model_id: String,
name: String,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/set_model", response = LegacySetModelResponse)]
#[serde(rename_all = "camelCase")]
struct LegacySetModelRequest {
session_id: SessionId,
model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
struct LegacySetModelResponse {}
/// What [`spawn`] needs to start one adapter. /// What [`spawn`] needs to start one adapter.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -94,6 +195,8 @@ pub struct SpawnConfig {
/// instead of opening a fresh one. The adapter must advertise /// instead of opening a fresh one. The adapter must advertise
/// `loadSession`; the handshake fails otherwise. /// `loadSession`; the handshake fails otherwise.
pub load_session: Option<String>, pub load_session: Option<String>,
/// The provider-specific adapter to launch.
pub adapter: AcpAdapter,
} }
impl SpawnConfig { impl SpawnConfig {
@@ -103,6 +206,7 @@ impl SpawnConfig {
env, env,
handshake_timeout: HANDSHAKE_TIMEOUT, handshake_timeout: HANDSHAKE_TIMEOUT,
load_session: None, load_session: None,
adapter: AcpAdapter::ClaudeCode,
} }
} }
@@ -111,6 +215,11 @@ impl SpawnConfig {
self.load_session = Some(session_id.into()); self.load_session = Some(session_id.into());
self self
} }
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
self.adapter = adapter;
self
}
} }
/// A blocked ACP permission request delivered to the daemon. /// A blocked ACP permission request delivered to the daemon.
@@ -126,31 +235,39 @@ pub struct PermissionRequest {
pub enum AcpError { pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command /// The adapter is not on `PATH`. The message names the install command
/// so the failed session is actionable rather than silently dead. /// so the failed session is actionable rather than silently dead.
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")] #[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
AdapterMissing, AdapterMissing { adapter: AcpAdapter },
#[error("failed to start {ADAPTER_BIN}: {0}")] #[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
Spawn(#[source] std::io::Error), Spawn {
adapter: AcpAdapter,
#[source]
source: std::io::Error,
},
/// The adapter answered the handshake with an error. /// The adapter answered the handshake with an error.
#[error("ACP handshake failed: {0}")] #[error("ACP handshake failed: {0}")]
Handshake(String), Handshake(String),
/// The adapter went away before the handshake finished. Carries the /// The adapter went away before the handshake finished. Carries the
/// tail of its stderr, which is where a missing Claude login shows up. /// tail of its stderr, which is where a missing Claude login shows up.
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")] #[error("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
ExitedEarly { status: String, stderr: String }, ExitedEarly {
adapter: AcpAdapter,
status: String,
stderr: String,
},
#[error("ACP handshake timed out after {0:?}")] #[error("ACP handshake timed out after {0:?}")]
HandshakeTimeout(Duration), HandshakeTimeout(Duration),
} }
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the /// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
/// session env's `PATH` when it sets one, the daemon's own otherwise. /// session env's `PATH` when it sets one, the daemon's own otherwise.
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> { pub fn resolve_adapter(adapter: AcpAdapter, env: &HashMap<String, String>) -> Option<PathBuf> {
let path: OsString = match env.get("PATH") { let path: OsString = match env.get("PATH") {
Some(p) => OsString::from(p), Some(p) => OsString::from(p),
None => std::env::var_os("PATH")?, None => std::env::var_os("PATH")?,
}; };
std::env::split_paths(&path) std::env::split_paths(&path)
.filter(|dir| !dir.as_os_str().is_empty()) .filter(|dir| !dir.as_os_str().is_empty())
.map(|dir| dir.join(ADAPTER_BIN)) .map(|dir| dir.join(adapter.binary()))
.find(|candidate| is_executable(candidate)) .find(|candidate| is_executable(candidate))
} }
@@ -168,9 +285,20 @@ enum Command {
turn: u64, turn: u64,
done: oneshot::Sender<Result<(), String>>, done: oneshot::Sender<Result<(), String>>,
}, },
SetConfig {
config_id: String,
value: SessionConfigOptionValue,
done: oneshot::Sender<Result<(), String>>,
},
Close, Close,
} }
struct ConnectionConfig {
cwd: PathBuf,
load_session: Option<String>,
adapter: AcpAdapter,
}
enum ChildCommand { enum ChildCommand {
Signal(libc::c_int), Signal(libc::c_int),
Reap(oneshot::Sender<bool>), Reap(oneshot::Sender<bool>),
@@ -301,6 +429,23 @@ impl AcpSession {
Ok(done_rx) Ok(done_rx)
} }
/// Change one option advertised by the ACP agent.
pub fn set_config(
&self,
config_id: String,
value: SessionConfigOptionValue,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::SetConfig {
config_id,
value,
done: done_tx,
})
.map_err(|_| "ACP connection is closed".to_string())?;
Ok(done_rx)
}
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait /// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped. /// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) { pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
@@ -341,7 +486,9 @@ impl Drop for AcpSession {
/// Fails without a running child: on any error the process is already /// Fails without a running child: on any error the process is already
/// signalled, so a failed session never leaves a stray adapter behind. /// signalled, so a failed session never leaves a stray adapter behind.
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> { pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?; let provider = config.adapter;
let adapter = resolve_adapter(provider, &config.env)
.ok_or(AcpError::AdapterMissing { adapter: provider })?;
let mut cmd = tokio::process::Command::new(&adapter); let mut cmd = tokio::process::Command::new(&adapter);
cmd.current_dir(&config.cwd) cmd.current_dir(&config.cwd)
@@ -369,18 +516,27 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
}); });
} }
} }
let mut child = cmd.spawn().map_err(AcpError::Spawn)?; let mut child = cmd.spawn().map_err(|source| AcpError::Spawn {
adapter: provider,
source,
})?;
let pid = child.id().expect("child pid is known before wait"); let pid = child.id().expect("child pid is known before wait");
let stdin = child.stdin.take().expect("piped stdin"); let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout"); let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr"); let stderr = child.stderr.take().expect("piped stderr");
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new())); let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone())); tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone(), provider));
let (exit_tx, exit_rx) = watch::channel(None); let (exit_tx, exit_rx) = watch::channel(None);
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel(); let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx)); tokio::spawn(supervise_child(
child,
pid,
child_commands_rx,
exit_tx,
provider,
));
let (updates_tx, updates_rx) = mpsc::unbounded_channel(); let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (permission_tx, permission_rx) = mpsc::unbounded_channel(); let (permission_tx, permission_rx) = mpsc::unbounded_channel();
@@ -389,8 +545,11 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
tokio::spawn(run_connection( tokio::spawn(run_connection(
transport, transport,
config.cwd.clone(), ConnectionConfig {
config.load_session.clone(), cwd: config.cwd.clone(),
load_session: config.load_session.clone(),
adapter: provider,
},
updates_tx, updates_tx,
permission_tx, permission_tx,
commands_rx, commands_rx,
@@ -423,6 +582,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
.to_string() .to_string()
}; };
AcpError::ExitedEarly { AcpError::ExitedEarly {
adapter: provider,
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()), status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
stderr: if stderr.is_empty() { stderr: if stderr.is_empty() {
String::new() String::new()
@@ -457,12 +617,13 @@ async fn supervise_child(
pid: u32, pid: u32,
mut commands: mpsc::UnboundedReceiver<ChildCommand>, mut commands: mpsc::UnboundedReceiver<ChildCommand>,
exit_tx: watch::Sender<Option<ExitStatus>>, exit_tx: watch::Sender<Option<ExitStatus>>,
adapter: AcpAdapter,
) { ) {
loop { loop {
tokio::select! { tokio::select! {
status = child.wait() => { status = child.wait() => {
let status = status.ok(); let status = status.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited"); tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(status); let _ = exit_tx.send(status);
return; return;
} }
@@ -476,7 +637,7 @@ async fn supervise_child(
Some(ChildCommand::Reap(done)) => match child.try_wait() { Some(ChildCommand::Reap(done)) => match child.try_wait() {
Ok(Some(status)) => { Ok(Some(status)) => {
let _ = done.send(true); let _ = done.send(true);
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited"); tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(Some(status)); let _ = exit_tx.send(Some(status));
return; return;
} }
@@ -484,7 +645,7 @@ async fn supervise_child(
let _ = done.send(false); let _ = done.send(false);
} }
Err(error) => { Err(error) => {
tracing::warn!(pid, %error, "could not reap {ADAPTER_BIN}"); tracing::warn!(pid, %error, provider = %adapter, "could not reap ACP adapter");
let _ = done.send(false); let _ = done.send(false);
} }
}, },
@@ -494,7 +655,7 @@ async fn supervise_child(
// a reused PID. // a reused PID.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
let status = child.wait().await.ok(); let status = child.wait().await.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited"); tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(status); let _ = exit_tx.send(status);
return; return;
} }
@@ -513,10 +674,104 @@ fn client_capabilities() -> ClientCapabilities {
.terminal(false) .terminal(false)
} }
fn advertised_config_options(
mut options: Vec<SessionConfigOption>,
modes: Option<&SessionModeState>,
models: Option<&LegacyModelState>,
) -> Vec<SessionConfigOption> {
let has_model = options
.iter()
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model)));
let has_mode = options
.iter()
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Mode)));
if let Some(models) = models.filter(|_| !has_model) {
let mut choices = models
.available_models
.iter()
.map(|model| {
let mut option =
SessionConfigSelectOption::new(model.model_id.clone(), &model.name);
if model.model_id == "default" {
option.name = "Opus (default)".into();
}
option.description.clone_from(&model.description);
option
})
.collect::<Vec<_>>();
if !models
.available_models
.iter()
.any(|model| model.model_id == "fable")
{
choices.push(SessionConfigSelectOption::new("fable", "Fable"));
}
options.push(
SessionConfigOption::select(
LEGACY_MODEL_CONFIG_ID,
"Model",
models.current_model_id.clone(),
choices,
)
.category(SessionConfigOptionCategory::Model),
);
}
if let Some(modes) = modes.filter(|_| !has_mode) {
let choices = modes
.available_modes
.iter()
.map(|mode| {
let mut option = SessionConfigSelectOption::new(mode.id.0.clone(), &mode.name);
option.description.clone_from(&mode.description);
option
})
.collect::<Vec<_>>();
options.push(
SessionConfigOption::select(
LEGACY_MODE_CONFIG_ID,
"Mode",
modes.current_mode_id.0.clone(),
choices,
)
.category(SessionConfigOptionCategory::Mode),
);
}
options
}
fn replace_config_value(
options: &Arc<Mutex<Vec<SessionConfigOption>>>,
config_id: &str,
value: &SessionConfigOptionValue,
) -> Vec<SessionConfigOption> {
let mut options = options.lock().expect("config options");
if let Some(option) = options
.iter_mut()
.find(|option| option.id.0.as_ref() == config_id)
{
match (&mut option.kind, value) {
(SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => {
select.current_value = value.clone();
}
(SessionConfigKind::Boolean(boolean), SessionConfigOptionValue::Boolean { value }) => {
boolean.current_value = *value;
}
_ => {}
}
}
options.clone()
}
fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> SessionNotification {
SessionNotification::new(
SessionId::new(session_id),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)),
)
}
async fn run_connection<T>( async fn run_connection<T>(
transport: T, transport: T,
cwd: PathBuf, config: ConnectionConfig,
load_session: Option<String>,
updates_tx: mpsc::UnboundedSender<SessionNotification>, updates_tx: mpsc::UnboundedSender<SessionNotification>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>, permission_requests: mpsc::UnboundedSender<PermissionRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>, mut commands_rx: mpsc::UnboundedReceiver<Command>,
@@ -534,6 +789,7 @@ async fn run_connection<T>(
// until a cancelled prompt settles, so this active turn is unambiguous. // until a cancelled prompt settles, so this active turn is unambiguous.
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default())); let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
let permission_turn_seen = permission_turn.clone(); let permission_turn_seen = permission_turn.clone();
let received_updates = updates_tx.clone();
let result = Client let result = Client
.builder() .builder()
.name("maestro") .name("maestro")
@@ -544,7 +800,7 @@ async fn run_connection<T>(
return Ok(()); return Ok(());
} }
// A dropped receiver only means nobody wants updates any more. // A dropped receiver only means nobody wants updates any more.
let _ = updates_tx.send(notification); let _ = received_updates.send(notification);
Ok(()) Ok(())
}, },
agent_client_protocol::on_receive_notification!(), agent_client_protocol::on_receive_notification!(),
@@ -589,11 +845,13 @@ async fn run_connection<T>(
return Ok(()); return Ok(());
} }
}; };
let session_id = match load_session { let (session_id, modes, initial_options, models) = match config.load_session {
Some(existing) => { Some(existing) => {
if !initialized.agent_capabilities.load_session { if !initialized.agent_capabilities.load_session {
let _ = ready_tx.send(Err(ReadyError::Handshake(format!( let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}" "{} ACP adapter ({}) does not advertise loadSession; cannot resume session {existing}",
config.adapter,
config.adapter.binary()
)))); ))));
return Ok(()); return Ok(());
} }
@@ -601,7 +859,11 @@ async fn run_connection<T>(
let (loaded_tx, loaded_rx) = oneshot::channel(); let (loaded_tx, loaded_rx) = oneshot::channel();
let replaying_done = replaying.clone(); let replaying_done = replaying.clone();
if let Err(e) = cx if let Err(e) = cx
.send_request(LoadSessionRequest::new(existing.clone(), cwd)) .send_request(ExtendedLoadSessionRequest {
session_id: SessionId::new(existing.as_str()),
cwd: config.cwd,
mcp_servers: vec![],
})
.on_receiving_result(move |result| { .on_receiving_result(move |result| {
replaying_done.store(false, Ordering::SeqCst); replaying_done.store(false, Ordering::SeqCst);
async move { async move {
@@ -613,8 +875,8 @@ async fn run_connection<T>(
let _ = ready_tx.send(Err(ready_error(e))); let _ = ready_tx.send(Err(ready_error(e)));
return Ok(()); return Ok(());
} }
match loaded_rx.await { let loaded = match loaded_rx.await {
Ok(Ok(_)) => {} Ok(Ok(loaded)) => loaded,
Ok(Err(e)) => { Ok(Err(e)) => {
let _ = ready_tx.send(Err(ready_error(e))); let _ = ready_tx.send(Err(ready_error(e)));
return Ok(()); return Ok(());
@@ -625,21 +887,48 @@ async fn run_connection<T>(
))); )));
return Ok(()); return Ok(());
} }
} };
existing (existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models)
} }
None => match cx None => match cx
.send_request(NewSessionRequest::new(cwd)) .send_request(ExtendedNewSessionRequest {
cwd: config.cwd,
mcp_servers: vec![],
})
.block_task() .block_task()
.await .await
{ {
Ok(created) => created.session_id.0.to_string(), Ok(created) => (
created.session_id.0.to_string(),
created.modes,
created.config_options.unwrap_or_default(),
created.models,
),
Err(e) => { Err(e) => {
let _ = ready_tx.send(Err(ready_error(e))); let _ = ready_tx.send(Err(ready_error(e)));
return Ok(()); return Ok(());
} }
}, },
}; };
let config_options = Arc::new(Mutex::new(advertised_config_options(
initial_options,
modes.as_ref(),
models.as_ref(),
)));
if let Some(modes) = &modes {
let _ = updates_tx.send(SessionNotification::new(
SessionId::new(session_id.as_str()),
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
modes.current_mode_id.clone(),
)),
));
}
if !config_options.lock().expect("config options").is_empty() {
let _ = updates_tx.send(config_notification(
&session_id,
config_options.lock().expect("config options").clone(),
));
}
let _ = ready_tx.send(Ok(session_id.clone())); let _ = ready_tx.send(Ok(session_id.clone()));
loop { loop {
@@ -689,6 +978,83 @@ async fn run_connection<T>(
} }
} }
} }
Some(Command::SetConfig { config_id, value, done }) => {
let done = Arc::new(Mutex::new(Some(done)));
let callback_done = done.clone();
let callback_options = config_options.clone();
let callback_updates = updates_tx.clone();
let callback_session = session_id.clone();
let callback_config_id = config_id.clone();
let callback_value = value.clone();
let sent = if config_id == LEGACY_MODEL_CONFIG_ID {
let Some(model_id) = value.as_value_id() else {
let _ = done.lock().expect("config sender").take()
.map(|done| done.send(Err("model must be a string value".into())));
continue;
};
cx.send_request(LegacySetModelRequest {
session_id: SessionId::new(session_id.as_str()),
model_id: model_id.0.to_string(),
}).on_receiving_result(move |result| async move {
if result.is_ok() {
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
let _ = callback_updates.send(config_notification(&callback_session, options));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
}
Ok(())
})
} else if config_id == LEGACY_MODE_CONFIG_ID {
let Some(mode_id) = value.as_value_id() else {
let _ = done.lock().expect("config sender").take()
.map(|done| done.send(Err("mode must be a string value".into())));
continue;
};
cx.send_request(SetSessionModeRequest::new(
SessionId::new(session_id.as_str()),
mode_id.0.to_string(),
)).on_receiving_result(move |result| async move {
if result.is_ok() {
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
let _ = callback_updates.send(config_notification(&callback_session, options));
let _ = callback_updates.send(SessionNotification::new(
SessionId::new(callback_session.as_str()),
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
callback_value.as_value_id().expect("checked mode value").0.clone(),
)),
));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
}
Ok(())
})
} else {
cx.send_request(SetSessionConfigOptionRequest::new(
SessionId::new(session_id.as_str()),
config_id,
value,
))
.on_receiving_result(move |result| async move {
if let Ok(response) = &result {
let mut current = callback_options.lock().expect("config options");
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
current.extend(response.config_options.clone());
let _ = callback_updates.send(config_notification(&callback_session, current.clone()));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
}
Ok(())
})
};
if let Err(error) = sent {
if let Some(done) = done.lock().expect("config sender").take() {
let _ = done.send(Err(error.to_string()));
}
}
}
Some(Command::Close) | None => break, Some(Command::Close) | None => break,
}, },
_ = cx.incoming_closed() => break, _ = cx.incoming_closed() => break,
@@ -706,10 +1072,11 @@ async fn drain_stderr(
stderr: tokio::process::ChildStderr, stderr: tokio::process::ChildStderr,
pid: u32, pid: u32,
tail: Arc<Mutex<VecDeque<u8>>>, tail: Arc<Mutex<VecDeque<u8>>>,
adapter: AcpAdapter,
) { ) {
let mut lines = tokio::io::BufReader::new(stderr).lines(); let mut lines = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(pid, "{ADAPTER_BIN}: {line}"); tracing::debug!(pid, provider = %adapter, "ACP adapter: {line}");
let mut tail = tail.lock().expect("stderr tail"); let mut tail = tail.lock().expect("stderr tail");
tail.extend(line.as_bytes()); tail.extend(line.as_bytes());
tail.push_back(b'\n'); tail.push_back(b'\n');
+48 -2
View File
@@ -36,6 +36,7 @@ Environment knobs, all optional:
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
JSON-RPC error JSON-RPC error
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
callback until a later prompt callback until a later prompt
@@ -78,6 +79,41 @@ prompt_failures = 0
pending_prompt_id = None pending_prompt_id = None
pending_permission_id = None pending_permission_id = None
delayed_permission = False delayed_permission = False
current_model = "opus"
current_mode = "default"
current_effort = "high"
def settings_response(include_session=False):
result = {}
if include_session:
result["sessionId"] = session_id
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
result.update({
"models": {
"currentModelId": current_model,
"availableModels": [
{"modelId": "opus", "name": "Opus"},
{"modelId": "sonnet", "name": "Sonnet"},
],
},
"modes": {
"currentModeId": current_mode,
"availableModes": [
{"id": "default", "name": "Default"},
{"id": "plan", "name": "Plan"},
],
},
"configOptions": [{
"id": "effort", "name": "Effort", "category": "thought_level",
"type": "select", "currentValue": current_effort,
"options": [
{"value": "low", "name": "Low"},
{"value": "high", "name": "High"},
],
}],
})
return result
def chunk(text): def chunk(text):
@@ -139,7 +175,7 @@ for raw in sys.stdin:
while gate and not os.path.exists(gate): while gate and not os.path.exists(gate):
time.sleep(0.01) time.sleep(0.01)
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000) time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}}) send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
send(chunk("hello from fake")) send(chunk("hello from fake"))
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1": if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
sys.exit(0) sys.exit(0)
@@ -152,8 +188,18 @@ for raw in sys.stdin:
continue continue
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))): for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
send(chunk(f"replayed {i + 1}")) send(chunk(f"replayed {i + 1}"))
send({"jsonrpc": "2.0", "id": rid, "result": {}}) send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
send(chunk("hello after load")) send(chunk("hello after load"))
elif method == "session/set_model":
current_model = params.get("modelId")
send({"jsonrpc": "2.0", "id": rid, "result": {}})
elif method == "session/set_mode":
current_mode = params.get("modeId")
send({"jsonrpc": "2.0", "id": rid, "result": {}})
elif method == "session/set_config_option":
current_effort = params.get("value")
send({"jsonrpc": "2.0", "id": rid, "result": {
"configOptions": settings_response()["configOptions"]}})
elif method == "session/cancel": elif method == "session/cancel":
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1": if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
send(chunk("final on cancel")) send(chunk("final on cancel"))
+120 -9
View File
@@ -1,12 +1,12 @@
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus //! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
//! one ignored test against the real `claude-code-acp`. //! ignored smoke tests against each maintained adapter.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use maestro_acp::schema::{ContentBlock, SessionUpdate}; use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND}; use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use tempfile::TempDir; use tempfile::TempDir;
fn fake_agent() -> PathBuf { fn fake_agent() -> PathBuf {
@@ -25,6 +25,7 @@ impl Fake {
let bin = dir.path().join("bin"); let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap(); std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap(); std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir } Self { dir }
} }
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env)) let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await .await
.expect_err("no adapter on PATH"); .expect_err("no adapter on PATH");
assert!(matches!(err, AcpError::AdapterMissing)); assert!(matches!(
err,
AcpError::AdapterMissing {
adapter: AcpAdapter::ClaudeCode
}
));
let msg = err.to_string(); let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{msg}"); assert!(msg.contains(ADAPTER_BIN), "{msg}");
assert!(msg.contains(INSTALL_COMMAND), "{msg}"); assert!(msg.contains(INSTALL_COMMAND), "{msg}");
} }
#[test]
fn codex_adapter_has_its_own_binary_and_install_guidance() {
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
assert!(AcpAdapter::Codex
.install_command()
.contains("@agentclientprotocol/codex-acp"));
}
#[tokio::test]
async fn spawn_selects_the_codex_adapter() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await
.expect("spawn Codex adapter");
assert!(fake.log().contains("recv session/new"));
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test] #[tokio::test]
async fn session_updates_reach_the_taken_receiver() { async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new(); let fake = Fake::new();
@@ -180,11 +207,68 @@ async fn session_updates_reach_the_taken_receiver() {
.await; .await;
} }
#[tokio::test]
async fn advertised_settings_are_normalized_and_mutable() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
let mut updates = session.take_updates().expect("updates");
let initial = loop {
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("settings update")
.expect("channel open");
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
break options.config_options;
}
};
assert_eq!(initial.len(), 3);
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "effort"));
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
let model = initial
.iter()
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
.expect("legacy model option");
let model = serde_json::to_value(model).unwrap();
assert_eq!(model["options"][0]["name"], "Opus");
assert!(model["options"]
.as_array()
.unwrap()
.iter()
.any(|option| option["value"] == "fable"));
let changed = session
.set_config(
"__maestro_legacy_model".into(),
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
)
.expect("queue model change");
changed
.await
.expect("response delivered")
.expect("model changed");
fake.wait_for_log("recv session/set_model").await;
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test] #[tokio::test]
async fn terminate_sends_cancel_then_sigterm() { async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path())) let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await .await
.expect("spawn"); .expect("spawn");
let pid = session.pid(); let pid = session.pid();
@@ -225,7 +309,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
async fn handshake_error_fails_the_spawn_and_reaps_the_child() { async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()); let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into()); config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails"); let err = maestro_acp::spawn(config).await.expect_err("init fails");
@@ -284,7 +368,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
let err = maestro_acp::spawn(config).await.expect_err("child died"); let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err { match &err {
AcpError::ExitedEarly { status, stderr } => { AcpError::ExitedEarly { status, stderr, .. } => {
assert!(status.contains('3'), "{status}"); assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}"); assert!(stderr.contains("not logged in"), "{stderr}");
} }
@@ -320,7 +404,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
async fn load_session_resumes_the_stored_id_and_drops_the_replay() { async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into()); config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn"); let session = maestro_acp::spawn(config).await.expect("spawn");
@@ -353,7 +440,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
async fn load_session_requires_the_load_capability() { async fn load_session_requires_the_load_capability() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into()); config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config) let err = maestro_acp::spawn(config)
@@ -368,7 +458,10 @@ async fn load_session_requires_the_load_capability() {
async fn load_session_error_fails_the_spawn() { async fn load_session_error_fails_the_spawn() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into()); config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails"); let err = maestro_acp::spawn(config).await.expect_err("load fails");
@@ -392,3 +485,21 @@ async fn real_adapter_handshake() {
.await; .await;
assert!(session.has_exited()); assert!(session.has_exited());
} }
/// End to end against the maintained Codex adapter. It needs a locally
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
#[tokio::test]
#[ignore]
async fn real_codex_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session =
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
.await
.expect("real codex-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
+35 -2
View File
@@ -21,12 +21,13 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
StructuredApprovalDecision, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION, StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
PROTOCOL_VERSION,
}; };
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::session::{QueueAcpPrompt, SessionError, SessionManager}; use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
use crate::sidecar::EventCursor; use crate::sidecar::EventCursor;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -70,6 +71,7 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay)) .route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt)) .route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel)) .route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route( .route(
routes::patterns::STRUCTURED_APPROVAL, routes::patterns::STRUCTURED_APPROVAL,
post(structured_approval), post(structured_approval),
@@ -472,6 +474,37 @@ async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
} }
} }
async fn structured_config(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<StructuredConfigRequest>,
) -> Response {
match s
.manager
.set_acp_config(id, body.config_id, body.value)
.await
{
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
Ok(SetAcpConfig::InvalidValue) => error_response(
StatusCode::BAD_REQUEST,
"invalid_config_value",
"configuration values must be strings or booleans",
),
Ok(SetAcpConfig::Unavailable) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Ok(SetAcpConfig::Failed(message)) => {
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
}
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_approval( async fn structured_approval(
State(s): State<LocalApiState>, State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>, Path((id, nonce)): Path<(Uuid, String)>,
+261 -15
View File
@@ -2,6 +2,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall}; use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
use maestro_protocol::{ use maestro_protocol::{
ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind,
@@ -37,9 +38,12 @@ pub struct Projection {
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>, pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
state: ReducedState, state: ReducedState,
active_turn: Option<u64>, active_turn: Option<u64>,
active_turn_started_at: Option<DateTime<Utc>>,
active_turn_context_tokens: Option<u64>,
turn_failed: bool, turn_failed: bool,
next_turn: u64, next_turn: u64,
turn_tool_calls: HashSet<String>, turn_tool_calls: HashSet<String>,
anonymous_content_row: Option<(String, String)>,
} }
impl Projection { impl Projection {
@@ -60,6 +64,7 @@ impl Projection {
SessionUpdate::ToolCall(tool) => self.tool_call(tool), SessionUpdate::ToolCall(tool) => self.tool_call(tool),
SessionUpdate::ToolCallUpdate(update) => self.tool_update(update), SessionUpdate::ToolCallUpdate(update) => self.tool_update(update),
SessionUpdate::Plan(plan) => { SessionUpdate::Plan(plan) => {
self.anonymous_content_row = None;
let data = to_value(plan); let data = to_value(plan);
self.state.plan = Some(data.clone()); self.state.plan = Some(data.clone());
Some(self.replace("plan".into(), TranscriptRowKind::Plan, data)) Some(self.replace("plan".into(), TranscriptRowKind::Plan, data))
@@ -77,16 +82,20 @@ impl Projection {
SessionUpdate::ConfigOptionUpdate(options) => { SessionUpdate::ConfigOptionUpdate(options) => {
let value = to_value(options); let value = to_value(options);
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone()); self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
self.state.config_options = Some(value);
None None
} }
// ACP deliberately adds notification variants over time. Known but // ACP deliberately adds notification variants over time. Known but
// non-transcript updates become a generic notice instead of being // non-transcript updates become a generic notice instead of being
// dropped or forcing clients to understand ACP's enum. // dropped or forcing clients to understand ACP's enum.
_ => Some(self.append( _ => {
format!("notice:{seq}"), self.anonymous_content_row = None;
TranscriptRowKind::Notice, Some(self.append(
to_value(&notification.update), format!("notice:{seq}"),
)), TranscriptRowKind::Notice,
to_value(&notification.update),
))
}
}; };
ProjectionUpdate { ProjectionUpdate {
transcript, transcript,
@@ -122,12 +131,19 @@ impl Projection {
/// Start a prompt turn and return its monotonically increasing token. /// Start a prompt turn and return its monotonically increasing token.
pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) { pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) {
self.anonymous_content_row = None;
self.next_turn += 1; self.next_turn += 1;
let turn = self.next_turn; let turn = self.next_turn;
self.active_turn = Some(turn); self.active_turn = Some(turn);
let started_at = Utc::now();
let started_context_tokens = usage_tokens(self.state.usage.as_ref());
self.active_turn_started_at = Some(started_at);
self.active_turn_context_tokens = started_context_tokens;
self.turn_failed = false; self.turn_failed = false;
self.state.turn.responding = true; self.state.turn.responding = true;
self.state.turn.thinking = true; self.state.turn.thinking = true;
self.state.turn.started_at = Some(started_at);
self.state.turn.started_context_tokens = started_context_tokens;
self.turn_tool_calls.clear(); self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear(); self.state.turn.active_tool_calls.clear();
( (
@@ -156,10 +172,44 @@ impl Projection {
self.turn_failed = lifecycle == TurnLifecycle::Failure; self.turn_failed = lifecycle == TurnLifecycle::Failure;
self.state.turn.responding = false; self.state.turn.responding = false;
self.state.turn.thinking = false; self.state.turn.thinking = false;
let finished_at = Utc::now();
let started_at = self.active_turn_started_at.take();
let started_context_tokens = self.active_turn_context_tokens.take();
self.state.turn.started_at = None;
self.state.turn.started_context_tokens = None;
self.turn_tool_calls.clear(); self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear(); self.state.turn.active_tool_calls.clear();
let context_tokens = usage_tokens(self.state.usage.as_ref());
let context_delta = context_tokens
.zip(started_context_tokens)
.map(|(end, start)| i128::from(end) - i128::from(start));
let elapsed_ms = started_at.map_or(0, |started| {
finished_at
.signed_duration_since(started)
.num_milliseconds()
.max(0)
});
let outcome = match lifecycle {
TurnLifecycle::Finish => "completed",
TurnLifecycle::Cancel => "cancelled",
TurnLifecycle::Failure => "failed",
TurnLifecycle::Begin => unreachable!(),
};
let row = self.append(
format!("turn_meta:{}", uuid::Uuid::new_v4()),
TranscriptRowKind::Notice,
serde_json::json!({
"maestroTurnMeta": {
"elapsedMs": elapsed_ms,
"contextTokens": context_tokens,
"contextDelta": context_delta,
"outcome": outcome,
"model": self.state.model,
}
}),
);
Some(ProjectionUpdate { Some(ProjectionUpdate {
transcript: None, transcript: Some(row),
state: self.state(), state: self.state(),
}) })
} }
@@ -186,7 +236,8 @@ impl Projection {
} }
} }
pub fn restore_approval(&mut self, row: TranscriptRow) -> ProjectionUpdate { pub fn restore_row(&mut self, row: TranscriptRow) -> ProjectionUpdate {
self.anonymous_content_row = None;
let transcript = if !self.row_index.contains_key(&row.id) { let transcript = if !self.row_index.contains_key(&row.id) {
self.row_index.insert(row.id.clone(), self.rows.len()); self.row_index.insert(row.id.clone(), self.rows.len());
self.rows.push(row.clone()); self.rows.push(row.clone());
@@ -245,12 +296,32 @@ impl Projection {
kind: TranscriptRowKind, kind: TranscriptRowKind,
prefix: &str, prefix: &str,
) -> Option<TranscriptDelta> { ) -> Option<TranscriptDelta> {
let id = chunk
.message_id
.as_ref()
.map(|id| format!("{prefix}:{id}"))
.unwrap_or_else(|| format!("{prefix}:{seq}"));
let data = to_value(chunk); let data = to_value(chunk);
let id = if let Some(message_id) = chunk.message_id.as_ref() {
self.anonymous_content_row = None;
format!("{prefix}:{message_id}")
} else {
let text = data.pointer("/content/text").and_then(Value::as_str);
let previous = self
.anonymous_content_row
.as_ref()
.filter(|(previous_prefix, _)| previous_prefix == prefix)
.map(|(_, id)| id.clone());
if text.is_some_and(|text| !text.is_empty()) {
if let Some(id) = previous {
let index = *self
.row_index
.get(&id)
.expect("anonymous content row remains addressable");
let row = &mut self.rows[index];
append_text(&mut row.data, &data);
return Some(TranscriptDelta::Patch { row: row.clone() });
}
}
let id = format!("{prefix}:{seq}");
self.anonymous_content_row = text.map(|_| (prefix.to_owned(), id.clone()));
id
};
if let Some(index) = self.row_index.get(&id).copied() { if let Some(index) = self.row_index.get(&id).copied() {
let row = &mut self.rows[index]; let row = &mut self.rows[index];
append_text(&mut row.data, &data); append_text(&mut row.data, &data);
@@ -260,6 +331,7 @@ impl Projection {
} }
fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> { fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> {
self.anonymous_content_row = None;
let id = format!("tool:{}", tool.tool_call_id); let id = format!("tool:{}", tool.tool_call_id);
let tool_id = tool.tool_call_id.to_string(); let tool_id = tool.tool_call_id.to_string();
let mut canonical = tool.clone(); let mut canonical = tool.clone();
@@ -290,6 +362,7 @@ impl Projection {
&mut self, &mut self,
update: &maestro_acp::schema::ToolCallUpdate, update: &maestro_acp::schema::ToolCallUpdate,
) -> Option<TranscriptDelta> { ) -> Option<TranscriptDelta> {
self.anonymous_content_row = None;
let tool_id = update.tool_call_id.to_string(); let tool_id = update.tool_call_id.to_string();
let Some(tool) = self.tools.get_mut(&tool_id) else { let Some(tool) = self.tools.get_mut(&tool_id) else {
self.pending_tool_updates self.pending_tool_updates
@@ -383,6 +456,10 @@ impl Projection {
} }
} }
fn usage_tokens(usage: Option<&Value>) -> Option<u64> {
usage?.get("used")?.as_u64()
}
fn to_value(value: &impl serde::Serialize) -> Value { fn to_value(value: &impl serde::Serialize) -> Value {
serde_json::to_value(value).expect("ACP schema values serialize") serde_json::to_value(value).expect("ACP schema values serialize")
} }
@@ -426,7 +503,7 @@ fn model_from_options(value: &Value) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use maestro_acp::schema::SessionNotification; use maestro_acp::schema::SessionNotification;
use maestro_protocol::{TranscriptDelta, TranscriptRowKind}; use maestro_protocol::{TranscriptDelta, TranscriptRow, TranscriptRowKind};
use super::{Projection, TurnLifecycle}; use super::{Projection, TurnLifecycle};
@@ -496,6 +573,137 @@ mod tests {
); );
} }
#[test]
fn projects_the_codex_adapter_fixture_without_provider_specific_rows() {
let fixture = include_str!("../tests/fixtures/codex-acp-session.jsonl");
let events = fixture
.lines()
.map(serde_json::from_str::<SessionNotification>)
.collect::<Result<Vec<_>, _>>()
.expect("Codex ACP fixture follows the supported schema");
let mut projection = Projection::default();
for (seq, event) in events.iter().enumerate() {
projection.apply((seq + 1) as u64, event);
}
let rows = projection.snapshot(events.len() as u64).rows;
assert_eq!(
rows.iter().map(|row| row.kind).collect::<Vec<_>>(),
[
TranscriptRowKind::User,
TranscriptRowKind::AgentThought,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolDiff,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::Plan,
TranscriptRowKind::Notice,
TranscriptRowKind::Notice,
TranscriptRowKind::AgentText,
]
);
assert_eq!(rows[2].data["rawInput"]["command"], "rg TODO");
assert_eq!(rows[2].data["rawOutput"]["exit_code"], 0);
assert_eq!(rows[4].data["_meta"]["is_mcp_tool_call"], true);
assert_eq!(rows[5].data["rawInput"]["query"], "Agent Client Protocol");
assert_eq!(
rows[7].data["content"][0]["content"]["mimeType"],
"image/png"
);
let state = projection.state();
assert_eq!(state.model.as_deref(), Some("gpt-5.3-codex"));
assert_eq!(state.mode.as_deref(), Some("agent"));
assert_eq!(state.usage.as_ref().unwrap()["used"], 1200);
assert_eq!(state.context.as_ref().unwrap()["size"], 258400);
assert_eq!(
state.config_options.as_ref().unwrap()["configOptions"][2]["currentValue"],
"high"
);
}
#[test]
fn coalesces_anonymous_stream_chunks_without_joining_turns() {
let mut projection = Projection::default();
let (first_turn, _) = projection.begin_turn();
for (seq, text) in [(1, ""), (2, "p"), (3, "ong")] {
projection.apply(
seq,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text},
})),
);
}
projection.turn_lifecycle(TurnLifecycle::Finish, first_turn);
projection.begin_turn();
projection.apply(
4,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "next"},
})),
);
let rows = projection.snapshot(4).rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "pong");
assert!(rows[1].data.get("maestroTurnMeta").is_some());
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "next");
}
#[test]
fn anonymous_empty_chunk_delimits_turns_during_replay() {
let mut projection = Projection::default();
for (seq, text) in [(1, ""), (2, "visible"), (3, ""), (4, "p"), (5, "ong")] {
projection.apply(
seq,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text},
})),
);
}
let rows = projection.snapshot(5).rows;
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "visible");
assert_eq!(rows[1].data.pointer("/content/text").unwrap(), "pong");
}
#[test]
fn restored_user_prompt_delimits_anonymous_agent_rows() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "before"},
})),
);
projection.restore_row(TranscriptRow {
id: "user:prompt".into(),
kind: TranscriptRowKind::User,
data: serde_json::json!({"content": {"type": "text", "text": "next"}}),
});
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "after"},
})),
);
let rows = projection.snapshot(2).rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "before");
assert_eq!(rows[1].kind, TranscriptRowKind::User);
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "after");
}
#[test] #[test]
fn tool_update_patches_the_original_stable_row() { fn tool_update_patches_the_original_stable_row() {
let mut projection = Projection::default(); let mut projection = Projection::default();
@@ -614,6 +822,10 @@ mod tests {
assert_eq!(state.context.as_ref().unwrap()["size"], 200); assert_eq!(state.context.as_ref().unwrap()["size"], 200);
assert_eq!(state.mode.as_deref(), Some("plan")); assert_eq!(state.mode.as_deref(), Some("plan"));
assert_eq!(state.model.as_deref(), Some("opus")); assert_eq!(state.model.as_deref(), Some("opus"));
assert_eq!(
state.config_options.as_ref().unwrap()["configOptions"][0]["currentValue"],
"opus"
);
assert_eq!( assert_eq!(
state.plan.as_ref().unwrap()["entries"], state.plan.as_ref().unwrap()["entries"],
serde_json::json!([]) serde_json::json!([])
@@ -685,6 +897,38 @@ mod tests {
} }
} }
#[test]
fn completed_turn_keeps_duration_and_context_delta_in_transcript() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"usage_update", "used":10_000, "size":200_000
})),
);
let (turn, begun) = projection.begin_turn();
assert!(begun.state.turn.started_at.is_some());
assert_eq!(begun.state.turn.started_context_tokens, Some(10_000));
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"usage_update", "used":12_500, "size":200_000
})),
);
let finished = projection
.turn_lifecycle(TurnLifecycle::Finish, turn)
.expect("active turn finishes");
let TranscriptDelta::Append { row } = finished.transcript.unwrap() else {
panic!("turn footer");
};
assert_eq!(row.kind, TranscriptRowKind::Notice);
assert_eq!(row.data["maestroTurnMeta"]["contextDelta"], 2_500);
assert_eq!(row.data["maestroTurnMeta"]["contextTokens"], 12_500);
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
assert!(finished.state.turn.started_at.is_none());
}
fn terminal_turn_drops_unfinished_tools( fn terminal_turn_drops_unfinished_tools(
terminal: TurnLifecycle, terminal: TurnLifecycle,
unfinished_id: &str, unfinished_id: &str,
@@ -703,7 +947,9 @@ mod tests {
projection.turn_lifecycle(terminal, turn); projection.turn_lifecycle(terminal, turn);
assert!(projection.state().turn.active_tool_calls.is_empty()); assert!(projection.state().turn.active_tool_calls.is_empty());
assert_eq!(projection.snapshot(1).rows.len(), 1); let rows = projection.snapshot(1).rows;
assert_eq!(rows.len(), 2);
assert!(rows[1].data.get("maestroTurnMeta").is_some());
projection.begin_turn(); projection.begin_turn();
projection.apply( projection.apply(
@@ -829,7 +1075,7 @@ mod tests {
panic!("approval row") panic!("approval row")
}; };
let mut recovered = Projection::default(); let mut recovered = Projection::default();
let update = recovered.restore_approval(row.clone()); let update = recovered.restore_row(row.clone());
assert_eq!( assert_eq!(
update.transcript, update.transcript,
Some(TranscriptDelta::Append { row: row.clone() }) Some(TranscriptDelta::Append { row: row.clone() })
+168 -33
View File
@@ -15,11 +15,12 @@ use std::task::{Context, Poll};
use std::time::Duration; use std::time::Duration;
use chrono::Utc; 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::{ use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session, ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, WorktreeSpec, NAME_KEY, SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
NAME_SOURCE_KEY, TranscriptRowKind, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
}; };
use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify}; use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify};
use uuid::Uuid; use uuid::Uuid;
@@ -59,6 +60,16 @@ pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// actually go. /// actually go.
const SUBMIT_DELAY: Duration = Duration::from_millis(150); 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`. /// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)] #[derive(Debug)]
pub struct SessionRecord { pub struct SessionRecord {
@@ -218,6 +229,15 @@ pub enum QueueAcpPrompt {
Failed(String), Failed(String),
} }
/// Result of changing a live structured session option.
#[derive(Debug)]
pub enum SetAcpConfig {
Updated,
InvalidValue,
Unavailable,
Failed(String),
}
impl SessionError { impl SessionError {
pub fn to_error_info(&self) -> ErrorInfo { pub fn to_error_info(&self) -> ErrorInfo {
let code = match self { let code = match self {
@@ -648,6 +668,7 @@ impl SessionManager {
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else { let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
return Ok(false); return Ok(false);
}; };
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected); let _ = stream.projected_live.send(projected);
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
@@ -719,6 +740,27 @@ impl SessionManager {
if stream.projection.turn_active() { if stream.projection.turn_active() {
return Ok(QueueAcpPrompt::InFlight); return Ok(QueueAcpPrompt::InFlight);
} }
let row = TranscriptRow {
id: format!("user:{}", Uuid::new_v4()),
kind: TranscriptRowKind::User,
data: serde_json::json!({
"content": {"type": "text", "text": text},
}),
};
let event = PersistedEvent::User { row: row.clone() };
let seq = match stream.log.append(&event) {
Ok(seq) => seq,
Err(error) => {
if matches!(error, AppendError::Fatal { .. }) {
stream.dispatch.failed = true;
}
return Ok(QueueAcpPrompt::Failed(error.to_string()));
}
};
let logged = LoggedEvent { seq, event };
let projected = stream.projection.restore_row(row);
let _ = stream.live.send(logged);
let _ = stream.projected_live.send(projected);
let (turn, update) = stream.projection.begin_turn(); let (turn, update) = stream.projection.begin_turn();
let _ = stream.projected_live.send(update); let _ = stream.projected_live.send(update);
match acp.prompt(text.clone(), turn) { match acp.prompt(text.clone(), turn) {
@@ -744,6 +786,7 @@ impl SessionManager {
.expect( .expect(
"newly claimed turn remains current while stream is locked", "newly claimed turn remains current while stream is locked",
); );
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected); let _ = stream.projected_live.send(projected);
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
@@ -789,6 +832,7 @@ impl SessionManager {
} }
cancel_pending_approvals(&mut stream); cancel_pending_approvals(&mut stream);
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) { if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected); let _ = stream.projected_live.send(projected);
} }
drop(stream); drop(stream);
@@ -829,6 +873,48 @@ impl SessionManager {
} }
} }
/// Change one option on a live ACP session and wait for the agent to
/// acknowledge it. The adapter's returned option set is projected by the
/// normal event pump before this request completes.
pub async fn set_acp_config(
&self,
id: Uuid,
config_id: String,
value: serde_json::Value,
) -> Result<SetAcpConfig, SessionError> {
let (acp, stream) = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(SetAcpConfig::Unavailable);
}
(record.acp.clone(), record.acp_events.clone())
};
let (Some(acp), Some(stream)) = (acp, stream) else {
return Ok(SetAcpConfig::Unavailable);
};
let value = match value {
serde_json::Value::String(value) => {
maestro_acp::schema::SessionConfigOptionValue::value_id(value)
}
serde_json::Value::Bool(value) => {
maestro_acp::schema::SessionConfigOptionValue::boolean(value)
}
_ => return Ok(SetAcpConfig::InvalidValue),
};
let operation = stream.lock().await.operation.clone();
let _operation = operation.lock().await;
let done = match acp.set_config(config_id, value) {
Ok(done) => done,
Err(message) => return Ok(SetAcpConfig::Failed(message)),
};
match done.await {
Ok(Ok(())) => Ok(SetAcpConfig::Updated),
Ok(Err(message)) => Ok(SetAcpConfig::Failed(message)),
Err(_) => Ok(SetAcpConfig::Failed("ACP connection is closed".into())),
}
}
/// Cancel an ACP turn and immediately clear its client-facing lifecycle. /// Cancel an ACP turn and immediately clear its client-facing lifecycle.
pub async fn cancel_acp_turn(&self, id: Uuid) -> Result<bool, SessionError> { pub async fn cancel_acp_turn(&self, id: Uuid) -> Result<bool, SessionError> {
let (acp, stream) = { let (acp, stream) = {
@@ -863,6 +949,7 @@ impl SessionManager {
.projection .projection
.turn_lifecycle(TurnLifecycle::Cancel, turn) .turn_lifecycle(TurnLifecycle::Cancel, turn)
.expect("active turn remains current while stream is locked"); .expect("active turn remains current while stream is locked");
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected); let _ = stream.projected_live.send(projected);
drop(stream); drop(stream);
sync_structured_activity(&self.inner, id).await; sync_structured_activity(&self.inner, id).await;
@@ -1174,14 +1261,18 @@ impl SessionManager {
// up still yields a session — Dead, with the reason on it — so the // up still yields a session — Dead, with the reason on it — so the
// operator sees what to fix instead of a silently missing session. // operator sees what to fix instead of a silently missing session.
let (acp, acp_failure) = match req.view { let (acp, acp_failure) = match req.view {
SessionView::Structured => { SessionView::Structured => match structured_adapter(&req.command) {
match maestro_acp::spawn(SpawnConfig::new( Err(error) => (None, Some(error)),
&effective_cwd, Ok(adapter) => match maestro_acp::spawn(
session_env SpawnConfig::new(
.iter() &effective_cwd,
.map(|(k, v)| (k.clone(), v.clone())) session_env
.collect(), .iter()
)) .map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)
.adapter(adapter),
)
.await .await
{ {
Ok(acp) => (Some(Arc::new(acp)), None), Ok(acp) => (Some(Arc::new(acp)), None),
@@ -1189,8 +1280,8 @@ impl SessionManager {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start"); tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
(None, Some(e.to_string())) (None, Some(e.to_string()))
} }
} },
} },
SessionView::Terminal => { SessionView::Terminal => {
let spawned = { let spawned = {
let tmux = self.inner.tmux.clone(); let tmux = self.inner.tmux.clone();
@@ -1598,23 +1689,30 @@ impl SessionManager {
let (acp, failure) = if recoverable { let (acp, failure) = if recoverable {
match session.acp_session_id.clone() { match session.acp_session_id.clone() {
Some(acp_session_id) => { Some(acp_session_id) => match structured_adapter(&session.command) {
let config = SpawnConfig::new( Err(error) => {
&session.cwd, tracing::warn!(%id, %error, "structured session: unsupported command during recovery");
session_env (None, Some(error))
.iter() }
.map(|(key, value)| (key.clone(), value.clone())) Ok(adapter) => {
.collect(), let config = SpawnConfig::new(
) &session.cwd,
.load_session(acp_session_id); session_env
match maestro_acp::spawn(config).await { .iter()
Ok(acp) => (Some(Arc::new(acp)), None), .map(|(key, value)| (key.clone(), value.clone()))
Err(error) => { .collect(),
tracing::warn!(%id, %error, "structured session: adapter failed to resume"); )
(None, Some(error.to_string())) .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 => (
None, None,
Some("structured session has no stored ACP session id".into()), Some("structured session has no stored ACP session id".into()),
@@ -2118,10 +2216,33 @@ fn start_acp_event_pump(
fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> ProjectionUpdate { fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> ProjectionUpdate {
match &event.event { match &event.event {
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification), PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
PersistedEvent::Approval { row } => projection.restore_approval(row.clone()), PersistedEvent::User { row }
| PersistedEvent::Approval { row }
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
} }
} }
fn persist_turn_update(
stream: &mut AcpEventStream,
mut update: ProjectionUpdate,
) -> ProjectionUpdate {
let Some(TranscriptDelta::Append { row }) = update.transcript.as_ref() else {
return update;
};
let event = PersistedEvent::Turn { row: row.clone() };
match stream.log.append(&event) {
Ok(seq) => {
let _ = stream.live.send(LoggedEvent { seq, event });
}
Err(error) => {
tracing::error!(%error, "turn metadata history append failed");
stream.projection.rollback_row(&row.id);
update.transcript = None;
}
}
update
}
async fn sync_structured_activity(inner: &Arc<Inner>, id: Uuid) { async fn sync_structured_activity(inner: &Arc<Inner>, id: Uuid) {
let events = { let events = {
let sessions = inner.sessions.lock().await; let sessions = inner.sessions.lock().await;
@@ -2870,15 +2991,18 @@ mod tests {
use std::task::Poll; use std::task::Poll;
use std::time::Duration; use std::time::Duration;
use maestro_acp::schema::{ use maestro_acp::{
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest, schema::{
ToolCallUpdate, ToolCallUpdateFields, PermissionOption, PermissionOptionKind, RequestPermissionOutcome,
RequestPermissionRequest, ToolCallUpdate, ToolCallUpdateFields,
},
AcpAdapter,
}; };
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionView}; use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionView};
use super::{ use super::{
apply_structured_activity, cancel_pending_approvals, permission_outcome, 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, sync_structured_activity_from_stream, AcpDispatchState, AcpEventStream, ApprovalDecision,
RecordOutcome, SessionManager, RecordOutcome, SessionManager,
}; };
@@ -2902,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 { fn stream(log: EventLog) -> AcpEventStream {
let (live, _live_rx) = tokio::sync::broadcast::channel(16); let (live, _live_rx) = tokio::sync::broadcast::channel(16);
let (projected_live, _projected_rx) = tokio::sync::broadcast::channel(16); let (projected_live, _projected_rx) = tokio::sync::broadcast::channel(16);
+6
View File
@@ -263,9 +263,15 @@ pub struct LoggedEvent {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "maestroEvent", rename_all = "snake_case")] #[serde(tag = "maestroEvent", rename_all = "snake_case")]
pub enum PersistedEvent { pub enum PersistedEvent {
User {
row: TranscriptRow,
},
Approval { Approval {
row: TranscriptRow, row: TranscriptRow,
}, },
Turn {
row: TranscriptRow,
},
#[serde(skip)] #[serde(skip)]
Notification(Box<SessionNotification>), Notification(Box<SessionNotification>),
} }
+111 -10
View File
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture}; use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_acp::schema::{ContentBlock, SessionUpdate}; use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND}; use maestro_acp::{AcpAdapter, ADAPTER_BIN};
use maestro_daemon::projection::TurnLifecycle; use maestro_daemon::projection::TurnLifecycle;
use maestro_daemon::sidecar::{EventCursor, SidecarStore}; use maestro_daemon::sidecar::{EventCursor, SidecarStore};
use maestro_daemon::worktree::WorktreeManager; use maestro_daemon::worktree::WorktreeManager;
@@ -38,6 +38,7 @@ impl Fake {
let bin = dir.path().join("bin"); let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap(); std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap(); std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir } Self { dir }
} }
@@ -96,10 +97,18 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
} }
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest { fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
structured_command(cwd, env, "claude")
}
fn structured_command(
cwd: &Path,
env: HashMap<String, String>,
command: &str,
) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: SessionView::Structured, view: SessionView::Structured,
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
command: "claude".into(), command: command.into(),
env, env,
metadata: HashMap::new(), metadata: HashMap::new(),
worktree: None, worktree: None,
@@ -130,7 +139,11 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let session = fx let session = fx
.manager .manager
.create(structured(cwd.path(), env)) .create(structured_command(
cwd.path(),
env,
"codex --dangerously-bypass-approvals-and-sandbox",
))
.await .await
.expect("create"); .expect("create");
@@ -215,7 +228,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into()); env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
let session = fx let session = fx
.manager .manager
.create(structured(cwd.path(), env)) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.unwrap(); .unwrap();
@@ -349,12 +362,75 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
.await .await
.expect("finish lifecycle broadcast") .expect("finish lifecycle broadcast")
.expect("projection broadcast"); .expect("projection broadcast");
assert_eq!(finish.transcript, None); let Some(TranscriptDelta::Append { row }) = finish.transcript else {
panic!("finish should append durable turn metadata");
};
assert_eq!(row.kind, TranscriptRowKind::Notice);
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
assert_eq!(finish.state.turn, Default::default()); assert_eq!(finish.state.turn, Default::default());
let recovered = fx
.manager
.read_acp_projection_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("turn replay")
.expect("structured session");
assert!(recovered.iter().any(|event| {
matches!(
&event.transcript,
Some(TranscriptDelta::Append { row })
if row.data["maestroTurnMeta"]["outcome"] == "completed"
)
}));
fx.manager.kill(session.id, false).await.expect("kill"); fx.manager.kill(session.id, false).await.expect("kill");
} }
#[tokio::test]
async fn queued_prompt_is_a_durable_user_transcript_row() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "show my prompt".into())
.await
.expect("queue prompt"),
maestro_daemon::session::QueueAcpPrompt::Queued
));
let (snapshot, _, _) = fx
.manager
.subscribe_acp_projection(session.id, EventCursor::Since(0))
.await
.expect("projection subscription")
.expect("structured session");
let user = snapshot
.rows
.iter()
.find(|row| row.kind == TranscriptRowKind::User)
.expect("user prompt row");
assert_eq!(user.data["content"]["text"], "show my prompt");
let persisted = fx
.manager
.read_acp_events(session.id, EventCursor::Since(0), 100)
.expect("event replay");
assert!(persisted.iter().any(|event| matches!(
&event.event,
maestro_daemon::sidecar::PersistedEvent::User { row }
if row.data["content"]["text"] == "show my prompt"
)));
fx.manager.kill(session.id, false).await.unwrap();
}
#[tokio::test] #[tokio::test]
async fn stale_turn_completion_cannot_end_the_next_turn() { async fn stale_turn_completion_cannot_end_the_next_turn() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
@@ -507,7 +583,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let session = fx let session = fx
.manager .manager
.create(structured(cwd.path(), env)) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.expect("create"); .expect("create");
let acp_id = session.acp_session_id.clone().expect("acp id"); let acp_id = session.acp_session_id.clone().expect("acp id");
@@ -621,7 +697,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: SessionView::Structured, view: SessionView::Structured,
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "claude".into(), command: "codex".into(),
env: fake.env(), env: fake.env(),
metadata: HashMap::new(), metadata: HashMap::new(),
worktree: Some(WorktreeSpec { worktree: Some(WorktreeSpec {
@@ -716,15 +792,18 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
let session = fx let session = fx
.manager .manager
.create(structured(cwd.path(), env)) .create(structured_command(cwd.path(), env, "codex"))
.await .await
.expect("a failed session, not an error"); .expect("a failed session, not an error");
assert_eq!(session.status, SessionStatus::Dead); assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error); assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail"); let detail = session.activity_detail.clone().expect("actionable detail");
assert!(detail.contains(ADAPTER_BIN), "{detail}"); assert!(detail.contains(AcpAdapter::Codex.binary()), "{detail}");
assert!(detail.contains(INSTALL_COMMAND), "{detail}"); assert!(
detail.contains(AcpAdapter::Codex.install_command()),
"{detail}"
);
assert_eq!(session.acp_session_id, None); assert_eq!(session.acp_session_id, None);
// It is a real session: listed, on disk, and dismissable like any other // It is a real session: listed, on disk, and dismissable like any other
@@ -738,6 +817,28 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
assert!(!fx.manager.has(session.id).await); assert!(!fx.manager.has(session.id).await);
} }
#[tokio::test]
async fn unsupported_structured_command_fails_without_launching_claude() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured_command(cwd.path(), fake.env(), "opencode"))
.await
.expect("failed structured session is persisted");
assert_eq!(session.status, SessionStatus::Dead);
let detail = session.activity_detail.expect("selection error");
assert!(detail.contains("Claude Code or Codex"), "{detail}");
assert!(
fake.log().is_empty(),
"unsupported command launched an adapter"
);
fx.manager.kill(session.id, false).await.expect("dismiss");
}
#[tokio::test] #[tokio::test]
async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() { async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
@@ -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 /// `git worktree add -b` alone fails outright when the branch already exists,
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md. /// even with the target directory free — a random handle commonly collides
/// with one an earlier, already-removed worktree left behind. `add_new`
/// sidesteps it on the branch name itself instead of erroring (#77).
#[test] #[test]
fn add_new_suffixes_when_branch_already_exists() { fn add_new_suffixes_branch_name_on_branch_collision() {
let repo_dir = TempDir::new().unwrap(); let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf(); let repo = repo_dir.path().to_path_buf();
init_repo(&repo); init_repo(&repo);
// pre-create the branch // pre-create the branch, unused by any worktree.
let status = Command::new("git") let status = Command::new("git")
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
@@ -425,9 +427,17 @@ fn add_new_suffixes_when_branch_already_exists() {
let wt_root = TempDir::new().unwrap(); let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/dup").unwrap(); let path = mgr
.add_new(&repo, "feat/dup")
.expect("sidesteps the branch collision");
assert!(path.ends_with("feat/dup-2"), "{}", path.display()); assert!(path.ends_with("feat/dup-2"), "{}", path.display());
assert!(path.is_dir()); assert!(path.exists());
// The pre-existing branch is untouched; the new worktree checked out the
// suffixed one instead of erroring or adopting `feat/dup`.
let branches = worktree_branches(&repo);
assert!(branches.contains(&"feat/dup-2".to_string()), "{branches:?}");
assert!(!branches.contains(&"feat/dup".to_string()), "{branches:?}");
} }
/// The project picker lists linked worktrees as pickable rows, so `base_repo` /// The project picker lists linked worktrees as pickable rows, so `base_repo`
+32 -28
View File
@@ -383,13 +383,11 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
client.kill_session(s.id).await.expect("kill"); client.kill_session(s.id).await.expect("kill");
} }
/// #66: a second spawn onto an *existing* branch that already has a worktree /// #66/#77: a second spawn onto the same branch used to be rejected outright;
/// is rejected with a structured worktree error; the first worktree stays /// since the branch-collision suffixing landed, it succeeds instead — the
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9 /// second worktree lands on `<branch>-2` and the first stays untouched.
/// suffixes the new branch name instead, see
/// `colliding_branch_name_gets_suffixed_too`.)
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() { async fn second_spawn_on_same_branch_gets_suffixed() {
let (_daemon, client) = bring_up().await; let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap(); let base = TempDir::new().unwrap();
@@ -399,28 +397,33 @@ async fn second_spawn_on_same_branch_is_rejected() {
.spawn_session(&worktree_req(base.path(), "feature/dup", true)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await .await
.expect("first spawn"); .expect("first spawn");
let dup = client let second = client
.spawn_session(&worktree_req(base.path(), "feature/dup", false)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await; .await
match dup { .expect("second spawn suffixes instead of rejecting");
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
assert_eq!(
info.code, "worktree_failed",
"expected worktree error: {info:?}"
);
}
other => panic!("expected rejection for duplicate branch, got {other:?}"),
}
// Exactly one registration, one directory, still usable. let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
// Count branch records only: the worktree path now contains the branch assert_eq!(
// name too (`worktrees/feature/dup`), so a substring match over every line second.cwd, suffixed,
// would count the same tree twice. "second worktree must land on the suffixed path"
let holders = git_worktree_list(base.path()) );
.iter() assert!(suffixed.is_dir());
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
.count(); // Both registrations exist: the original branch, untouched, and the
assert_eq!(holders, 1, "duplicate spawn must not register another tree"); // suffixed one the second spawn created.
let registrations = git_worktree_list(base.path());
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
"original branch must stay registered: {registrations:?}"
);
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
"suffixed branch must be registered too: {registrations:?}"
);
assert!( assert!(
base.path() base.path()
.join("worktrees") .join("worktrees")
@@ -431,7 +434,8 @@ async fn second_spawn_on_same_branch_is_rejected() {
); );
let _origin = throwaway_origin(base.path()); let _origin = throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill"); client.kill_session(first.id).await.expect("kill first");
client.kill_session(second.id).await.expect("kill second");
} }
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement /// #66/#56: when the client picks a *linked worktree* as base_repo, placement
+19 -1
View File
@@ -123,7 +123,7 @@ pub struct Session {
/// daemon that predates the field keeps working untouched. /// daemon that predates the field keeps working untouched.
#[serde(default)] #[serde(default)]
pub view: SessionView, pub view: SessionView,
/// The ACP session id the `claude-code-acp` adapter handed back from /// The ACP session id the selected provider adapter handed back from
/// `session/new` (#134). Only a [`SessionView::Structured`] session has /// `session/new` (#134). Only a [`SessionView::Structured`] session has
/// one, and only once its handshake succeeded; a later respawn passes it /// one, and only once its handshake succeeded; a later respawn passes it
/// to `session/load` so the conversation continues (#135). Distinct from /// to `session/load` so the conversation continues (#135). Distinct from
@@ -824,6 +824,15 @@ pub struct TurnState {
pub thinking: bool, pub thinking: bool,
#[serde(default)] #[serde(default)]
pub active_tool_calls: Vec<String>, pub active_tool_calls: Vec<String>,
/// Wall-clock start of the active prompt. This lets reconnecting clients
/// keep one continuous elapsed-time display instead of restarting at zero.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
/// Context-window usage when the active prompt began. ACP exposes context
/// occupancy, not provider billing-token accounting, so clients must label
/// deltas as context tokens rather than tokens billed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_context_tokens: Option<u64>,
} }
/// A whole-session view derived from ACP notifications. /// A whole-session view derived from ACP notifications.
@@ -846,6 +855,8 @@ pub struct ReducedState {
pub model: Option<String>, pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>, pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_options: Option<Value>,
} }
/// Server messages sent over a structured-session WebSocket. /// Server messages sent over a structured-session WebSocket.
@@ -871,6 +882,13 @@ pub struct StructuredPromptRequest {
pub prompt: String, pub prompt: String,
} }
/// HTTP body for `POST /api/sessions/:id/structured/config`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StructuredConfigRequest {
pub config_id: String,
pub value: Value,
}
/// Operator decision for a pending ACP permission request. /// Operator decision for a pending ACP permission request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
+6
View File
@@ -91,6 +91,11 @@ pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel") format!("{SESSIONS}/{id}/structured/cancel")
} }
/// `POST /api/sessions/:id/structured/config` — change an ACP session option.
pub fn structured_config(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/config")
}
/// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission. /// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission.
pub fn structured_approval(id: Uuid, nonce: &str) -> String { pub fn structured_approval(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/approvals/{nonce}") format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
@@ -109,6 +114,7 @@ pub mod patterns {
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay"; pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt"; pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel"; pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce"; pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
} }
+3 -1
View File
@@ -3053,17 +3053,19 @@ mod tests {
let request = |view| CreateSessionRequest { let request = |view| CreateSessionRequest {
view, view,
cwd: std::path::PathBuf::from("/srv/work"), cwd: std::path::PathBuf::from("/srv/work"),
command: "claude".into(), command: "codex".into(),
env: Default::default(), env: Default::default(),
metadata: Default::default(), metadata: Default::default(),
worktree: None, worktree: None,
}; };
let structured_id = Uuid::from_u128(188); let structured_id = Uuid::from_u128(188);
app.state.mode = Mode::Insert;
app.submit_spawn(request(SessionView::Structured)).await; app.submit_spawn(request(SessionView::Structured)).await;
assert!(app.catalog.snapshot().sessions.is_empty()); assert!(app.catalog.snapshot().sessions.is_empty());
assert!(app.structured.contains_key(&structured_id)); assert!(app.structured.contains_key(&structured_id));
assert!(app.emulators.get(structured_id).is_none()); assert!(app.emulators.get(structured_id).is_none());
assert_eq!(app.state.mode, Mode::Normal);
let terminal_id = Uuid::from_u128(187); let terminal_id = Uuid::from_u128(187);
app.submit_spawn(request(SessionView::Terminal)).await; app.submit_spawn(request(SessionView::Terminal)).await;
+60 -16
View File
@@ -91,7 +91,7 @@ pub enum DraftField {
/// Disclosure collapsing the optional view and worktree controls below. /// Disclosure collapsing the optional view and worktree controls below.
/// Closed by default; Enter / Space toggles. /// Closed by default; Enter / Space toggles.
Advanced, Advanced,
/// ACP-backed transcript instead of a terminal. Claude Code only. /// ACP-backed transcript instead of a terminal. Claude Code or Codex.
StructuredToggle, StructuredToggle,
/// Worktree on/off for this session (#65). Enter / Space toggles. /// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle, WorktreeToggle,
@@ -256,7 +256,7 @@ pub struct DraftPane {
/// take the default target. /// take the default target.
pub advanced_collapsed: bool, pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Off by default and supported only /// ACP-backed structured transcript. 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, pub structured_view: bool,
/// Enclosing repository of the chosen target — from the project scan for /// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known. /// picked rows, `/api/gitroot` for raw paths. `None` until known.
@@ -673,7 +673,7 @@ impl DraftPane {
} }
fn supports_structured_view(&self) -> bool { 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) { fn clear_unsupported_structured_view(&mut self) {
@@ -1888,11 +1888,28 @@ mod tests {
} }
#[test] #[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 { let mut draft = DraftPane {
advanced_collapsed: false, advanced_collapsed: false,
..DraftPane::default() ..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.structured_view);
assert!(draft.field_order().contains(&DraftField::StructuredToggle)); assert!(draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
@@ -1901,6 +1918,12 @@ mod tests {
draft.toggle_structured_view(); draft.toggle_structured_view();
assert!(draft.structured_view); assert!(draft.structured_view);
draft.cycle_preset(1); 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_eq!(draft.command(), "opencode");
assert!( assert!(
!draft.structured_view, !draft.structured_view,
@@ -1928,9 +1951,9 @@ mod tests {
/// A custom command typed into the free-text field, run through the same /// 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 /// 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 /// env prefix, or a wrapper still resolves to Claude Code or Codex, so
/// offered and the request carries `Structured`; an unrelated program /// the toggle stays offered and the request carries `Structured`; an
/// does not. /// unrelated program does not.
#[test] #[test]
fn structured_view_follows_command_resolution_for_custom_commands() { fn structured_view_follows_command_resolution_for_custom_commands() {
let cases: &[(&str, bool)] = &[ let cases: &[(&str, bool)] = &[
@@ -1952,6 +1975,16 @@ mod tests {
("yarn --silent dlx claude-code", true), ("yarn --silent dlx claude-code", true),
("bun x claude-code", true), ("bun x claude-code", true),
("\"/opt/my agent/claude\" --resume", 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), ("bash", false),
("opencode", false), ("opencode", false),
("/usr/local/bin/bash -l", 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 /// 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 /// 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] #[test]
fn structured_view_follows_command_resolution_for_presets() { fn structured_view_follows_command_resolution_for_presets() {
let mut draft = DraftPane { let mut draft = DraftPane {
@@ -2014,6 +2047,11 @@ mod tests {
label: "Claude (npx)".into(), label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(), command: "npx claude-code --resume".into(),
}, },
SpawnPreset {
id: "codex-wrapped".into(),
label: "Codex (npx)".into(),
command: "npx codex exec".into(),
},
SpawnPreset { SpawnPreset {
id: "aider".into(), id: "aider".into(),
label: "Aider".into(), label: "Aider".into(),
@@ -2027,7 +2065,13 @@ mod tests {
assert!(draft.structured_view); assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured); 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. // stale selection instead of letting an invalid request through.
draft.cycle_preset(1); draft.cycle_preset(1);
assert_eq!(draft.command(), "aider"); assert_eq!(draft.command(), "aider");
@@ -2036,11 +2080,11 @@ mod tests {
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal); 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 /// selection immediately, matching the preset-switch case above — an
/// invalid `Structured` request can never reach `build_request`. /// invalid `Structured` request can never reach `build_request`.
#[test] #[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 { let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")), picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false, worktree_enabled: false,
@@ -2049,22 +2093,22 @@ mod tests {
..DraftPane::default() ..DraftPane::default()
}; };
draft.selected_preset = draft.presets.len() - 1; 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(); draft.toggle_structured_view();
assert!(draft.structured_view); assert!(draft.structured_view);
typed(&mut draft, " -p"); typed(&mut draft, " -p");
assert_eq!(draft.command(), "claude -p"); assert_eq!(draft.command(), "codex -p");
assert!(draft.structured_view, "still Claude, selection survives"); 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); draft.on_input(&InputKey::Backspace);
} }
typed(&mut draft, "bash"); typed(&mut draft, "bash");
assert_eq!(draft.command(), "bash"); assert_eq!(draft.command(), "bash");
assert!( assert!(
!draft.structured_view, !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); 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-cli = { path = "../maestro-cli", optional = true, default-features = false }
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false } maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
maestro-tui = { path = "../maestro-tui", optional = true } maestro-tui = { path = "../maestro-tui", optional = true }
[dev-dependencies]
tempfile.workspace = true
+102
View File
@@ -0,0 +1,102 @@
//! Process-level routing for the umbrella `maestro` binary (#10): each
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
//! not just parse successfully.
//!
//! A malformed `--config` file makes every crate's dispatch fail identically
//! at config load, before any subcommand touches tmux, the network, or a
//! terminal. That lets the assertions tell "clap parsed it but the handler
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
//! exit 1).
//!
//! The absence tests below only compile when the matching feature is off, so
//! they run under CI's per-feature-subset matrix (`--no-default-features
//! --features cli|daemon|tui`) rather than the default all-features build.
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
}
fn bad_config() -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
f
}
fn run(args: &[&str]) -> Output {
Command::new(bin())
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
}
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
/// config crate's error surfacing through — never clap's own usage-error
/// page, which would mean the subcommand never reached its handler.
fn assert_dispatched(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
}
/// Never reached a handler: clap rejected the subcommand outright because the
/// feature that defines it is off. Only used by the `not(feature = "...")`
/// tests below, which themselves only exist in a single-feature build.
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
fn assert_unrecognized(out: &Output, name: &str) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
assert!(
stderr.contains("unrecognized subcommand"),
"stderr:\n{stderr}"
);
assert!(stderr.contains(name), "stderr:\n{stderr}");
}
#[cfg(feature = "cli")]
#[test]
fn list_dispatches_into_maestro_cli() {
let cfg = bad_config();
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "cli"))]
#[test]
fn list_is_unrecognized_without_cli_feature() {
assert_unrecognized(&run(&["list"]), "list");
}
#[cfg(feature = "daemon")]
#[test]
fn daemon_dispatches_into_maestro_daemon() {
let cfg = bad_config();
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "daemon"))]
#[test]
fn daemon_is_unrecognized_without_daemon_feature() {
assert_unrecognized(&run(&["daemon"]), "daemon");
}
#[cfg(feature = "tui")]
#[test]
fn tui_dispatches_into_maestro_tui() {
let cfg = bad_config();
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "tui"))]
#[test]
fn tui_is_unrecognized_without_tui_feature() {
assert_unrecognized(&run(&["tui"]), "tui");
}
+12 -9
View File
@@ -195,25 +195,28 @@ An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`. `crates/maestro-tui/src/action.rs`.
## Structured sessions: `claude-code-acp` ## Structured sessions: ACP adapters
A session spawned with `view: Structured` (#134) runs Claude Code as a child A session spawned with `view: Structured` runs Claude Code or Codex as a child
of the daemon over the Agent Client Protocol instead of in a tmux pane. The of the daemon over the Agent Client Protocol instead of in a tmux pane. The
daemon resolves the adapter binary, `claude-code-acp`, on its `PATH` at spawn resolved session command selects the adapter binary, which must be on the
time. Install it once per host: session's `PATH` when the spawn request sets one, or the daemon's `PATH`
otherwise. Install the adapter for each provider you use:
```sh ```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp npm install -g --prefix ~/.local @zed-industries/claude-code-acp
npm install -g --prefix ~/.local @agentclientprotocol/codex-acp
``` ```
That puts `claude-code-acp` in `~/.local/bin`, which must be on the daemon's That puts `claude-code-acp` and `codex-acp` in `~/.local/bin`, which must be on
`PATH` (a systemd user unit does not read your shell profile; set the applicable `PATH` (a systemd user unit does not read your shell profile; set
`Environment=PATH=…` in the unit or install somewhere already on it). The `Environment=PATH=…` in the unit or install somewhere already on it). The
adapter needs a logged-in Claude Code (`claude /login`). Claude adapter needs a logged-in Claude Code (`claude /login`); Codex uses the
Codex CLI's or adapter's configured authentication.
When the adapter is missing the spawn still creates the session, marked dead When the adapter is missing the spawn still creates the session, marked dead
with the install command above as its activity detail, so the sidebar says with the provider-specific install command as its activity detail, so clients
what to run. The adapter is a child of the daemon: it ends with a `DELETE say what to run. The adapter is a child of the daemon: it ends with a `DELETE
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon /api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
itself. A daemon restart ends an in-flight structured turn, but preserves its itself. A daemon restart ends an in-flight structured turn, but preserves its
worktree, session record, and event-log transcript. The replacement daemon worktree, session record, and event-log transcript. The replacement daemon
+66 -43
View File
@@ -16,10 +16,10 @@ does not exist yet, and nothing here describes it.
A terminal session's agent runs inside a tmux pane; a structured session's A terminal session's agent runs inside a tmux pane; a structured session's
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
daemon is the ACP **client**; the adapter process, `claude-code-acp`, is the daemon is the ACP **client**; the provider adapter process is the ACP
ACP **agent** — it wraps Claude Code's own SDK and translates the model's **agent**. It translates the provider's events into ACP `session/update`
tool calls into ACP `session/update` notifications instead of drawing them to notifications instead of drawing them to a pane. `crates/maestro-acp` owns the
a pane. `crates/maestro-acp` owns the daemon's side of that connection; daemon's side of that connection;
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two `crates/maestro-daemon/src/projection.rs` folds what it receives into the two
projections below. projections below.
@@ -37,30 +37,26 @@ ACP handle instead of a tmux pane).
## The adapter ## The adapter
`ADAPTER_BIN` (`claude-code-acp`) is resolved by walking `PATH` — the The resolved structured command selects a provider adapter. Its binary is
session's own `PATH` env var if the spawn request set one, the daemon's resolved by walking the session's `PATH` when the spawn request sets one, or
otherwise — and taking the first executable file named `claude-code-acp` on the daemon's `PATH` otherwise, and taking the first executable with that name.
it. Install it once per host:
```sh | Command | Adapter binary | Install command | Provider detail |
npm install -g --prefix ~/.local @zed-industries/claude-code-acp | --- | --- | --- | --- |
``` | `claude` | `claude-code-acp` | `npm install -g --prefix ~/.local @zed-industries/claude-code-acp` | Needs a logged-in Claude Code. |
| `codex` | `codex-acp` | `npm install -g --prefix ~/.local @agentclientprotocol/codex-acp` | Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration. |
That is `maestro_acp::INSTALL_COMMAND`, and it is not just documentation: it The selected install command is baked into `AcpError::AdapterMissing`. When an
is baked into `AcpError::AdapterMissing`'s message. When the adapter cannot adapter cannot be found, spawn still creates a **Dead** session with that
be found, spawn still creates the session — **Dead**, with that install command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
command as its `activity_detail` — so the sidebar names the fix instead of caveat). Provider authentication failures surface through the adapter's stderr
the session silently never appearing (see `docs/INSTALL.md` for the full tail in the same field.
install writeup, including the systemd `PATH` caveat). The adapter also needs
a logged-in Claude Code (`claude /login`); a missing login surfaces the same
way, via the adapter's own stderr tail in the failure message.
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
slow machine to boot a node process and the Claude Code SDK behind it, never slow machine to boot an adapter and its provider process, never long enough
long enough to hang a spawn indefinitely. Any handshake failure — missing to hang a spawn indefinitely. Any handshake failure — missing adapter, early
adapter, early exit, a protocol error, or the timeout — leaves no orphaned exit, a protocol error, or the timeout — leaves no orphaned process: `spawn`
process: `spawn` closes and reaps the child itself before returning the closes and reaps the child itself before returning the error.
error.
## The ACP handshake ## The ACP handshake
@@ -69,17 +65,17 @@ declares, deliberately, almost nothing:
| Capability | Declared | Why | | Capability | Declared | Why |
| --- | --- | --- | | --- | --- | --- |
| `fs.readTextFile` | `false` | The adapter uses Claude Code's own Read tool instead of asking the daemon to read a file on its behalf — the same tool a terminal-view Claude Code session uses. | | `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
| `fs.writeTextFile` | `false` | Same reasoning, for Write. | | `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
| `terminal` | `false` | The adapter runs Claude Code's own Bash tool rather than asking the daemon to open a terminal for it. | | `terminal` | `false` | Providers run commands through their own tool surface. |
Declining all three keeps the daemon out of the file/terminal surface Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because `claude-code-acp` has default; the daemon does not advertise support because neither supported
never asked for it, not because it was weighed and refused. adapter needs it, not because it was weighed and refused.
The one client method that *is* implemented for real is The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below. `session/request_permission` (#139) — see [Approvals](#approvals) below.
@@ -96,6 +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 already holds that history, and forwarding it too would duplicate every row
in the projections below. in the projections below.
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
shell commands, file changes, MCP calls, web search, image generation/view,
reasoning, plans, and token usage into standard ACP chunks and tool calls;
those fold into the same eight rows. Codex's `available_commands_update` and
`session_info_update`, like equivalent Claude updates, remain opaque
`Notice` rows. Its `session/load` capability is required for daemon recovery
just as it is for Claude.
## The eight transcript row kinds ## The eight transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured `TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
@@ -106,14 +110,14 @@ crate.
| Kind | Carries | Row identity | | Kind | Carries | Row identity |
| --- | --- | --- | | --- | --- | --- |
| `User` | An echoed `user_message_chunk` — the prompt text as ACP saw it. | `user:<message_id>`, or `user:<seq>` when the chunk carries no id. | | `User` | The prompt accepted by Maestro's structured prompt endpoint, persisted before ACP dispatch. An adapter `user_message_chunk` is also supported for imported ACP history. | `user:<uuid>` for a daemon-recorded prompt; `user:<message_id>` or `user:<seq>` for an ACP chunk. |
| `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>` / `agent:<seq>`. Later chunks for the same id are appended into `data.content.text` in place, not appended as new rows. | | `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>`, or `agent:<seq>` from the first chunk when no id is supplied. Later chunks for the same id—or consecutive anonymous text chunks—are appended into `data.content.text` in place. An empty anonymous chunk starts a fresh row, matching the turn marker emitted by adapters that omit message ids. |
| `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / `thought:<seq>`, coalesced the same way as `AgentText`. | | `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / first anonymous `thought:<seq>`, coalesced the same way as `AgentText`. |
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. | | `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. | | `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. | | `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. | | `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. | `notice:<seq>`. | | `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
`Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the `Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the
`agent-client-protocol` dependency's schema already deserializes (it `agent-client-protocol` dependency's schema already deserializes (it
@@ -147,7 +151,10 @@ choices, not daemon ones.
TUI renderers can never disagree about it. TUI renderers can never disagree about it.
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel, - Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
or a failure ends it (`TurnState.responding` / `.thinking` / or a failure ends it (`TurnState.responding` / `.thinking` /
`.active_tool_calls` are derived, not renderer-computed). `.active_tool_calls` / `.started_at` are derived, not renderer-computed).
Ending a turn appends a durable `maestroTurnMeta` footer with its elapsed
time, outcome, model, and context-window usage/delta. ACP does not expose
provider billing-token counts, so these are explicitly context tokens.
- Approval nonce minting, secrecy, and the single-active-approval rule (see - Approval nonce minting, secrecy, and the single-active-approval rule (see
below) — a client never sees an ACP permission request before the daemon below) — a client never sees an ACP permission request before the daemon
has already turned it into an `Approval` row and a nonce. has already turned it into an `Approval` row and a nonce.
@@ -164,9 +171,22 @@ choices, not daemon ones.
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a - How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
presentation for `Notice`. `Plan` is the exception: both clients omit it presentation for `Notice`. `Plan` is the exception: both clients omit it
from the transcript entirely and render the current plan in a separate from the transcript; the TUI may show the current plan in its status line,
plan rail (web) / status line (TUI) instead. while the web client deliberately has no stats or plan rail.
- How `responding` vs. `thinking` are distinguished visually, if at all. - The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
source for composer `/` autocomplete at any whitespace-delimited token under
the caret. Completing a command replaces only that token. The client omits
those state-only notices, along with `session_info_update`, from the visible
transcript.
- How live `responding`, `thinking`, tool activity, elapsed time, and context
usage are drawn. The web client keeps these in a compact strip above the
composer and renders each durable turn footer inline with the transcript.
- How the operator is asked to resolve an `Approval` row — the daemon only - How the operator is asked to resolve an `Approval` row — the daemon only
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`), exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
never the UI that solicits it. never the UI that solicits it.
@@ -197,8 +217,9 @@ every row, once appended, is only ever patched or left alone.
The event log's own line position is the cursor: 1-based, shared by ACP The event log's own line position is the cursor: 1-based, shared by ACP
notifications and daemon-generated rows alike (`sidecar.rs`'s notifications and daemon-generated rows alike (`sidecar.rs`'s
`PersistedEvent` envelope is what lets an `Approval` row share a cursor `PersistedEvent` envelope is what lets `User` and `Approval` rows share a cursor
sequence with raw ACP JSON without pretending to be ACP traffic). Two ways to sequence with raw ACP JSON without pretending to be ACP traffic; the envelope
also carries durable `Turn` footers). Two ways to
read it: read it:
- **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and - **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and
@@ -247,7 +268,7 @@ pending decision, entirely on the daemon side:
- **Secrecy from the agent and from durable history.** The adapter itself - **Secrecy from the agent and from durable history.** The adapter itself
never learns the nonce — it is a daemon-to-client secret, never sent back never learns the nonce — it is a daemon-to-client secret, never sent back
over the ACP connection. It is likewise never written to the event log: over the ACP connection. It is likewise never written to the event log:
restoring an `Approval` row from disk (`Projection::restore_approval`) restoring an `Approval` row from disk (`Projection::restore_row`)
produces a historical row with no `pending_approval` in state, because the produces a historical row with no `pending_approval` in state, because the
nonce that would make it "pending" never made it to disk in the first nonce that would make it "pending" never made it to disk in the first
place. A restart therefore cannot resume a pending approval — see place. A restart therefore cannot resume a pending approval — see
@@ -289,7 +310,9 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn." allowed to claim the daemon's idea of "the active turn."
- A prompt claims the turn and dispatches it to ACP as one operation. While a - A prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing — one active turn per session, never a hidden queue of prompts. queuing — one active turn per session, never a hidden queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`, - Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
@@ -315,8 +338,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw - **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
ACP `SessionNotification` (kept in its original JSON so old logs stay ACP `SessionNotification` (kept in its original JSON so old logs stay
readable) or a `{"maestroEvent": "approval", "row": …}` envelope for a readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
daemon-generated row. Both share the same 1-based line-position cursor. A for a daemon-generated row. Both share the same 1-based line-position cursor. A
line a hard kill left half-written is terminated before the next append, so line a hard kill left half-written is terminated before the next append, so
it keeps its position and is skipped as malformed rather than corrupting it keeps its position and is skipped as malformed rather than corrupting
what follows. An append that fails *while the daemon is running* — a what follows. An append that fails *while the daemon is running* — a
+14 -1
View File
@@ -11,7 +11,8 @@
"@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0" "@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
}, },
"devDependencies": { "devDependencies": {
"happy-dom": "^20.11.6", "happy-dom": "^20.11.6",
@@ -1222,6 +1223,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+2 -1
View File
@@ -16,7 +16,8 @@
"@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0" "@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
}, },
"devDependencies": { "devDependencies": {
"happy-dom": "^20.11.6", "happy-dom": "^20.11.6",
+10
View File
@@ -20,6 +20,9 @@ importers:
'@xterm/xterm': '@xterm/xterm':
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.5.0 version: 5.5.0
marked:
specifier: ^18.0.11
version: 18.0.11
devDependencies: devDependencies:
happy-dom: happy-dom:
specifier: ^20.11.6 specifier: ^20.11.6
@@ -424,6 +427,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.11:
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
engines: {node: '>= 20'}
hasBin: true
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -864,6 +872,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.11: {}
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.12: {} nanoid@3.3.12: {}
+16
View File
@@ -12,6 +12,7 @@ import type {
RecentDirs, RecentDirs,
Session, Session,
StructuredApprovalDecision, StructuredApprovalDecision,
StructuredConfigRequest,
StructuredPromptRequest, StructuredPromptRequest,
Uuid, Uuid,
} from "./types.js"; } from "./types.js";
@@ -93,6 +94,21 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
); );
} }
/** Change one option advertised by a live ACP session. */
export async function setStructuredConfig(
id: Uuid,
req: StructuredConfigRequest,
): Promise<void> {
await jsonOrError(
await fetch(routes.structuredConfig(id), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(req),
}),
);
}
/** Report whether this client's pane is the one currently showing `id` — not /** Report whether this client's pane is the one currently showing `id` — not
* merely highlighted in a sidebar. The daemon uses this to clear an * merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough * `AwaitingInput` glow once the pane has been looked at long enough
+21 -2
View File
@@ -64,6 +64,7 @@ export function createAttachConnection(
<div class="attach-body"></div> <div class="attach-body"></div>
`; `;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement; const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
@@ -323,6 +324,9 @@ export function createAttachConnection(
let ws: WebSocket; let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS; let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null; let reconnectTimer: number | null = null;
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
// Follow it once per socket; later scrolling remains under the operator's control.
let awaitingReplay = true;
const sendControl = (msg: ClientControl) => { const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return; if (ws.readyState !== WebSocket.OPEN) return;
@@ -342,6 +346,7 @@ export function createAttachConnection(
}; };
function connect(): void { function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId)); ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
@@ -362,10 +367,20 @@ export function createAttachConnection(
return; return;
} }
if (ev.data instanceof ArrayBuffer) { if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data)); const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) { } else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer". // Some browsers may default to Blob despite binaryType="arraybuffer".
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b))); void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
} }
}; };
@@ -463,6 +478,10 @@ export function createAttachConnection(
// The closure above: the same bytes-out path keystrokes and the mobile key // The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take. // bar already take.
sendInput, sendInput,
updateInfo(next: AttachInfo) {
titleEl.innerHTML = paneTitleHtml(sessionId, next);
titleEl.title = paneTitleAttr(sessionId, next);
},
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
+1
View File
@@ -27,6 +27,7 @@ function fakeConn(kind: string, id: string) {
sendInput(data: string) { sendInput(data: string) {
sent.push({ id, data }); sent.push({ id, data });
}, },
updateInfo() {},
dispose() { dispose() {
disposed.push(`${kind}:${id}`); disposed.push(`${kind}:${id}`);
}, },
+1
View File
@@ -110,6 +110,7 @@ export class ConnectionPool {
} else { } else {
this.cache.delete(id); this.cache.delete(id);
this.cache.set(id, conn); // LRU refresh this.cache.set(id, conn); // LRU refresh
if (info) conn.updateInfo(info);
} }
} else { } else {
conn = this.create(id, info); conn = this.create(id, info);
+25
View File
@@ -130,6 +130,31 @@ describe("DaemonLink", () => {
expect(link.status().state).toBe("online"); expect(link.status().state).toBe("online");
}); });
it("rechecks immediately when the browser returns from the background", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
reply = info("aaa", "i1");
link.recover();
await Promise.resolve();
expect(link.status().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => { it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual(); const sched = manual();
const reload = vi.fn(); const reload = vi.fn();
+11
View File
@@ -148,6 +148,17 @@ export class DaemonLink {
void this.probe(); void this.probe();
} }
/**
* Recheck liveness after the browser resumes from the background. Mobile
* browsers may suspend both WebSocket events and timers while a PWA is
* hidden, leaving a stale `down` state after the daemon is already back.
*/
recover(): void {
if (this.state === "updating") return;
this.noteDisconnected();
void this.probe();
}
/** /**
* One `/api/info` round-trip. Resolves `true` when the daemon answered. * One `/api/info` round-trip. Resolves `true` when the daemon answered.
* *
+15
View File
@@ -132,6 +132,21 @@ describe("CatalogStore reducer", () => {
}); });
describe("CatalogStore reconnect", () => { describe("CatalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.start();
store.reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => { it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket()); const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets); const { store, scheduler } = storeWith(sockets);
+17
View File
@@ -59,6 +59,23 @@ export class CatalogStore {
this.connect(); this.connect();
} }
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// A close event from the superseded socket must not schedule a second
// connection alongside the new one.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.connect();
}
/** Current reconnect delay, for tests asserting the backoff schedule. */ /** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number { backoff(): number {
return this.backoffMs; return this.backoffMs;
+13
View File
@@ -107,6 +107,19 @@ void link.start();
store.start(); store.start();
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after a
// transient lost connection has already recovered.
function recoverFromBackground(): void {
if (document.visibilityState !== "visible") return;
link.recover();
store.reconnect();
}
document.addEventListener("visibilitychange", recoverFromBackground);
window.addEventListener("pageshow", recoverFromBackground);
window.addEventListener("online", recoverFromBackground);
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed // Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite. // without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) { if (import.meta.env.DEV && location.search.includes("mock")) {
+3
View File
@@ -4,6 +4,7 @@
// transcript (structured.ts). // transcript (structured.ts).
import type { Uuid } from "./types.js"; import type { Uuid } from "./types.js";
import type { AttachInfo } from "./paneTitle.js";
export interface PaneConnection { export interface PaneConnection {
readonly sessionId: Uuid; readonly sessionId: Uuid;
@@ -21,6 +22,8 @@ export interface PaneConnection {
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection * literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
* has no PTY, so this is a no-op there. */ * has no PTY, so this is a no-op there. */
sendInput(data: string): void; sendInput(data: string): void;
/** Refresh the pane header after the catalog renames a live session. */
updateInfo(info: AttachInfo): void;
/** Fully disconnect and remove the element. */ /** Fully disconnect and remove the element. */
dispose(): void; dispose(): void;
} }
+5
View File
@@ -52,6 +52,11 @@ export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`; return `${session(id)}/structured/cancel`;
} }
/** `POST /api/sessions/:id/structured/config` — change an ACP session option. */
export function structuredConfig(id: Uuid): string {
return `${session(id)}/structured/config`;
}
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission /** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
* request. */ * request. */
export function structuredApproval(id: Uuid, nonce: string): string { export function structuredApproval(id: Uuid, nonce: string): string {
+15 -9
View File
@@ -26,15 +26,21 @@ describe("buildSpawnRequest", () => {
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } }); expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
}); });
it("requests structured view only for Claude Code", () => { it.each(["claude", "FOO=1 /usr/local/bin/codex", "env -i codex"])(
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({ "requests structured view for resolved %s commands",
req: { (command) => {
cwd: base.cwd, expect(buildSpawnRequest({ ...base, command, structuredView: true }, null)).toEqual({
command: "claude", req: {
metadata: { name: "amber-fox" }, cwd: base.cwd,
view: "Structured", command,
}, metadata: { name: "amber-fox" },
}); view: "Structured",
},
});
},
);
it("does not request structured view for unsupported commands", () => {
expect( expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null), buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({ ).toEqual({
+9 -2
View File
@@ -15,6 +15,13 @@ import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js"; import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
function supportsStructuredView(command: string): boolean {
const tool = splitCommand(command).tool;
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
}
/** Where a worktree's branch comes from: freshly created (default) or picked /** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */ * from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing"; export type WorktreeBranchMode = "new" | "existing";
@@ -68,7 +75,7 @@ export function buildSpawnRequest(
metadata, metadata,
}; };
if (values.structuredView && splitCommand(values.command).tool === "claude") { if (values.structuredView && supportsStructuredView(values.command)) {
req.view = "Structured"; req.view = "Structured";
} }
@@ -369,7 +376,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let commandTouched = false; let commandTouched = false;
function updateStructuredAvailability(): void { function updateStructuredAvailability(): void {
const supported = splitCommand(command).tool === "claude"; const supported = supportsStructuredView(command);
structuredWrap.hidden = !supported; structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false; if (!supported) structuredView.checked = false;
} }
+25 -11
View File
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("is default-off and shown only for the resolved Claude Code command", async () => { it("is default-off and shown for resolved Claude Code and Codex commands", async () => {
stubApi(); stubApi();
handle = createSpawnForm({ onSuccess() {} }); handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
@@ -59,26 +59,33 @@ describe("spawn form structured view", () => {
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(false); expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click(); const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
expect(wrap.hidden).toBe(true); custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click(); command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
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(); stubApi();
handle = createSpawnForm({ onSuccess() {} }); handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
await drain(); 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]")!; const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
checkbox.click(); checkbox.click();
expect(checkbox.checked).toBe(true); expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "opencode"; command.value = "opencode";
command.dispatchEvent(new Event("input")); command.dispatchEvent(new Event("input"));
@@ -86,7 +93,7 @@ describe("spawn form structured view", () => {
expect(checkbox.checked).toBe(false); expect(checkbox.checked).toBe(false);
}); });
it("emits Structured in the submitted request when selected", async () => { it.each(["claude", "codex"])("emits Structured for a selected %s command", async (tool) => {
const requests = stubApi(); const requests = stubApi();
handle = createSpawnForm({ handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false }, seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
@@ -95,13 +102,20 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
await drain(); 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.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit(); handle.el.requestSubmit();
await drain(); await drain();
expect(requests).toHaveLength(1); expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({ expect(JSON.parse(String(requests[0].body))).toMatchObject({
command: "claude", command: tool,
view: "Structured", view: "Structured",
}); });
}); });
+342 -85
View File
@@ -8,14 +8,20 @@ vi.mock("./api.js", async () => {
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined), sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined), cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined), resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
}; };
}); });
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js"; import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
setStructuredConfig,
} from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js"; import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
import { CatalogStore } from "./events.js"; import type { CatalogStore } from "./events.js";
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js"; import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js"; import type { ReducedState, StructuredEvent, TranscriptRow } from "./types.js";
/** Just enough of a WebSocket for the connection to drive (mirrors /** Just enough of a WebSocket for the connection to drive (mirrors
* events.test.ts's FakeSocket, adapted for JSON structured events). */ * events.test.ts's FakeSocket, adapted for JSON structured events). */
@@ -109,7 +115,43 @@ describe("structured connection: data flow", () => {
conn.dispose(); conn.dispose();
}); });
it("keeps snapshot plan rows out of the transcript while rendering the plan rail", () => { 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 ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
ws.open(); ws.open();
@@ -138,7 +180,56 @@ describe("structured connection: data flow", () => {
expect(transcript.textContent).toContain("before plan"); expect(transcript.textContent).toContain("before plan");
expect(transcript.textContent).toContain("after plan"); expect(transcript.textContent).toContain("after plan");
expect(transcript.textContent).not.toContain("[plan]"); expect(transcript.textContent).not.toContain("[plan]");
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("ship it"); expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
it("renders advertised session settings and sends changed values", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "reduced_state",
state: {
...idleState,
config_options: {
configOptions: [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "opus",
options: [
{ value: "opus", name: "Opus" },
{ value: "sonnet", name: "Sonnet" },
],
},
{
id: "effort",
name: "Effort",
category: "thought_level",
type: "select",
currentValue: "high",
options: [{ value: "high", name: "High" }],
},
],
},
},
});
expect(conn.el.querySelector(".structured-config-trigger")?.getAttribute("aria-label")).toBe(
"Session settings. Model: Opus. Effort: High",
);
(conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click();
const selects = conn.el.querySelectorAll<HTMLSelectElement>(".structured-config-select");
expect(selects).toHaveLength(2);
selects[0].value = "sonnet";
selects[0].dispatchEvent(new Event("change"));
expect(setStructuredConfig).toHaveBeenCalledWith("s1", {
config_id: "model",
value: "sonnet",
});
conn.dispose(); conn.dispose();
}); });
@@ -194,8 +285,7 @@ describe("structured connection: data flow", () => {
"user:1", "user:1",
"agent:1", "agent:1",
]); ]);
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("updated"); expect(transcript.textContent).not.toContain("updated");
expect(conn.el.querySelector(".structured-rail")!.textContent).not.toContain("first");
ws.emit({ ws.emit({
type: "projection_update", type: "projection_update",
@@ -216,7 +306,7 @@ describe("structured connection: data flow", () => {
state: idleState, state: idleState,
}); });
expect(conn.el.querySelector(".structured-rail .st-plan")).toBeNull(); expect(conn.el.querySelector(".structured-rail")).toBeNull();
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([ expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"agent:1", "agent:1",
]); ]);
@@ -235,6 +325,7 @@ describe("structured connection: data flow", () => {
transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) }, transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
state: idleState, state: idleState,
}); });
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.classList).toContain("st-row-enter");
ws.emit({ ws.emit({
type: "projection_update", type: "projection_update",
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) }, transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) },
@@ -242,11 +333,37 @@ describe("structured connection: data flow", () => {
}); });
const rows = conn.el.querySelectorAll(".st-row-agent"); const rows = conn.el.querySelectorAll(".st-row-agent");
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
expect(rows[0].classList).not.toContain("st-row-enter");
expect(conn.el.textContent).toContain("partial done"); expect(conn.el.textContent).toContain("partial done");
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
conn.dispose(); conn.dispose();
}); });
it("groups multiple tools in one user turn but keeps separate turns separate", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 6,
rows: [
row("user:1", "user", { content: { type: "text", text: "first" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("user:2", "user", { content: { type: "text", text: "second" } }),
toolRow("tool:3", "three"),
],
},
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.getAttribute("aria-hidden")).toBe("true");
expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => { it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -358,6 +475,142 @@ describe("structured connection: data flow", () => {
describe("structured connection: turn/composer gating", () => { describe("structured connection: turn/composer gating", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
const commandNotice = (commands: Array<{ name: string; description: string; hint?: string }>) =>
row("commands", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: commands.map(({ name, description, hint }) => ({
name,
description,
...(hint ? { input: { hint } } : {}),
})),
});
it("autocompletes the live ACP command catalog with keyboard navigation", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
commandNotice([
{ name: "yolo", description: "Implement directly" },
{ name: "blitz", description: "Complete a milestone", hint: "<milestone>" },
]),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const menu = conn.el.querySelector<HTMLElement>(".structured-command-menu")!;
input.value = "/";
input.dispatchEvent(new Event("input"));
expect(menu.hidden).toBe(false);
expect([...menu.querySelectorAll(".structured-command-name")].map((node) => node.textContent)).toEqual([
"/blitz",
"/yolo",
]);
expect(menu.querySelector(".structured-command-hint")!.textContent).toBe("<milestone>");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(input.value).toBe("/yolo ");
expect(menu.hidden).toBe(true);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "/yolo ");
conn.dispose();
});
it("filters commands as the operator types and accepts a touch selection", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
commandNotice([
{ name: "hourlog", description: "Reconcile hours" },
{ name: "humanizer", description: "Rewrite prose" },
{ name: "blitz", description: "Complete a milestone" },
]),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.value = "/hum";
input.dispatchEvent(new Event("input"));
const options = conn.el.querySelectorAll<HTMLElement>(".structured-command-option");
expect(options).toHaveLength(1);
expect(options[0].textContent).toContain("/humanizer");
options[0].click();
expect(input.value).toBe("/humanizer ");
expect(document.activeElement).toBe(input);
conn.dispose();
});
it("completes the slash token under the caret without replacing surrounding text", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
commandNotice([
{ name: "security-review", description: "Review pending changes" },
{ name: "status", description: "Show status" },
{ name: "blitz", description: "Complete a milestone" },
]),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.value = "asd /s dsa";
input.setSelectionRange(6, 6);
input.dispatchEvent(new Event("input"));
const options = conn.el.querySelectorAll<HTMLElement>(".structured-command-option");
expect([...options].map((option) => option.querySelector(".structured-command-name")!.textContent)).toEqual([
"/security-review",
"/status",
]);
options[0].click();
expect(input.value).toBe("asd /security-review dsa");
expect(input.selectionStart).toBe(input.value.indexOf("dsa"));
expect(input.selectionEnd).toBe(input.selectionStart);
conn.dispose();
});
it("replaces the rest of a partially edited slash token after the caret", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [commandNotice([{ name: "status", description: "Show status" }])],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.value = "before /stale after";
input.setSelectionRange(9, 9);
input.dispatchEvent(new Event("input"));
conn.el.querySelector<HTMLElement>(".structured-command-option")!.click();
expect(input.value).toBe("before /status after");
conn.dispose();
});
it("disables the composer while a turn is active and re-enables when idle", () => { it("disables the composer while a turn is active and re-enables when idle", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -368,9 +621,46 @@ describe("structured connection: turn/composer gating", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(true); expect(input.disabled).toBe(true);
expect(cancel.hidden).toBe(false); expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true); expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
conn.dispose();
});
it("shows live thinking time, tool activity, and context usage", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
const activity = conn.el.querySelector<HTMLElement>(".structured-activity")!;
ws.emit({
type: "reduced_state",
state: {
turn: {
responding: true,
thinking: true,
active_tool_calls: [],
started_at: new Date().toISOString(),
started_context_tokens: 10_000,
},
usage: { used: 12_400, size: 200_000 },
},
});
expect(activity.hidden).toBe(false);
expect(activity.textContent).toContain("Thinking · 0s · 12.4k context / 200k context");
ws.emit({
type: "reduced_state",
state: {
turn: { responding: true, thinking: false, active_tool_calls: ["bash-1", "read-1"] },
usage: { used: 12_800, size: 200_000 },
},
});
expect(activity.textContent).toContain("Running 2 tools");
ws.emit({ type: "reduced_state", state: idleState });
expect(activity.hidden).toBe(true);
conn.dispose(); conn.dispose();
}); });
@@ -701,9 +991,9 @@ describe("structured connection: reconnection/error/loading", () => {
}); });
const transcript = conn.el.querySelector(".structured-rows")!; const transcript = conn.el.querySelector(".structured-rows")!;
expect(transcript.textContent).toBe("Younew"); expect(transcript.textContent).toBe("new");
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull(); expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("reconnected"); expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose(); conn.dispose();
}); });
@@ -935,7 +1225,6 @@ describe("structured connection: reconnection/error/loading", () => {
describe("structured connection: transcript scrolling", () => { describe("structured connection: transcript scrolling", () => {
let frames: Map<number, FrameRequestCallback>; let frames: Map<number, FrameRequestCallback>;
let nextFrame: number; let nextFrame: number;
let resizeCallback: ResizeObserverCallback;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -950,14 +1239,6 @@ describe("structured connection: transcript scrolling", () => {
vi.stubGlobal("cancelAnimationFrame", (id: number) => { vi.stubGlobal("cancelAnimationFrame", (id: number) => {
frames.delete(id); frames.delete(id);
}); });
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
});
}); });
afterEach(() => { afterEach(() => {
@@ -986,13 +1267,6 @@ describe("structured connection: transcript scrolling", () => {
for (const [, cb] of pending) cb(0); for (const [, cb] of pending) cb(0);
} }
function resizeBody(width: number): void {
resizeCallback(
[{ contentRect: { width } } as ResizeObserverEntry],
{} as ResizeObserver,
);
}
it("shows the bottom after the initial transcript snapshot is laid out", () => { it("shows the bottom after the initial transcript snapshot is laid out", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -1017,7 +1291,7 @@ describe("structured connection: transcript scrolling", () => {
it("keeps pinned appends and patches at the bottom after layout", () => { it("keeps pinned appends and patches at the bottom after layout", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
const { values } = metrics(conn, 600, 200); const { scroll, values } = metrics(conn, 600, 200);
ws.open(); ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames(); runFrames();
@@ -1033,6 +1307,7 @@ describe("structured connection: transcript scrolling", () => {
}); });
runFrames(); runFrames();
expect(values.top).toBe(520); expect(values.top).toBe(520);
expect(scroll.style.scrollBehavior).toBe("smooth");
values.height = 780; values.height = 780;
ws.emit({ ws.emit({
@@ -1047,6 +1322,32 @@ describe("structured connection: transcript scrolling", () => {
}); });
runFrames(); runFrames();
expect(values.top).toBe(580); 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(); conn.dispose();
}); });
@@ -1075,30 +1376,6 @@ describe("structured connection: transcript scrolling", () => {
conn.dispose(); conn.dispose();
}); });
it.each([
{ position: "pinned", top: 800, expected: 850, manuallyScrolled: false },
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
])("preserves the $position position across the narrow breakpoint", ({ top, expected, manuallyScrolled }) => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 1000, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.top = top;
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
resizeBody(500);
values.clientHeight = 150;
runFrames();
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
expect(values.top).toBe(expected);
resizeBody(500);
expect(frames.size).toBe(0);
conn.dispose();
});
it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => { it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -1211,14 +1488,11 @@ describe("structured connection: transcript scrolling", () => {
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!; const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 }); Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.dispatchEvent(new Event("input")); input.dispatchEvent(new Event("input"));
resizeBody(500);
expect(frames.size).toBe(0); expect(frames.size).toBe(0);
conn.onMount({ focus: false }); conn.onMount({ focus: false });
expect(frames.size).toBe(2); expect(frames.size).toBe(2);
conn.dispose(); conn.dispose();
expect(frames.size).toBe(0); expect(frames.size).toBe(0);
resizeBody(700);
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
runFrames(); runFrames();
expect(values.top).toBe(25); expect(values.top).toBe(25);
}); });
@@ -1227,6 +1501,18 @@ describe("structured connection: transcript scrolling", () => {
describe("structured connection: pane lifecycle", () => { describe("structured connection: pane lifecycle", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("keeps the composer outside the transcript scroll region", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const main = conn.el.querySelector(".structured-main")!;
const scroll = conn.el.querySelector(".structured-scroll")!;
const composer = conn.el.querySelector(".structured-composer")!;
expect(main.contains(scroll)).toBe(true);
expect(main.contains(composer)).toBe(true);
expect(scroll.contains(composer)).toBe(false);
conn.dispose();
});
it("sendInput is a no-op — there is no PTY behind a structured session", () => { it("sendInput is a no-op — there is no PTY behind a structured session", () => {
const conn = mount({ sockets: [new FakeSocket()] }); const conn = mount({ sockets: [new FakeSocket()] });
expect(() => conn.sendInput("\x0b")).not.toThrow(); expect(() => conn.sendInput("\x0b")).not.toThrow();
@@ -1264,41 +1550,12 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose(); conn.dispose();
}); });
it("dispose closes the socket and unsubscribes from the store", () => { it("dispose closes the socket", () => {
const store = new CatalogStore({ socket: () => new FakeSocket() as unknown as WebSocket });
const unsubscribe = vi.spyOn(store, "subscribe");
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store }); const conn = mount({ sockets: [ws] });
expect(unsubscribe).toHaveBeenCalled();
conn.dispose(); conn.dispose();
expect(ws.closed).toBe(true); expect(ws.closed).toBe(true);
}); });
it("reflects live worktree/blocked-work data from the store in the rail", () => {
const catalogWs = new FakeSocket();
const store = new CatalogStore({ socket: () => catalogWs as unknown as WebSocket });
store.start();
const session: Session = {
id: "s1",
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
blocked_work: { uncommitted_files: 1, untracked_files: 0, unpushed_commits: 2 },
};
catalogWs.emit({ type: "Snapshot", sessions: [session] } as never);
const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.textContent).toContain("feat/x");
conn.dispose();
});
}); });
describe("structured connection: deferred mount focus", () => { describe("structured connection: deferred mount focus", () => {
+495 -109
View File
@@ -1,4 +1,4 @@
// A single live structured session: a transcript + rail bound to the // A single live structured session: a transcript bound to the
// daemon's `WS /api/structured/:id` (#140). Mirrors attach.ts's contract — // daemon's `WS /api/structured/:id` (#140). Mirrors attach.ts's contract —
// decoupled from the UI, streaming whether or not mounted, torn down only by // decoupled from the UI, streaming whether or not mounted, torn down only by
// `dispose()` — but the wire is JSON rows, not PTY bytes, and every connect // `dispose()` — but the wire is JSON rows, not PTY bytes, and every connect
@@ -10,19 +10,24 @@ import {
cancelStructuredTurn, cancelStructuredTurn,
resolveStructuredApproval, resolveStructuredApproval,
sendStructuredPrompt, sendStructuredPrompt,
setStructuredConfig,
structuredWsUrl, structuredWsUrl,
} from "./api.js"; } from "./api.js";
import { requestToggleDrawer } from "./drawer.js"; import { requestToggleDrawer } from "./drawer.js";
import type { CatalogState, CatalogStore } from "./events.js"; import type { CatalogStore } from "./events.js";
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js"; import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js"; import type { PaneConnection } from "./pane.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { buildTranscriptRow, renderRail } from "./structuredRender.js"; import {
buildToolGroup,
buildTranscriptRow,
formatContextTokens,
formatElapsed,
} from "./structuredRender.js";
import type { import type {
AcpPlan, AcpSessionConfigChoice,
AcpUsage, AcpSessionConfigOption,
ReducedState, ReducedState,
Session,
StructuredApprovalDecision, StructuredApprovalDecision,
StructuredEvent, StructuredEvent,
TranscriptDelta, TranscriptDelta,
@@ -47,11 +52,23 @@ const idleTurn = (): ReducedState["turn"] => ({
active_tool_calls: [], active_tool_calls: [],
}); });
interface AvailableCommand {
name: string;
description: string;
input?: { hint?: string };
}
interface CommandToken {
start: number;
end: number;
query: string;
}
export function createStructuredConnection( export function createStructuredConnection(
sessionId: Uuid, sessionId: Uuid,
opts: StructuredOptions = {}, opts: StructuredOptions = {},
info?: AttachInfo, info?: AttachInfo,
store?: CatalogStore, _store?: CatalogStore,
socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)), socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)),
): PaneConnection { ): PaneConnection {
const el = document.createElement("div"); const el = document.createElement("div");
@@ -60,31 +77,54 @@ export function createStructuredConnection(
<header class="attach-header"> <header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button> <button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span> <span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
<span class="st-pills"></span>
<span class="attach-state" data-state="connecting">connecting…</span> <span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button> <button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header> </header>
<div class="structured-body"> <div class="structured-body">
<div class="structured-scroll"> <div class="structured-main">
<div class="structured-rows"></div> <div class="structured-scroll">
<div class="structured-rows"></div>
</div>
<div class="structured-activity" role="status" aria-live="polite" hidden>
<span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-text">Thinking</span>
</div>
<form class="structured-composer"> <form class="structured-composer">
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"></textarea> <div class="structured-input-wrap">
<button type="submit" class="btn btn-primary structured-send">Send</button> <div id="structured-command-menu-${sessionId}" class="structured-command-menu"
role="listbox" hidden></div>
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
autocomplete="off" aria-autocomplete="list" aria-expanded="false"
aria-controls="structured-command-menu-${sessionId}"></textarea>
</div>
<div class="structured-config" hidden>
<button type="button" class="structured-config-trigger" aria-haspopup="true"
aria-expanded="false" aria-label="Session settings" title="Session settings">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6"/></svg>
</button>
<div class="structured-config-menu" role="group" aria-label="Session settings" hidden></div>
</div>
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
</button>
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button> <button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
</form> </form>
</div> </div>
<aside class="structured-rail"></aside>
</div> </div>
`; `;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const pillsEl = el.querySelector(".st-pills") 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;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement; const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement; const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
const railEl = el.querySelector(".structured-rail") as HTMLElement; const activityEl = el.querySelector(".structured-activity") as HTMLDivElement;
const bodyEl = el.querySelector(".structured-body") as HTMLElement; const activityText = el.querySelector(".structured-activity-text") as HTMLSpanElement;
const composerForm = el.querySelector(".structured-composer") as HTMLFormElement; const composerForm = el.querySelector(".structured-composer") as HTMLFormElement;
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement; const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement; const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement; const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
@@ -108,15 +148,23 @@ export function createStructuredConnection(
const rowIndex = new Map<string, number>(); const rowIndex = new Map<string, number>();
const rowEls = new Map<string, HTMLElement>(); const rowEls = new Map<string, HTMLElement>();
const expandedRowIds = new Set<string>(); 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 state: ReducedState = { turn: idleTurn() };
let pendingApprovalRowId: string | null = null; let pendingApprovalRowId: string | null = null;
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId); let availableCommands: AvailableCommand[] = [];
let configBusy = false;
let filteredCommands: AvailableCommand[] = [];
let activeCommandIndex = -1;
let submissionGeneration = 0; let submissionGeneration = 0;
let awaitingBeginGeneration: number | null = null; let awaitingBeginGeneration: number | null = null;
let acceptedSubmission: { generation: number; socketGeneration: number } | null = null; let acceptedSubmission: { generation: number; socketGeneration: number } | null = null;
let socketGeneration = 0; let socketGeneration = 0;
let stateSocketGeneration = 0; let stateSocketGeneration = 0;
let disposed = false; let disposed = false;
let optimisticTurnStartedAt: number | null = null;
let activityTimer: number | null = null;
// Within 64px, wheel and touch imprecision should still count as following. // Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64; const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -125,7 +173,20 @@ export function createStructuredConnection(
let scrollWorkActive = true; let scrollWorkActive = true;
let parkedScrollTop: number | null = null; let parkedScrollTop: number | null = null;
let scrollFrame: 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 { function cancelScrollFrame(): void {
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame); if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
@@ -133,15 +194,17 @@ export function createStructuredConnection(
pendingScroll = null; pendingScroll = null;
} }
function beginTranscriptLayout(forceBottom = false): void { function beginTranscriptLayout(forceBottom = false, smooth = false): void {
if (!scrollWorkActive || disposed) return; if (!scrollWorkActive || disposed) return;
if (pendingScroll) { if (pendingScroll) {
if (forceBottom) pendingScroll.follow = true; if (forceBottom) pendingScroll.follow = true;
if (smooth) pendingScroll.smooth = true;
return; return;
} }
pendingScroll = { pendingScroll = {
top: scrollEl.scrollTop, top: scrollEl.scrollTop,
follow: forceBottom || followingBottom, follow: forceBottom || followingBottom,
smooth,
}; };
} }
@@ -152,30 +215,50 @@ export function createStructuredConnection(
const pending = pendingScroll; const pending = pendingScroll;
pendingScroll = null; pendingScroll = null;
if (!pending || disposed || !scrollWorkActive) return; if (!pending || disposed || !scrollWorkActive) return;
scrollEl.scrollTop = pending.follow const target = pending.follow
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight) ? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
: pending.top; : 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 { function updateTranscriptLayout(update: () => void, forceBottom = false, smooth = false): void {
beginTranscriptLayout(forceBottom); beginTranscriptLayout(forceBottom, smooth);
update(); update();
finishTranscriptLayout(); finishTranscriptLayout();
} }
function onTranscriptScroll(): void { function onTranscriptScroll(): void {
if (smoothFollowing) return;
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop; const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX; followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
cancelScrollFrame(); cancelScrollFrame();
} }
function onTranscriptClick(event: Event): void { function onTranscriptClick(event: Event): void {
if ((event.target as Element | null)?.closest(".st-tool-head")) beginTranscriptLayout(); if ((event.target as Element | null)?.closest(".st-tool-head, .st-tool-group-head")) {
beginTranscriptLayout(false, true);
}
}
function settleDisclosureLayout(): void {
finishTranscriptLayout();
window.setTimeout(() => updateTranscriptLayout(() => {}, false, true), 190);
} }
scrollEl.addEventListener("scroll", onTranscriptScroll); scrollEl.addEventListener("scroll", onTranscriptScroll);
scrollEl.addEventListener("click", onTranscriptClick, true); scrollEl.addEventListener("click", onTranscriptClick, true);
scrollEl.addEventListener("wheel", stopSmoothFollow, { passive: true });
scrollEl.addEventListener("touchstart", stopSmoothFollow, { passive: true });
function lastRowIdOfKind(kind: TranscriptRowKind): string | null { function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
@@ -220,51 +303,96 @@ export function createStructuredConnection(
(expanded) => { (expanded) => {
if (expanded) expandedRowIds.add(row.id); if (expanded) expandedRowIds.add(row.id);
else expandedRowIds.delete(row.id); else expandedRowIds.delete(row.id);
finishTranscriptLayout(); settleDisclosureLayout();
}, },
); );
if (!el) return null; 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") { if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
expandedRowIds.delete(row.id); expandedRowIds.delete(row.id);
} }
return el; return el;
} }
function insertRowElement(row: TranscriptRow, el: HTMLElement): void { function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
const index = rowIndex.get(row.id) ?? rows.length - 1; const groups = new Map<string, TranscriptRow[]>();
let next: HTMLElement | null = null; let turnId = "before-first-message";
for (let i = index + 1; i < rows.length; i++) { for (const row of rows) {
next = rowEls.get(rows[i].id) ?? null; if (row.kind === "user") turnId = row.id;
if (next) break; if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const id = `turn:${turnId}`;
const group = groups.get(id) ?? [];
group.push(row);
groups.set(id, group);
} }
rowEls.set(row.id, el); return groups;
rowsEl.insertBefore(el, next);
} }
function mountRow(row: TranscriptRow): void { function rebuildTranscript(): void {
const el = buildRowElement(row); const active = document.activeElement as HTMLElement | null;
if (el) insertRowElement(row, el); const focusedRowId = active?.closest<HTMLElement>("[data-row-id]")?.dataset.rowId;
} const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
const groups = toolGroupsByTurn();
const groupedRowIds = new Set<string>();
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
function remountRow(row: TranscriptRow): void { rowEls.clear();
const old = rowEls.get(row.id); groupEls.clear();
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null; const children: HTMLElement[] = [];
const restoreFocus = document.activeElement === oldToolHead; let turnId = "before-first-message";
const el = buildRowElement(row); for (const row of rows) {
if (!el) { if (row.kind === "user") turnId = row.id;
old?.remove(); const groupId = `turn:${turnId}`;
rowEls.delete(row.id); const groupRows = groups.get(groupId) ?? [];
expandedRowIds.delete(row.id); if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
return; if (groupedRowIds.has(row.id)) continue;
const toolEls = groupRows.flatMap((toolRow) => {
groupedRowIds.add(toolRow.id);
const toolEl = buildRowElement(toolRow);
if (!toolEl) return [];
rowEls.set(toolRow.id, toolEl);
return [toolEl];
});
const groupEl = buildToolGroup(
groupId,
groupRows,
toolEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
);
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
continue;
}
const rowEl = buildRowElement(row);
if (!rowEl) continue;
rowEls.set(row.id, rowEl);
children.push(rowEl);
} }
if (old) { // A completed turn's footer is useful as the conversation's current
rowEls.set(row.id, el); // tail, but repeating duration/model/token summaries throughout history
old.replaceWith(el); // makes them read like transcript content. Keep only a footer that is the
} else { // final visible item; appending the next turn naturally retires it.
insertRowElement(row, el); 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) {
groupEls.get(focusedGroupId)?.querySelector<HTMLButtonElement>(".st-tool-group-head")?.focus();
} }
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
if (restoreFocus) newToolHead?.focus();
} }
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void { function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
@@ -277,9 +405,8 @@ export function createStructuredConnection(
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id); for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
rowIndex.clear(); rowIndex.clear();
rows.forEach((row, i) => rowIndex.set(row.id, i)); rows.forEach((row, i) => rowIndex.set(row.id, i));
rowEls.clear(); rebuildTranscript();
rowsEl.replaceChildren(); syncAvailableCommands();
for (const row of rows) mountRow(row);
}, forceBottom); }, forceBottom);
receivedSnapshot = true; receivedSnapshot = true;
} }
@@ -288,18 +415,16 @@ export function createStructuredConnection(
if (!delta) return; if (!delta) return;
updateTranscriptLayout(() => { updateTranscriptLayout(() => {
if (delta.type === "append") { if (delta.type === "append") {
if (receivedSnapshot) enteringRowIds.add(delta.row.id);
rowIndex.set(delta.row.id, rows.length); rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row); rows.push(delta.row);
mountRow(delta.row);
} else if (delta.type === "patch") { } else if (delta.type === "patch") {
const i = rowIndex.get(delta.row.id); const i = rowIndex.get(delta.row.id);
if (i === undefined) { if (i === undefined) {
rowIndex.set(delta.row.id, rows.length); rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row); rows.push(delta.row);
mountRow(delta.row);
} else { } else {
rows[i] = delta.row; rows[i] = delta.row;
remountRow(delta.row);
} }
} else { } else {
const i = rowIndex.get(delta.id); const i = rowIndex.get(delta.id);
@@ -308,38 +433,140 @@ export function createStructuredConnection(
expandedRowIds.delete(delta.id); expandedRowIds.delete(delta.id);
rowIndex.clear(); rowIndex.clear();
rows.forEach((row, idx) => rowIndex.set(row.id, idx)); rows.forEach((row, idx) => rowIndex.set(row.id, idx));
rowEls.get(delta.id)?.remove();
rowEls.delete(delta.id);
} }
rebuildTranscript();
syncAvailableCommands();
}, false, true);
}
function sessionConfigOptions(): AcpSessionConfigOption[] {
return state.config_options?.configOptions ?? [];
}
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
return (option.options ?? []).flatMap((choice) =>
"options" in choice ? choice.options : [choice],
);
}
function currentConfigLabel(option: AcpSessionConfigOption): string {
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
?? option.currentValue;
}
async function changeConfig(option: AcpSessionConfigOption, value: string | boolean): Promise<void> {
configBusy = true;
renderConfig();
clearError();
try {
await setStructuredConfig(sessionId, { config_id: option.id, value });
} catch (error) {
showError(error instanceof Error ? error.message : "Could not change the session setting");
} finally {
configBusy = false;
renderConfig();
}
}
function renderConfig(): void {
const options = sessionConfigOptions();
configEl.hidden = options.length === 0;
if (options.length === 0) {
configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false");
return;
}
const ordered = [...options].sort((a, b) => {
const rank = (category?: string | null) =>
["model", "thought_level", "mode", "model_config"].indexOf(category ?? "");
const aRank = rank(a.category);
const bRank = rank(b.category);
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
}); });
} const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
configTrigger.title = summary;
function toolRowCount(): number { configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
return rows.reduce((n, r) => n + (r.kind === "tool_call" || r.kind === "tool_diff" ? 1 : 0), 0); configTrigger.disabled = configBusy;
} configMenu.replaceChildren();
for (const option of ordered) {
function renderPills(): void { const row = document.createElement("label");
const parts: string[] = []; row.className = "structured-config-row";
if (state.model) parts.push(state.model); const name = document.createElement("span");
if (state.mode) parts.push(state.mode); name.className = "structured-config-name";
pillsEl.textContent = parts.join(" · "); name.textContent = option.name;
} row.append(name);
if (option.type === "select") {
function renderRailNow(): void { const select = document.createElement("select");
renderRail(railEl, { select.className = "structured-config-select";
plan: (state.plan ?? null) as AcpPlan | null, select.disabled = configBusy;
turn: state.turn, for (const choice of configChoices(option)) {
toolRowCount: toolRowCount(), const item = document.createElement("option");
usage: (state.usage ?? null) as AcpUsage | null, item.value = choice.value;
session: latestSession, item.textContent = choice.name;
}); item.title = choice.description ?? "";
select.append(item);
}
select.value = String(option.currentValue);
select.addEventListener("change", () => void changeConfig(option, select.value));
row.append(select);
} else if (option.type === "boolean") {
const toggle = document.createElement("input");
toggle.type = "checkbox";
toggle.className = "structured-config-toggle";
toggle.checked = option.currentValue === true;
toggle.disabled = configBusy;
toggle.addEventListener("change", () => void changeConfig(option, toggle.checked));
row.append(toggle);
} else {
continue;
}
if (option.description) row.title = option.description;
configMenu.append(row);
}
} }
function renderComposerState(): void { function renderComposerState(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = active; input.disabled = active;
sendBtn.disabled = active; sendBtn.disabled = active;
cancelBtn.hidden = !state.turn.responding && !state.turn.thinking; sendBtn.hidden = projectedActive;
cancelBtn.hidden = !projectedActive;
if (active) closeCommandMenu();
renderActivity();
}
function liveElapsedMs(): number {
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticTurnStartedAt;
return startedAt === null ? 0 : Math.max(0, Date.now() - startedAt);
}
function renderActivity(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
activityEl.hidden = !active;
if (!active) {
optimisticTurnStartedAt = null;
if (activityTimer !== null) window.clearInterval(activityTimer);
activityTimer = null;
return;
}
const tools = state.turn.active_tool_calls.length;
const label = awaitingBeginGeneration !== null
? "Sending"
: state.turn.thinking
? "Thinking"
: tools > 0
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
: "Responding";
const parts = [label, formatElapsed(liveElapsedMs())];
if (typeof state.usage?.used === "number") {
const usage = formatContextTokens(state.usage.used);
parts.push(typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage);
}
activityText.textContent = parts.join(" · ");
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
} }
function reconcileAwaitingBegin(): void { function reconcileAwaitingBegin(): void {
@@ -363,15 +590,34 @@ export function createStructuredConnection(
updateTranscriptLayout(() => { updateTranscriptLayout(() => {
const prev = pendingApprovalRowId; const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId; pendingApprovalRowId = newPendingId;
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]); if (prev !== null || newPendingId !== null) rebuildTranscript();
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
}); });
} }
renderPills(); renderConfig();
renderRailNow();
renderComposerState(); renderComposerState();
} }
function closeConfigMenu(): void {
configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false");
}
configTrigger.addEventListener("click", () => {
const opening = configMenu.hidden;
configMenu.hidden = !opening;
configTrigger.setAttribute("aria-expanded", String(opening));
});
const closeConfigOnOutsidePress = (event: PointerEvent) => {
if (!configEl.contains(event.target as Node)) closeConfigMenu();
};
document.addEventListener("pointerdown", closeConfigOnOutsidePress);
configEl.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeConfigMenu();
configTrigger.focus();
}
});
// ---- error banner ---- // ---- error banner ----
let errorEl: HTMLElement | null = null; let errorEl: HTMLElement | null = null;
function showError(message: string): void { function showError(message: string): void {
@@ -388,19 +634,166 @@ export function createStructuredConnection(
} }
// ---- composer ---- // ---- composer ----
function commandsFromTranscript(): AvailableCommand[] {
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].kind !== "notice") continue;
const data = rows[i].data as
| { sessionUpdate?: string; availableCommands?: unknown[] }
| undefined;
if (data?.sessionUpdate !== "available_commands_update") continue;
return (data.availableCommands ?? []).flatMap((value) => {
if (!value || typeof value !== "object") return [];
const command = value as Partial<AvailableCommand>;
if (typeof command.name !== "string" || typeof command.description !== "string") return [];
return [{ name: command.name, description: command.description, input: command.input }];
});
}
return [];
}
function closeCommandMenu(): void {
commandMenu.hidden = true;
commandMenu.replaceChildren();
filteredCommands = [];
activeCommandIndex = -1;
input.setAttribute("aria-expanded", "false");
input.removeAttribute("aria-activedescendant");
}
function commandTokenAtCursor(): CommandToken | null {
const cursor = input.selectionStart;
if (cursor !== input.selectionEnd) return null;
let start = cursor;
while (start > 0 && !/\s/.test(input.value[start - 1])) start--;
let end = cursor;
while (end < input.value.length && !/\s/.test(input.value[end])) end++;
const match = /^\/([^\s/]*)$/.exec(input.value.slice(start, cursor));
return match ? { start, end, query: match[1].toLocaleLowerCase() } : null;
}
function chooseCommand(command: AvailableCommand): void {
const token = commandTokenAtCursor();
if (!token) return;
const before = input.value.slice(0, token.start);
const after = input.value.slice(token.end);
let replacement = `/${command.name}`;
let cursor = before.length + replacement.length;
if (after.length === 0) {
replacement += " ";
cursor++;
} else if (/^[ \t]/.test(after)) {
cursor++;
}
input.value = before + replacement + after;
closeCommandMenu();
autoResize();
input.focus();
input.setSelectionRange(cursor, cursor);
}
function renderCommandMenu(): void {
const token = commandTokenAtCursor();
if (!token || input.disabled || availableCommands.length === 0) {
closeCommandMenu();
return;
}
const { query } = token;
filteredCommands = availableCommands
.filter((command) => command.name.toLocaleLowerCase().includes(query))
.sort((a, b) => {
const aStarts = a.name.toLocaleLowerCase().startsWith(query);
const bStarts = b.name.toLocaleLowerCase().startsWith(query);
return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1;
});
if (filteredCommands.length === 0) {
closeCommandMenu();
return;
}
activeCommandIndex = Math.min(Math.max(activeCommandIndex, 0), filteredCommands.length - 1);
const options = filteredCommands.map((command, index) => {
const option = document.createElement("div");
option.id = `${commandMenu.id}-option-${index}`;
option.className = "structured-command-option";
option.setAttribute("role", "option");
option.setAttribute("aria-selected", String(index === activeCommandIndex));
const title = document.createElement("div");
title.className = "structured-command-title";
const name = document.createElement("span");
name.className = "structured-command-name";
name.textContent = `/${command.name}`;
const hint = document.createElement("span");
hint.className = "structured-command-hint";
hint.textContent = command.input?.hint ?? "";
title.append(name, hint);
const description = document.createElement("div");
description.className = "structured-command-description";
description.textContent = command.description.replace(/\s+/g, " ").trim();
option.append(title, description);
option.addEventListener("pointerdown", (event) => event.preventDefault());
option.addEventListener("click", () => chooseCommand(command));
return option;
});
commandMenu.replaceChildren(...options);
commandMenu.hidden = false;
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", options[activeCommandIndex].id);
options[activeCommandIndex].scrollIntoView?.({ block: "nearest" });
}
function syncAvailableCommands(): void {
availableCommands = commandsFromTranscript();
renderCommandMenu();
}
function autoResize(): void { function autoResize(): void {
updateTranscriptLayout(() => { updateTranscriptLayout(() => {
input.style.height = "auto"; input.style.height = "auto";
input.style.overflowY = "hidden";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`; input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
}); if (input.scrollHeight > 160) input.style.overflowY = "auto";
}, false, true);
} }
input.addEventListener("input", autoResize); input.addEventListener("input", () => {
autoResize();
activeCommandIndex = 0;
renderCommandMenu();
});
input.addEventListener("click", renderCommandMenu);
input.addEventListener("select", renderCommandMenu);
input.addEventListener("keyup", (event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) renderCommandMenu();
});
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
if (!commandMenu.hidden) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const direction = e.key === "ArrowDown" ? 1 : -1;
activeCommandIndex =
(activeCommandIndex + direction + filteredCommands.length) % filteredCommands.length;
renderCommandMenu();
return;
}
if ((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") {
e.preventDefault();
chooseCommand(filteredCommands[activeCommandIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
closeCommandMenu();
return;
}
}
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
composerForm.requestSubmit(); composerForm.requestSubmit();
} }
}); });
input.addEventListener("blur", () => {
window.setTimeout(() => {
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 0);
});
composerForm.addEventListener("submit", (e) => { composerForm.addEventListener("submit", (e) => {
e.preventDefault(); e.preventDefault();
const text = input.value; const text = input.value;
@@ -408,6 +801,7 @@ export function createStructuredConnection(
const generation = ++submissionGeneration; const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration; const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation; awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now();
acceptedSubmission = null; acceptedSubmission = null;
input.value = ""; input.value = "";
autoResize(); autoResize();
@@ -445,24 +839,6 @@ export function createStructuredConnection(
}); });
}); });
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
const NARROW_BREAKPOINT = 560;
const resizeObserver = new ResizeObserver((entries) => {
if (disposed) return;
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
const narrow = width < NARROW_BREAKPOINT;
if (bodyEl.classList.contains("st-narrow") === narrow) return;
updateTranscriptLayout(() => bodyEl.classList.toggle("st-narrow", narrow));
});
resizeObserver.observe(bodyEl);
// ---- live worktree/git-status rail data ----
const unsubscribeStore = store?.subscribe((catalog: CatalogState) => {
if (disposed) return;
latestSession = catalog.sessions.get(sessionId);
renderRailNow();
});
// ---- socket lifecycle ---- // ---- socket lifecycle ----
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let reconnectMs = INITIAL_RECONNECT_MS; let reconnectMs = INITIAL_RECONNECT_MS;
@@ -584,6 +960,7 @@ export function createStructuredConnection(
pendingScroll = { pendingScroll = {
top: parkedScrollTop ?? scrollEl.scrollTop, top: parkedScrollTop ?? scrollEl.scrollTop,
follow: followingBottom, follow: followingBottom,
smooth: false,
}; };
parkedScrollTop = null; parkedScrollTop = null;
finishTranscriptLayout(); finishTranscriptLayout();
@@ -599,6 +976,7 @@ export function createStructuredConnection(
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop; parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
scrollWorkActive = false; scrollWorkActive = false;
cancelScrollFrame(); cancelScrollFrame();
stopSmoothFollow();
cancelFocusFrame(); cancelFocusFrame();
}, },
focusTerminal() { focusTerminal() {
@@ -608,20 +986,28 @@ export function createStructuredConnection(
// No PTY behind a structured session — the leader's literal-byte // No PTY behind a structured session — the leader's literal-byte
// escape hatch has nothing to do here. // escape hatch has nothing to do here.
}, },
updateInfo(next: AttachInfo) {
titleEl.innerHTML = paneTitleHtml(sessionId, next);
titleEl.title = paneTitleAttr(sessionId, next);
},
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
if (activityTimer !== null) window.clearInterval(activityTimer);
mountToken++; mountToken++;
scrollWorkActive = false; scrollWorkActive = false;
cancelScrollFrame(); cancelScrollFrame();
stopSmoothFollow();
cancelFocusFrame(); cancelFocusFrame();
cancelReconnect(); cancelReconnect();
resizeObserver.disconnect();
unsubscribeStore?.();
closeCurrentSocket(); closeCurrentSocket();
expandedRowIds.clear(); expandedRowIds.clear();
expandedGroupIds.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll); scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true); scrollEl.removeEventListener("click", onTranscriptClick, true);
scrollEl.removeEventListener("wheel", stopSmoothFollow);
scrollEl.removeEventListener("touchstart", stopSmoothFollow);
document.removeEventListener("pointerdown", closeConfigOnOutsidePress);
el.remove(); el.remove();
}, },
}; };
+205 -105
View File
@@ -2,12 +2,12 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
branchFromWorktreePath, buildToolGroup,
buildTranscriptRow, buildTranscriptRow,
renderInlineMarkdown, renderInlineMarkdown,
renderRail, renderMarkdown,
} from "./structuredRender.js"; } from "./structuredRender.js";
import type { Session, TranscriptRow } from "./types.js"; import type { TranscriptRow } from "./types.js";
function row<K extends TranscriptRow["kind"]>( function row<K extends TranscriptRow["kind"]>(
kind: K, kind: K,
@@ -24,6 +24,7 @@ describe("buildTranscriptRow: user/agent/thought", () => {
); );
expect(el.className).toContain("st-row-user"); expect(el.className).toContain("st-row-user");
expect(el.textContent).toContain("hi <b>there</b>"); expect(el.textContent).toContain("hi <b>there</b>");
expect(el.querySelector(".st-who")).toBeNull();
// never actual markup from user input // never actual markup from user input
expect(el.querySelector("b")).toBeNull(); expect(el.querySelector("b")).toBeNull();
}); });
@@ -39,10 +40,15 @@ describe("buildTranscriptRow: user/agent/thought", () => {
expect(ps[0].innerHTML).toContain("<code>three</code>"); expect(ps[0].innerHTML).toContain("<code>three</code>");
}); });
it("renders a thought row as plain text", () => { it("trims ACP framing whitespace and renders thought-row inline markdown", () => {
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } })); const el = buildTranscriptRow(
row("agent_thought", {
content: { type: "text", text: "\n\n**Planning comply tool commentary update**\n" },
}),
);
expect(el.className).toContain("st-row-thought"); expect(el.className).toContain("st-row-thought");
expect(el.textContent).toBe("hmm"); expect(el.textContent).toBe("Planning comply tool commentary update");
expect(el.querySelector("strong")?.textContent).toBe("Planning comply tool commentary update");
}); });
it("falls back to a labelled placeholder for non-text content, never crashing", () => { it("falls back to a labelled placeholder for non-text content, never crashing", () => {
@@ -55,6 +61,21 @@ describe("buildTranscriptRow: user/agent/thought", () => {
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } })); const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
expect(el.textContent).toContain(long); expect(el.textContent).toContain(long);
}); });
it("copies message blocks on click and supports keyboard activation", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
const el = buildTranscriptRow(
row("agent_text", { content: { type: "text", text: "copy **this** exactly" } }),
);
expect(el.getAttribute("role")).toBe("button");
el.click();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("copy **this** exactly");
el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await Promise.resolve();
expect(writeText).toHaveBeenCalledTimes(2);
});
}); });
describe("renderInlineMarkdown", () => { describe("renderInlineMarkdown", () => {
@@ -67,6 +88,24 @@ describe("renderInlineMarkdown", () => {
}); });
}); });
describe("renderMarkdown", () => {
it("renders GFM tables as semantic table markup", () => {
const html = renderMarkdown("| Model | State |\n| --- | --- |\n| Opus | ready |");
expect(html).toContain("<table>");
expect(html).toContain("<th>Model</th>");
expect(html).toContain("<td>Opus</td>");
});
it("removes executable HTML and unsafe URLs", () => {
const html = renderMarkdown(
'<img src=x onerror="alert(1)"><script>alert(2)</script>[bad](javascript:alert(3))',
);
expect(html).not.toContain("<img");
expect(html).not.toContain("<script");
expect(html).not.toContain('href="javascript:');
});
});
describe("buildTranscriptRow: tool calls", () => { describe("buildTranscriptRow: tool calls", () => {
it("shows glyph, name, argument and no meta for a completed non-diff call", () => { it("shows glyph, name, argument and no meta for a completed non-diff call", () => {
const el = buildTranscriptRow( const el = buildTranscriptRow(
@@ -119,7 +158,10 @@ describe("buildTranscriptRow: tool calls", () => {
expect(head.getAttribute("aria-expanded")).toBe("true"); expect(head.getAttribute("aria-expanded")).toBe("true");
expect(out.textContent).toContain("match found"); expect(out.textContent).toContain("match found");
head.click(); head.click();
expect(out.hidden).toBe(true); expect(out.hidden).toBe(false);
expect(out.getAttribute("aria-hidden")).toBe("true");
expect(out.inert).toBe(true);
expect(out.classList.contains("is-expanded")).toBe(false);
}); });
it("does not expand when there is nothing to show", () => { it("does not expand when there is nothing to show", () => {
@@ -128,6 +170,134 @@ describe("buildTranscriptRow: tool calls", () => {
head.click(); head.click();
expect(head.getAttribute("aria-expanded")).toBe("false"); expect(head.getAttribute("aria-expanded")).toBe("false");
}); });
it("renders command fields and keeps the exact JSON available", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Bash(test)",
rawInput: { command: "printf ok", cwd: "/workspace", timeout_ms: 1000 },
rawOutput: { formatted_output: "ok", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
expect(el.querySelector(".st-tool-fact")!.textContent).toBe("Exit code0");
const raw = el.querySelector(".st-tool-raw") as HTMLDetailsElement;
expect(raw.open).toBe(false);
expect(raw.textContent).toContain('"timeout_ms": 1000');
expect(raw.textContent).toContain('"exit_code": 0');
});
it("renders MCP arguments and results without hiding the source payload", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "mcp.github.search",
rawInput: { server: "github", tool: "search", arguments: { query: "ACP" } },
rawOutput: { result: { count: 2 }, error: null },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const labels = [
...el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
].map((node) => node.querySelector(".st-tool-detail-label")?.textContent);
expect(labels).toEqual(["Arguments", "Result"]);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"server": "github"');
});
it("renders a recognized web search query", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Search the web",
rawInput: { type: "webSearch", query: "Agent Client Protocol", action: { type: "search" } },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail-label")!.textContent).toBe("Query");
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("Agent Client Protocol");
});
it("falls back to raw JSON for an unknown provider shape", () => {
const el = buildTranscriptRow(
row("tool_call", { title: "Custom", rawInput: { peculiar: [1, 2] }, rawOutput: "done" }),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(
el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
).toHaveLength(0);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"peculiar"');
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"done"');
});
it("renders standardized content before provider-specific and raw data", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Bash(test)",
content: [
{ type: "content", content: { type: "text", text: "typed result" } },
{ type: "content", content: { type: "resource_link", name: "report", uri: "file:///tmp/report" } },
{ type: "terminal", terminalId: "term-1" },
],
rawInput: { command: "test" },
rawOutput: { formatted_output: "typed result", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const inner = el.querySelector(".st-tool-out > .st-disclosure-clip > .st-disclosure-inner")!;
expect([...inner.children].map((node) => node.className)).toEqual([
"st-tool-text st-copyable",
"st-tool-resource",
"st-tool-terminal",
"st-tool-detail",
"st-tool-fact",
"st-tool-raw",
]);
expect(el.querySelector(".st-tool-resource")!.textContent).toContain("file:///tmp/report");
expect(el.querySelector(".st-tool-terminal")!.textContent).toBe("Terminal · term-1");
});
it("renders safe typed image and audio blocks as media controls", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Media",
content: [
{ type: "content", content: { type: "image", mimeType: "image/png", data: "aW1hZ2U=" } },
{ type: "content", content: { type: "audio", mimeType: "audio/mpeg", data: "YXVkaW8=" } },
],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect((el.querySelector(".st-tool-image") as HTMLImageElement).src).toBe("data:image/png;base64,aW1hZ2U=");
expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe("data:audio/mpeg;base64,YXVkaW8=");
});
});
describe("buildToolGroup", () => {
it("starts collapsed, then reveals independently expandable tool calls", () => {
const rows = [
row("tool_call", { title: "Read(a.ts)", content: [{ type: "content", content: { type: "text", text: "a" } }] }, "t1"),
row("tool_call", { title: "Bash(test)", content: [{ type: "content", content: { type: "text", text: "ok" } }] }, "t2"),
];
const children = rows.map((tool) => buildTranscriptRow(tool));
const onExpanded = vi.fn();
const group = buildToolGroup("turn:u1", rows, children, false, onExpanded);
const head = group.querySelector<HTMLButtonElement>(".st-tool-group-head")!;
const body = group.querySelector<HTMLElement>(".st-tool-group-body")!;
expect(head.textContent).toContain("2 tool calls");
expect(body.getAttribute("aria-hidden")).toBe("true");
expect(body.inert).toBe(true);
head.click();
expect(body.getAttribute("aria-hidden")).toBe("false");
expect(body.classList.contains("is-expanded")).toBe(true);
expect(onExpanded).toHaveBeenCalledWith(true);
const toolHeads = group.querySelectorAll<HTMLButtonElement>(".st-tool-head");
toolHeads[1].click();
expect(toolHeads[0].getAttribute("aria-expanded")).toBe("false");
expect(toolHeads[1].getAttribute("aria-expanded")).toBe("true");
});
}); });
describe("buildTranscriptRow: tool diffs", () => { describe("buildTranscriptRow: tool diffs", () => {
@@ -159,7 +329,7 @@ describe("buildTranscriptRow: tool diffs", () => {
(el.querySelector(".st-tool-head") as HTMLButtonElement).click(); (el.querySelector(".st-tool-head") as HTMLButtonElement).click();
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the // jsdom/happy-dom doesn't compute layout CSS, so assert the class the
// stylesheet keys `overflow-x: auto` off, not a computed style. // stylesheet keys `overflow-x: auto` off, not a computed style.
expect(el.querySelector(".st-diff")!.className).toBe("st-diff"); expect(el.querySelector(".st-diff")!.classList).toContain("st-diff");
}); });
it("renders a visible marker for a removed final newline", () => { it("renders a visible marker for a removed final newline", () => {
@@ -279,6 +449,24 @@ describe("buildTranscriptRow: approval", () => {
}); });
describe("buildTranscriptRow: notice / unknown kinds", () => { describe("buildTranscriptRow: notice / unknown kinds", () => {
it("renders durable per-turn duration and context usage", () => {
const el = buildTranscriptRow(
row("notice", {
maestroTurnMeta: {
elapsedMs: 72_000,
contextTokens: 18_400,
contextDelta: 2_350,
outcome: "completed",
model: "opus",
},
}, "turn:1"),
)!;
expect(el.classList.contains("st-turn-meta")).toBe(true);
expect(el.textContent).toContain("1m 12s · +2.4k context tokens · opus");
expect(el.getAttribute("aria-label")).toBe("Copy turn details");
});
it("renders notice and unknown row kinds safely", () => { it("renders notice and unknown row kinds safely", () => {
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe( expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
"[notice]", "[notice]",
@@ -287,104 +475,16 @@ describe("buildTranscriptRow: notice / unknown kinds", () => {
expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]"); expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]");
}); });
it("omits command-catalog and session-info notices used only as UI state", () => {
expect(
buildTranscriptRow(
row("notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
),
).toBeNull();
expect(buildTranscriptRow(row("notice", { sessionUpdate: "session_info_update" }))).toBeNull();
});
it("returns no transcript element for a plan row", () => { it("returns no transcript element for a plan row", () => {
expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull(); expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull();
}); });
}); });
describe("branchFromWorktreePath", () => {
it("takes everything after the last /worktrees/ segment", () => {
expect(branchFromWorktreePath("/home/n/tea/maestro/worktrees/acp-bridge")).toBe("acp-bridge");
});
it("keeps slashes in a branch name that contains them", () => {
expect(branchFromWorktreePath("/repo/worktrees/feature/login")).toBe("feature/login");
});
it("falls back to the basename when there is no /worktrees/ marker", () => {
expect(branchFromWorktreePath("/some/other/path")).toBe("path");
});
});
function session(overrides: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("renderRail", () => {
it("omits every section that has nothing to show", () => {
const el = document.createElement("div");
renderRail(el, {
plan: null,
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session(),
});
// Turn always renders (it has a state and a count); the rest are absent.
expect(el.querySelectorAll("h3")).toHaveLength(1);
expect(el.querySelector("h3")!.textContent).toBe("Turn");
});
it("renders the plan with status marks", () => {
const el = document.createElement("div");
renderRail(el, {
plan: {
entries: [
{ content: "done thing", priority: "high", status: "completed" },
{ content: "now thing", priority: "high", status: "in_progress" },
{ content: "later thing", priority: "low", status: "pending" },
],
},
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session(),
});
const items = el.querySelectorAll(".st-plan li");
expect(items).toHaveLength(3);
expect(items[0].className).toBe("st-plan-done");
expect(items[1].className).toBe("st-plan-now");
expect(items[2].className).toBe("st-plan-todo");
});
it("renders context usage and a meter proportional to used/size", () => {
const el = document.createElement("div");
renderRail(el, {
plan: null,
turn: { responding: true, thinking: false, active_tool_calls: ["a"] },
toolRowCount: 3,
usage: { used: 50_000, size: 200_000 },
session: session(),
});
expect(el.querySelector(".st-meter i")!.getAttribute("style")).toContain("25%");
});
it("renders the worktree section only when the session has one", () => {
const el = document.createElement("div");
renderRail(el, {
plan: null,
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session({
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
blocked_work: { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3 },
}),
});
const headers = [...el.querySelectorAll("h3")].map((h) => h.textContent);
expect(headers).toContain("Worktree");
const rows = [...el.querySelectorAll(".st-kv-row")].map((r) => r.textContent);
expect(rows.some((t) => t?.includes("feat/x"))).toBe(true);
expect(rows.some((t) => t?.includes("3"))).toBe(true); // ahead
expect(rows.some((t) => t?.includes("dirty") && t.includes("3"))).toBe(true); // 2+1
});
});
+462 -177
View File
@@ -4,18 +4,16 @@
// unit-testable without a WebSocket. // unit-testable without a WebSocket.
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { marked, Renderer } from "marked";
import { computeDiffLines, diffStats } from "./structuredDiff.js"; import { computeDiffLines, diffStats } from "./structuredDiff.js";
import type { import type {
AcpContentBlock, AcpContentBlock,
AcpPermissionOption, AcpPermissionOption,
AcpPlan,
AcpToolCallContent, AcpToolCallContent,
AcpToolCallLike, AcpToolCallLike,
AcpUsage,
InTheWay,
Session,
StructuredApprovalDecision, StructuredApprovalDecision,
TranscriptRow, TranscriptRow,
TurnMeta,
} from "./types.js"; } from "./types.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -98,10 +96,7 @@ function contentBlockText(block: AcpContentBlock | undefined): string {
} }
} }
/** A tiny, dependency-free inline renderer: `**bold**`, `*italic*`, and /** Small inline renderer retained for compact labels and focused tests. */
* `` `code` `` become real markup; everything else stays escaped plain
* text. No block-level markdown (lists, headings) — agent prose is
* paragraphs, not documents. */
export function renderInlineMarkdown(text: string): string { export function renderInlineMarkdown(text: string): string {
const escaped = escapeHtml(text); const escaped = escapeHtml(text);
return escaped return escaped
@@ -110,15 +105,88 @@ export function renderInlineMarkdown(text: string): string {
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>"); .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
} }
/** Render assistant output as GFM, then sanitize the complete result before it
* enters the DOM. This gives transcripts proper tables, lists, code blocks and
* links without trusting model-authored HTML or URL attributes. */
export function renderMarkdown(text: string): string {
const renderer = new Renderer();
renderer.html = ({ text: raw }) => escapeHtml(raw);
renderer.image = ({ text: alt }) => escapeHtml(alt ? `[image: ${alt}]` : "[image]");
renderer.link = ({ href, title, tokens }) => {
const label = renderer.parser.parseInline(tokens);
const scheme = /^([a-z][a-z\d+.-]*):/i.exec(href.trim())?.[1]?.toLowerCase();
if (scheme && !["http", "https", "mailto"].includes(scheme)) return label;
const safeTitle = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(href)}"${safeTitle}>${label}</a>`;
};
return marked.parse(text, { async: false, gfm: true, renderer });
}
function renderProse(container: HTMLElement, text: string): void { function renderProse(container: HTMLElement, text: string): void {
const paragraphs = text.split(/\n{2,}/); container.innerHTML = renderMarkdown(text);
for (const para of paragraphs) {
if (!para) continue; for (const table of Array.from(container.querySelectorAll("table"))) {
const p = document.createElement("p"); const scroller = el("div", "st-markdown-table-wrap");
p.innerHTML = renderInlineMarkdown(para); table.replaceWith(scroller);
container.appendChild(p); scroller.appendChild(table);
} }
if (container.children.length === 0) container.textContent = "";
for (const link of container.querySelectorAll("a")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}
async function writeClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand?.("copy") ?? false;
textarea.remove();
if (!copied) throw new Error("clipboard unavailable");
}
}
function makeCopyable(node: HTMLElement, text: string, label: string): void {
if (!text) return;
node.classList.add("st-copyable");
node.tabIndex = 0;
node.setAttribute("role", "button");
node.setAttribute("aria-label", `Copy ${label}`);
node.title = `Copy ${label}`;
const copy = () => {
void writeClipboard(text).then(
() => {
node.dataset.copyState = "copied";
node.setAttribute("aria-label", `${label} copied`);
window.setTimeout(() => {
delete node.dataset.copyState;
node.setAttribute("aria-label", `Copy ${label}`);
}, 1200);
},
() => {
node.dataset.copyState = "error";
},
);
};
node.addEventListener("click", (event) => {
if ((event.target as Element | null)?.closest("a")) return;
copy();
});
node.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copy();
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -128,21 +196,21 @@ function renderProse(container: HTMLElement, text: string): void {
function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement { function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement {
const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined; const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined;
const text = contentBlockText(data?.content); const text = contentBlockText(data?.content);
const displayText = variant === "thought" ? text.trim() : text;
const wrap = el("div", `st-row st-row-${variant}`); const wrap = el("div", `st-row st-row-${variant}`);
const body = el("div", "st-body"); const body = el("div", "st-body");
if (variant === "user") { if (variant === "user") {
const who = el("div", "st-who");
who.textContent = "You";
body.appendChild(who);
const line = el("div", "st-user-text"); const line = el("div", "st-user-text");
line.textContent = text; line.textContent = displayText;
body.appendChild(line); body.appendChild(line);
} else if (variant === "agent") { } else if (variant === "agent") {
renderProse(body, text); renderProse(body, displayText);
} else { } else {
body.textContent = text; body.innerHTML = renderInlineMarkdown(displayText);
} }
wrap.appendChild(body); wrap.appendChild(body);
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
makeCopyable(wrap, displayText, label);
return wrap; return wrap;
} }
@@ -151,6 +219,7 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
const label = el("div", "st-diff-path"); const label = el("div", "st-diff-path");
label.textContent = diff.path; label.textContent = diff.path;
label.title = diff.path; label.title = diff.path;
makeCopyable(label, diff.path, "path");
wrap.appendChild(label); wrap.appendChild(label);
const scroller = el("div", "st-diff"); const scroller = el("div", "st-diff");
const pre = document.createElement("pre"); const pre = document.createElement("pre");
@@ -162,10 +231,253 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
pre.appendChild(document.createTextNode("\n")); pre.appendChild(document.createTextNode("\n"));
} }
scroller.appendChild(pre); scroller.appendChild(pre);
makeCopyable(scroller, pre.textContent ?? "", "diff");
wrap.appendChild(scroller); wrap.appendChild(scroller);
return wrap; return wrap;
} }
function printable(value: unknown): string | null {
if (value === undefined || value === null) return null;
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function jsonPrintable(value: unknown): string | null {
if (value === undefined) return null;
try {
return JSON.stringify(value, null, 2) ?? null;
} catch {
return printable(value);
}
}
function record(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function appendToolDetail(container: HTMLElement, label: string, text: string): void {
const detail = el("div", "st-tool-detail");
const heading = el("div", "st-tool-detail-label");
heading.textContent = label;
const value = document.createElement("pre");
value.textContent = text;
makeCopyable(value, text, label.toLowerCase());
detail.append(heading, value);
container.appendChild(detail);
}
function appendToolValue(container: HTMLElement, label: string, value: unknown, duplicates?: Set<string>): void {
const text = printable(value);
if (text !== null && text !== "" && !duplicates?.has(text)) appendToolDetail(container, label, text);
}
function appendExitCode(container: HTMLElement, value: unknown): void {
if (typeof value !== "number") return;
const line = el("div", "st-tool-fact");
const label = el("span", "st-tool-fact-label");
label.textContent = "Exit code";
const code = el("code", value === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed");
code.textContent = String(value);
line.append(label, code);
container.appendChild(line);
}
function appendCommandPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (typeof input.command !== "string") return false;
appendToolDetail(container, "Command", input.command);
appendToolValue(container, "Working directory", input.cwd);
const result = record(output);
if (result) {
appendToolValue(container, "Output", result.formatted_output ?? result.stdout ?? result.output, contentText);
appendExitCode(container, result.exit_code);
}
return true;
}
function appendMcpPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (typeof input.server !== "string" || typeof input.tool !== "string" || !("arguments" in input)) return false;
appendToolValue(container, "Arguments", input.arguments);
const result = record(output);
if (result) {
appendToolValue(container, "Result", result.result, contentText);
appendToolValue(container, "Error", result.error);
}
return true;
}
function appendSearchPayload(
container: HTMLElement,
input: Record<string, unknown>,
tool: AcpToolCallLike,
): boolean {
if (typeof input.query !== "string" || (tool.kind !== "search" && input.type !== "webSearch")) return false;
appendToolDetail(container, "Query", input.query);
appendToolValue(container, "Action", input.action);
return true;
}
function appendFunctionPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (!("arguments" in input)) return false;
appendToolValue(container, "Arguments", input.arguments);
const result = record(output);
if (result) appendToolValue(container, "Result", result.output, contentText);
return true;
}
function appendKnownToolPayload(
container: HTMLElement,
tool: AcpToolCallLike,
content: AcpToolCallContent[],
): void {
const input = record(tool.rawInput);
if (!input) return;
const contentText = new Set(
content
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
.filter((block) => block.content.type === "text")
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
);
if (appendCommandPayload(container, input, tool.rawOutput, contentText)) return;
if (appendMcpPayload(container, input, tool.rawOutput, contentText)) return;
if (appendSearchPayload(container, input, tool)) return;
appendFunctionPayload(container, input, tool.rawOutput, contentText);
}
function safeMediaSource(
mimeType: string | undefined,
data: string | undefined,
family: "image" | "audio",
): string | null {
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
return `data:${mimeType};base64,${data}`;
}
function appendContentBlock(container: HTMLElement, block: AcpContentBlock): void {
if (block.type === "text") {
if (!block.text) return;
const text = el("div", "st-tool-text");
text.textContent = block.text;
makeCopyable(text, block.text, "output");
container.appendChild(text);
return;
}
if (block.type === "image") {
const source = safeMediaSource(block.mimeType, block.data, "image");
if (source) {
const image = document.createElement("img");
image.className = "st-tool-image";
image.src = source;
image.alt = "Tool output";
image.loading = "lazy";
container.appendChild(image);
return;
}
appendToolValue(container, "Image", block.uri ?? block.mimeType ?? "Unavailable");
return;
}
if (block.type === "audio") {
const source = safeMediaSource(block.mimeType, block.data, "audio");
if (source) {
const audio = document.createElement("audio");
audio.className = "st-tool-audio";
audio.controls = true;
audio.src = source;
container.appendChild(audio);
return;
}
appendToolValue(container, "Audio", block.mimeType ?? "Unavailable");
return;
}
if (block.type === "resource_link") {
const resource = el("div", "st-tool-resource");
const name = el("span", "st-tool-resource-name");
name.textContent = block.title ?? block.name ?? "Resource";
resource.appendChild(name);
if (block.uri) {
const uri = el("code", "st-tool-resource-uri");
uri.textContent = block.uri;
makeCopyable(uri, block.uri, "resource URI");
resource.appendChild(uri);
}
if (block.description) {
const description = el("span", "st-tool-resource-description");
description.textContent = block.description;
resource.appendChild(description);
}
container.appendChild(resource);
return;
}
const resource = block.resource;
if (typeof resource?.text === "string") {
appendToolDetail(container, resource.uri ? `Resource · ${resource.uri}` : "Resource", resource.text);
} else {
appendToolValue(container, "Resource", resource);
}
}
function appendStandardContent(container: HTMLElement, content: AcpToolCallContent[]): void {
for (const block of content) {
if (block.type === "content") appendContentBlock(container, block.content);
if (block.type === "diff") container.appendChild(buildDiffBlock(block));
if (block.type === "terminal") {
const note = el("div", "st-tool-terminal");
note.textContent = block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal";
container.appendChild(note);
}
}
}
function appendRawPayload(container: HTMLElement, tool: AcpToolCallLike): void {
const input = jsonPrintable(tool.rawInput);
const output = jsonPrintable(tool.rawOutput);
if (input === null && output === null) return;
const disclosure = document.createElement("details");
disclosure.className = "st-tool-raw";
const summary = document.createElement("summary");
summary.textContent = "Raw JSON";
disclosure.appendChild(summary);
const body = el("div", "st-tool-raw-body");
if (input !== null) appendToolDetail(body, "Input", input);
if (output !== null) appendToolDetail(body, "Output", output);
disclosure.appendChild(body);
container.appendChild(disclosure);
}
function setDisclosure(element: HTMLElement, expanded: boolean, animate = false): void {
element.hidden = false;
if (animate && expanded && !element.classList.contains("is-expanded")) {
// Establish the collapsed grid track before opening it in the same event
// turn. Without this read, a freshly lazy-built tool body has no painted
// starting state and the browser quite reasonably skips the transition.
void element.offsetHeight;
}
element.classList.toggle("is-expanded", expanded);
element.setAttribute("aria-hidden", String(!expanded));
element.inert = !expanded;
}
/** `+added -removed` across every diff block in a tool call's content — /** `+added -removed` across every diff block in a tool call's content —
* real derived data, never a fabricated duration. */ * real derived data, never a fabricated duration. */
function toolDiffMeta(content: AcpToolCallContent[]): string { function toolDiffMeta(content: AcpToolCallContent[]): string {
@@ -228,41 +540,34 @@ function buildToolRow(
} }
card.appendChild(head); card.appendChild(head);
const out = el("div", "st-tool-out"); const out = el("div", "st-tool-out st-disclosure");
const outClip = el("div", "st-disclosure-clip");
const outInner = el("div", "st-disclosure-inner");
outClip.appendChild(outInner);
out.appendChild(outClip);
out.hidden = true; out.hidden = true;
let outBuilt = false; let outBuilt = false;
const buildOut = () => { const buildOut = () => {
if (outBuilt) return; if (outBuilt) return;
outBuilt = true; outBuilt = true;
const texts = content appendStandardContent(outInner, content);
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content") appendKnownToolPayload(outInner, tool, content);
.map((c) => contentBlockText(c.content)) appendRawPayload(outInner, tool);
.filter(Boolean);
if (texts.length > 0) {
const textPanel = el("div", "st-tool-text");
textPanel.textContent = texts.join("\n");
out.appendChild(textPanel);
}
for (const block of content) {
if (block.type === "diff") out.appendChild(buildDiffBlock(block));
if (block.type === "terminal") {
const note = el("div", "st-tool-text");
note.textContent = "[embedded terminal]";
out.appendChild(note);
}
}
if (out.children.length === 0) out.hidden = true;
}; };
card.appendChild(out); card.appendChild(out);
const setExpanded = (nextExpanded: boolean) => { const setExpanded = (nextExpanded: boolean, animate = false) => {
if (nextExpanded) buildOut(); if (nextExpanded) buildOut();
if (out.children.length === 0) nextExpanded = false; if (outInner.children.length === 0) nextExpanded = false;
head.setAttribute("aria-expanded", String(nextExpanded)); head.setAttribute("aria-expanded", String(nextExpanded));
out.hidden = !nextExpanded; if (outInner.children.length === 0) {
out.hidden = true;
} else {
setDisclosure(out, nextExpanded, animate);
}
}; };
head.addEventListener("click", () => { head.addEventListener("click", () => {
setExpanded(head.getAttribute("aria-expanded") !== "true"); setExpanded(head.getAttribute("aria-expanded") !== "true", true);
onExpandedChange?.(head.getAttribute("aria-expanded") === "true"); onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
}); });
setExpanded(expanded); setExpanded(expanded);
@@ -270,14 +575,114 @@ function buildToolRow(
return wrap; return wrap;
} }
export function buildToolGroup(
id: string,
rows: TranscriptRow[],
children: HTMLElement[],
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
): HTMLElement {
const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id;
const head = document.createElement("button");
head.type = "button";
head.className = "st-tool-group-head";
head.setAttribute("aria-expanded", String(expanded));
const glyph = el("span", "st-tool-group-glyph");
glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title");
title.textContent = `${rows.length} tool calls`;
const names = el("span", "st-tool-group-names");
names.textContent = rows
.map((row) => {
const tool = (row.data ?? {}) as AcpToolCallLike;
return splitToolTitle(tool.title, tool.name, tool.kind).label;
})
.join(" · ");
head.append(glyph, title, names);
const body = el("div", "st-tool-group-body st-disclosure");
const bodyClip = el("div", "st-disclosure-clip");
const bodyInner = el("div", "st-disclosure-inner");
bodyInner.append(...children);
bodyClip.appendChild(bodyInner);
body.appendChild(bodyClip);
setDisclosure(body, expanded);
head.addEventListener("click", () => {
expanded = !expanded;
head.setAttribute("aria-expanded", String(expanded));
setDisclosure(body, expanded, true);
onExpandedChange?.(expanded);
});
wrap.append(head, body);
return wrap;
}
function buildPlainRow(row: TranscriptRow): HTMLElement { function buildPlainRow(row: TranscriptRow): HTMLElement {
const wrap = el("div", "st-row st-row-notice"); const wrap = el("div", "st-row st-row-notice");
const body = el("div", "st-body"); const body = el("div", "st-body");
body.textContent = `[${row.kind}]`; body.textContent = `[${row.kind}]`;
wrap.appendChild(body); wrap.appendChild(body);
makeCopyable(wrap, body.textContent, "notice");
return wrap; return wrap;
} }
function compactNumber(value: number): string {
if (Math.abs(value) < 1_000) return String(value);
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
}
export function formatElapsed(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
}
export function formatContextTokens(value: number): string {
return `${compactNumber(value)} context`;
}
function buildTurnMetaRow(meta: TurnMeta): HTMLElement {
const wrap = el("div", `st-turn-meta st-turn-meta-${meta.outcome}`);
wrap.setAttribute("aria-label", `Turn ${meta.outcome}`);
const parts = [formatElapsed(meta.elapsedMs)];
if (typeof meta.contextDelta === "number") {
const sign = meta.contextDelta > 0 ? "+" : "";
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
} else if (typeof meta.contextTokens === "number") {
parts.push(formatContextTokens(meta.contextTokens));
}
if (meta.model) parts.push(meta.model);
if (meta.outcome !== "completed") parts.push(meta.outcome);
const dot = el("span", "st-turn-meta-dot");
dot.setAttribute("aria-hidden", "true");
const text = el("span");
text.textContent = parts.join(" · ");
wrap.append(dot, text);
makeCopyable(wrap, text.textContent, "turn details");
return wrap;
}
function turnMeta(row: TranscriptRow): TurnMeta | null {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
if (!value || typeof value !== "object") return null;
const meta = value as Partial<TurnMeta>;
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
return meta as TurnMeta;
}
function isStateOnlyNotice(row: TranscriptRow): boolean {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
return update === "available_commands_update" || update === "session_info_update";
}
export interface ApprovalRowState { export interface ApprovalRowState {
pending: boolean; pending: boolean;
/** Resolves once the decision has been submitted; rejects when the POST /** Resolves once the decision has been submitted; rejects when the POST
@@ -367,8 +772,8 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
return wrap; return wrap;
} }
/** Build the DOM for one transcript row, or `null` for a plan shown only in /** Build the DOM for one transcript row, or `null` for state-only plan rows.
* the rail. `approval` is consulted only for an `approval` row; tool * `approval` is consulted only for an `approval` row; tool
* expansion is supplied and reported explicitly. */ * expansion is supplied and reported explicitly. */
export function buildTranscriptRow( export function buildTranscriptRow(
row: TranscriptRow & { kind: "plan" }, row: TranscriptRow & { kind: "plan" },
@@ -377,7 +782,13 @@ export function buildTranscriptRow(
onToolExpandedChange?: (expanded: boolean) => void, onToolExpandedChange?: (expanded: boolean) => void,
): null; ): null;
export function buildTranscriptRow( export function buildTranscriptRow(
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan"> }, row: TranscriptRow & { kind: "notice" },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null;
export function buildTranscriptRow(
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan" | "notice"> },
approval?: ApprovalRowState, approval?: ApprovalRowState,
toolExpanded?: boolean, toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void, onToolExpandedChange?: (expanded: boolean) => void,
@@ -414,6 +825,10 @@ export function buildTranscriptRow(
break; break;
case "plan": case "plan":
return null; return null;
case "notice":
if (isStateOnlyNotice(row)) return null;
built = turnMeta(row) ? buildTurnMetaRow(turnMeta(row)!) : buildPlainRow(row);
break;
default: default:
built = buildPlainRow(row); built = buildPlainRow(row);
break; break;
@@ -421,133 +836,3 @@ export function buildTranscriptRow(
built.dataset.rowId = row.id; built.dataset.rowId = row.id;
return built; return built;
} }
// ---------------------------------------------------------------------------
// rail
// ---------------------------------------------------------------------------
const PLAN_MARK: Record<string, string> = { completed: "✓", in_progress: "▸", pending: "·" };
const PLAN_CLASS: Record<string, string> = { completed: "done", in_progress: "now", pending: "todo" };
function renderPlanSection(plan: AcpPlan | null | undefined): HTMLElement | null {
if (!plan || plan.entries.length === 0) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Plan";
section.appendChild(h);
const list = document.createElement("ul");
list.className = "st-plan";
for (const entry of plan.entries) {
const li = document.createElement("li");
li.className = `st-plan-${PLAN_CLASS[entry.status] ?? "todo"}`;
li.setAttribute("aria-label", `${entry.status.replace("_", " ")}: ${entry.content}`);
const mark = el("span", "st-plan-mark");
mark.textContent = PLAN_MARK[entry.status] ?? "·";
mark.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.textContent = entry.content;
li.append(mark, text);
list.appendChild(li);
}
section.appendChild(list);
return section;
}
function kv(label: string, value: string): HTMLElement {
const row = el("div", "st-kv-row");
const k = el("span", "st-kv-k");
k.textContent = label;
const v = el("span", "st-kv-v");
v.textContent = value;
row.append(k, v);
return row;
}
function renderTurnSection(
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] },
toolRowCount: number,
usage: AcpUsage | null | undefined,
): HTMLElement {
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Turn";
section.appendChild(h);
const box = el("div", "st-kv");
const activity = turn.thinking ? "thinking" : turn.responding ? "responding" : "idle";
box.appendChild(kv("state", activity));
box.appendChild(kv("active tools", String(turn.active_tool_calls.length)));
box.appendChild(kv("tool calls", String(toolRowCount)));
if (usage?.cost) box.appendChild(kv("cost", `$${usage.cost.amount.toFixed(2)}`));
section.appendChild(box);
return section;
}
function renderContextSection(usage: AcpUsage | null | undefined): HTMLElement | null {
if (!usage) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Context";
section.appendChild(h);
const box = el("div", "st-kv");
box.appendChild(kv("used", formatTokenCount(usage.used)));
box.appendChild(kv("window", formatTokenCount(usage.size)));
section.appendChild(box);
const meter = el("div", "st-meter");
const fill = el("i");
const pct = usage.size > 0 ? Math.min(100, (usage.used / usage.size) * 100) : 0;
fill.style.width = `${pct}%`;
meter.appendChild(fill);
section.appendChild(meter);
return section;
}
function formatTokenCount(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
/** `SessionWorktree.path` is `<repo>/worktrees/<branch>` (docs/WORKTREES.md);
* the branch is not carried anywhere else on the wire, so derive it here. */
export function branchFromWorktreePath(path: string): string {
const marker = "/worktrees/";
const i = path.indexOf(marker);
if (i >= 0) return path.slice(i + marker.length);
return path.split("/").filter(Boolean).pop() ?? path;
}
function renderWorktreeSection(session: Session | undefined): HTMLElement | null {
const worktree = session?.worktree;
if (!worktree) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Worktree";
section.appendChild(h);
const box = el("div", "st-kv");
box.appendChild(kv("branch", branchFromWorktreePath(worktree.path)));
const blocked: InTheWay | null | undefined = session?.blocked_work;
box.appendChild(kv("ahead", String(blocked?.unpushed_commits ?? 0)));
const dirty = (blocked?.uncommitted_files ?? 0) + (blocked?.untracked_files ?? 0);
box.appendChild(kv("dirty", String(dirty)));
section.appendChild(box);
return section;
}
export interface RailContext {
plan: AcpPlan | null | undefined;
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] };
toolRowCount: number;
usage: AcpUsage | null | undefined;
session: Session | undefined;
}
/** Rebuild the rail wholesale from the latest state — rails are small, so a
* full rebuild per update is simpler and cheap enough that no incremental
* patching is worth the complexity. */
export function renderRail(container: HTMLElement, ctx: RailContext): void {
const sections = [
renderPlanSection(ctx.plan),
renderTurnSection(ctx.turn, ctx.toolRowCount, ctx.usage),
renderContextSection(ctx.usage),
renderWorktreeSection(ctx.session),
].filter((s): s is HTMLElement => s !== null);
container.replaceChildren(...sections);
}
+763 -137
View File
File diff suppressed because it is too large Load Diff
+62 -7
View File
@@ -56,7 +56,7 @@ export interface Session {
* how the command bar's empty query sorts a never-focused session after every * how the command bar's empty query sorts a never-focused session after every
* session that has one (`docs/COMMAND-BAR.md`). */ * session that has one (`docs/COMMAND-BAR.md`). */
last_focused_at?: IsoTimestamp | null; last_focused_at?: IsoTimestamp | null;
/** The ACP session id the `claude-code-acp` adapter returned from /** The ACP session id the selected provider adapter returned from
* `session/new` (#134). Only a `Structured` session whose handshake * `session/new` (#134). Only a `Structured` session whose handshake
* succeeded has one; a respawn passes it to `session/load` (#135). */ * succeeded has one; a respawn passes it to `session/load` (#135). */
acp_session_id?: string | null; acp_session_id?: string | null;
@@ -422,6 +422,8 @@ export interface TurnState {
responding: boolean; responding: boolean;
thinking: boolean; thinking: boolean;
active_tool_calls: string[]; active_tool_calls: string[];
started_at?: string | null;
started_context_tokens?: number | null;
} }
/** A whole-session view derived from ACP notifications. `plan`/`usage`/ /** A whole-session view derived from ACP notifications. `plan`/`usage`/
@@ -436,6 +438,7 @@ export interface ReducedState {
context?: AcpUsage | null; context?: AcpUsage | null;
model?: string | null; model?: string | null;
mode?: string | null; mode?: string | null;
config_options?: { configOptions: AcpSessionConfigOption[] } | null;
} }
/** Server messages on `WS /api/structured/:id`. */ /** Server messages on `WS /api/structured/:id`. */
@@ -449,20 +452,61 @@ export interface StructuredPromptRequest {
prompt: string; prompt: string;
} }
export interface StructuredConfigRequest {
config_id: string;
value: string | boolean;
}
export interface AcpSessionConfigChoice {
value: string;
name: string;
description?: string | null;
}
export interface AcpSessionConfigGroup {
group: string;
name: string;
options: AcpSessionConfigChoice[];
}
export interface AcpSessionConfigOption {
id: string;
name: string;
description?: string | null;
category?: string | null;
type: "select" | "boolean" | string;
currentValue: string | boolean;
options?: Array<AcpSessionConfigChoice | AcpSessionConfigGroup>;
}
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON /** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
* string (a bracketless Rust enum), not `{decision: ...}`. */ * string (a bracketless Rust enum), not `{decision: ...}`. */
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny"; export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ---- // ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a export interface AcpEmbeddedResource {
* generic placeholder rather than being dropped silently. */ uri?: string;
mimeType?: string;
text?: string;
blob?: string;
}
/** `ContentBlock`, including the fields needed to render typed tool output. */
export type AcpContentBlock = export type AcpContentBlock =
| { type: "text"; text: string } | { type: "text"; text: string }
| { type: "image"; mimeType?: string } | { type: "image"; data?: string; mimeType?: string; uri?: string | null }
| { type: "audio"; mimeType?: string } | { type: "audio"; data?: string; mimeType?: string }
| { type: "resource_link"; uri?: string; name?: string } | {
| { type: "resource"; resource?: unknown }; type: "resource_link";
uri?: string;
name?: string;
title?: string | null;
description?: string | null;
mimeType?: string | null;
size?: number | null;
}
| { type: "resource"; resource?: AcpEmbeddedResource };
/** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */ /** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */
export interface AcpContentChunk { export interface AcpContentChunk {
@@ -543,6 +587,17 @@ export interface AcpUsage {
cost?: { amount: number; currency: string } | null; cost?: { amount: number; currency: string } | null;
} }
/** Daemon-owned durable footer appended when one prompt turn settles. ACP
* only reports context occupancy, so `contextDelta` is not a billing-token
* count and may be negative when the provider compacts the context. */
export interface TurnMeta {
elapsedMs: number;
contextTokens?: number | null;
contextDelta?: number | null;
outcome: "completed" | "cancelled" | "failed" | string;
model?: string | null;
}
export interface AcpPermissionOption { export interface AcpPermissionOption {
optionId: string; optionId: string;
name: string; name: string;