Compare commits

..
Author SHA1 Message Date
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 dba3d8c249 Merge issue #199: align structured command resolution 2026-09-05 14:28:12 +01:00
35 changed files with 3184 additions and 763 deletions
Generated
+1
View File
@@ -1477,6 +1477,7 @@ version = "0.1.0"
dependencies = [
"agent-client-protocol",
"libc",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
+1
View File
@@ -10,6 +10,7 @@ description = "ACP client role: one claude-code-acp child per structured session
[dependencies]
agent-client-protocol.workspace = true
libc.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
+407 -40
View File
@@ -42,24 +42,56 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, Implementation,
InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId,
SessionNotification,
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, SetSessionModeRequest,
};
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::sync::{mpsc, oneshot, watch};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema;
/// The adapter binary, resolved on `PATH`.
pub const ADAPTER_BIN: &str = "claude-code-acp";
/// ACP adapter selected from the structured session's resolved command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
ClaudeCode,
Codex,
}
/// How to install the adapter when it is missing. Named in the error so an
/// operator staring at a failed session knows what to run.
impl AcpAdapter {
pub const fn binary(self) -> &'static str {
match self {
Self::ClaudeCode => "claude-code-acp",
Self::Codex => "codex-acp",
}
}
pub const fn install_command(self) -> &'static str {
match self {
Self::ClaudeCode => "npm install -g --prefix ~/.local @zed-industries/claude-code-acp",
Self::Codex => "npm install -g --prefix ~/.local @agentclientprotocol/codex-acp",
}
}
}
impl std::fmt::Display for AcpAdapter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::ClaudeCode => "Claude Code",
Self::Codex => "Codex",
})
}
}
/// Compatibility aliases for Claude Code callers.
pub const ADAPTER_BIN: &str = "claude-code-acp";
pub const INSTALL_COMMAND: &str =
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
@@ -78,6 +110,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2);
/// Bytes of the child's stderr kept for the failure message.
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.
#[derive(Debug, Clone)]
@@ -94,6 +195,8 @@ pub struct SpawnConfig {
/// instead of opening a fresh one. The adapter must advertise
/// `loadSession`; the handshake fails otherwise.
pub load_session: Option<String>,
/// The provider-specific adapter to launch.
pub adapter: AcpAdapter,
}
impl SpawnConfig {
@@ -103,6 +206,7 @@ impl SpawnConfig {
env,
handshake_timeout: HANDSHAKE_TIMEOUT,
load_session: None,
adapter: AcpAdapter::ClaudeCode,
}
}
@@ -111,6 +215,11 @@ impl SpawnConfig {
self.load_session = Some(session_id.into());
self
}
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
self.adapter = adapter;
self
}
}
/// A blocked ACP permission request delivered to the daemon.
@@ -126,31 +235,39 @@ pub struct PermissionRequest {
pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command
/// so the failed session is actionable rather than silently dead.
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")]
AdapterMissing,
#[error("failed to start {ADAPTER_BIN}: {0}")]
Spawn(#[source] std::io::Error),
#[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
AdapterMissing { adapter: AcpAdapter },
#[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
Spawn {
adapter: AcpAdapter,
#[source]
source: std::io::Error,
},
/// The adapter answered the handshake with an error.
#[error("ACP handshake failed: {0}")]
Handshake(String),
/// The adapter went away before the handshake finished. Carries the
/// tail of its stderr, which is where a missing Claude login shows up.
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")]
ExitedEarly { status: String, stderr: String },
#[error("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
ExitedEarly {
adapter: AcpAdapter,
status: String,
stderr: String,
},
#[error("ACP handshake timed out after {0:?}")]
HandshakeTimeout(Duration),
}
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
/// session env's `PATH` when it sets one, the daemon's own otherwise.
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> {
pub fn resolve_adapter(adapter: AcpAdapter, env: &HashMap<String, String>) -> Option<PathBuf> {
let path: OsString = match env.get("PATH") {
Some(p) => OsString::from(p),
None => std::env::var_os("PATH")?,
};
std::env::split_paths(&path)
.filter(|dir| !dir.as_os_str().is_empty())
.map(|dir| dir.join(ADAPTER_BIN))
.map(|dir| dir.join(adapter.binary()))
.find(|candidate| is_executable(candidate))
}
@@ -168,9 +285,20 @@ enum Command {
turn: u64,
done: oneshot::Sender<Result<(), String>>,
},
SetConfig {
config_id: String,
value: SessionConfigOptionValue,
done: oneshot::Sender<Result<(), String>>,
},
Close,
}
struct ConnectionConfig {
cwd: PathBuf,
load_session: Option<String>,
adapter: AcpAdapter,
}
enum ChildCommand {
Signal(libc::c_int),
Reap(oneshot::Sender<bool>),
@@ -301,6 +429,23 @@ impl AcpSession {
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
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
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
/// signalled, so a failed session never leaves a stray adapter behind.
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?;
let provider = config.adapter;
let adapter = resolve_adapter(provider, &config.env)
.ok_or(AcpError::AdapterMissing { adapter: provider })?;
let mut cmd = tokio::process::Command::new(&adapter);
cmd.current_dir(&config.cwd)
@@ -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 stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone(), provider));
let (exit_tx, exit_rx) = watch::channel(None);
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
tokio::spawn(supervise_child(
child,
pid,
child_commands_rx,
exit_tx,
provider,
));
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
@@ -389,8 +545,11 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
tokio::spawn(run_connection(
transport,
config.cwd.clone(),
config.load_session.clone(),
ConnectionConfig {
cwd: config.cwd.clone(),
load_session: config.load_session.clone(),
adapter: provider,
},
updates_tx,
permission_tx,
commands_rx,
@@ -423,6 +582,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
.to_string()
};
AcpError::ExitedEarly {
adapter: provider,
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
stderr: if stderr.is_empty() {
String::new()
@@ -457,12 +617,13 @@ async fn supervise_child(
pid: u32,
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
exit_tx: watch::Sender<Option<ExitStatus>>,
adapter: AcpAdapter,
) {
loop {
tokio::select! {
status = child.wait() => {
let status = status.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(status);
return;
}
@@ -476,7 +637,7 @@ async fn supervise_child(
Some(ChildCommand::Reap(done)) => match child.try_wait() {
Ok(Some(status)) => {
let _ = done.send(true);
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(Some(status));
return;
}
@@ -484,7 +645,7 @@ async fn supervise_child(
let _ = done.send(false);
}
Err(error) => {
tracing::warn!(pid, %error, "could not reap {ADAPTER_BIN}");
tracing::warn!(pid, %error, provider = %adapter, "could not reap ACP adapter");
let _ = done.send(false);
}
},
@@ -494,7 +655,7 @@ async fn supervise_child(
// a reused PID.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
let status = child.wait().await.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(status);
return;
}
@@ -513,10 +674,104 @@ fn client_capabilities() -> ClientCapabilities {
.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>(
transport: T,
cwd: PathBuf,
load_session: Option<String>,
config: ConnectionConfig,
updates_tx: mpsc::UnboundedSender<SessionNotification>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
@@ -534,6 +789,7 @@ async fn run_connection<T>(
// until a cancelled prompt settles, so this active turn is unambiguous.
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
let permission_turn_seen = permission_turn.clone();
let received_updates = updates_tx.clone();
let result = Client
.builder()
.name("maestro")
@@ -544,7 +800,7 @@ async fn run_connection<T>(
return Ok(());
}
// A dropped receiver only means nobody wants updates any more.
let _ = updates_tx.send(notification);
let _ = received_updates.send(notification);
Ok(())
},
agent_client_protocol::on_receive_notification!(),
@@ -589,11 +845,13 @@ async fn run_connection<T>(
return Ok(());
}
};
let session_id = match load_session {
let (session_id, modes, initial_options, models) = match config.load_session {
Some(existing) => {
if !initialized.agent_capabilities.load_session {
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}"
"{} ACP adapter ({}) does not advertise loadSession; cannot resume session {existing}",
config.adapter,
config.adapter.binary()
))));
return Ok(());
}
@@ -601,7 +859,11 @@ async fn run_connection<T>(
let (loaded_tx, loaded_rx) = oneshot::channel();
let replaying_done = replaying.clone();
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| {
replaying_done.store(false, Ordering::SeqCst);
async move {
@@ -613,8 +875,8 @@ async fn run_connection<T>(
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
match loaded_rx.await {
Ok(Ok(_)) => {}
let loaded = match loaded_rx.await {
Ok(Ok(loaded)) => loaded,
Ok(Err(e)) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
@@ -625,21 +887,48 @@ async fn run_connection<T>(
)));
return Ok(());
}
}
existing
};
(existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models)
}
None => match cx
.send_request(NewSessionRequest::new(cwd))
.send_request(ExtendedNewSessionRequest {
cwd: config.cwd,
mcp_servers: vec![],
})
.block_task()
.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) => {
let _ = ready_tx.send(Err(ready_error(e)));
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()));
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,
},
_ = cx.incoming_closed() => break,
@@ -706,10 +1072,11 @@ async fn drain_stderr(
stderr: tokio::process::ChildStderr,
pid: u32,
tail: Arc<Mutex<VecDeque<u8>>>,
adapter: AcpAdapter,
) {
let mut lines = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
tracing::debug!(pid, provider = %adapter, "ACP adapter: {line}");
let mut tail = tail.lock().expect("stderr tail");
tail.extend(line.as_bytes());
tail.push_back(b'\n');
+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_ONCE=1 answer only the first `session/prompt` with a
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
callback until a later prompt
@@ -78,6 +79,41 @@ prompt_failures = 0
pending_prompt_id = None
pending_permission_id = None
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):
@@ -139,7 +175,7 @@ for raw in sys.stdin:
while gate and not os.path.exists(gate):
time.sleep(0.01)
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
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"))
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
sys.exit(0)
@@ -152,8 +188,18 @@ for raw in sys.stdin:
continue
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
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"))
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":
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
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
//! one ignored test against the real `claude-code-acp`.
//! ignored smoke tests against each maintained adapter.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use tempfile::TempDir;
fn fake_agent() -> PathBuf {
@@ -25,6 +25,7 @@ impl Fake {
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir }
}
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await
.expect_err("no adapter on PATH");
assert!(matches!(err, AcpError::AdapterMissing));
assert!(matches!(
err,
AcpError::AdapterMissing {
adapter: AcpAdapter::ClaudeCode
}
));
let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{msg}");
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
}
#[test]
fn codex_adapter_has_its_own_binary_and_install_guidance() {
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
assert!(AcpAdapter::Codex
.install_command()
.contains("@agentclientprotocol/codex-acp"));
}
#[tokio::test]
async fn spawn_selects_the_codex_adapter() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await
.expect("spawn Codex adapter");
assert!(fake.log().contains("recv session/new"));
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new();
@@ -180,11 +207,68 @@ async fn session_updates_reach_the_taken_receiver() {
.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]
async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()))
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await
.expect("spawn");
let pid = session.pid();
@@ -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() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails");
@@ -284,7 +368,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err {
AcpError::ExitedEarly { status, stderr } => {
AcpError::ExitedEarly { status, stderr, .. } => {
assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}");
}
@@ -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() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
@@ -353,7 +440,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
async fn load_session_requires_the_load_capability() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config)
@@ -368,7 +458,10 @@ async fn load_session_requires_the_load_capability() {
async fn load_session_error_fails_the_spawn() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails");
@@ -392,3 +485,21 @@ async fn real_adapter_handshake() {
.await;
assert!(session.has_exited());
}
/// End to end against the maintained Codex adapter. It needs a locally
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
#[tokio::test]
#[ignore]
async fn real_codex_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session =
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
.await
.expect("real codex-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
+35 -2
View File
@@ -21,12 +21,13 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
StructuredApprovalDecision, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::session::{QueueAcpPrompt, SessionError, SessionManager};
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
use crate::sidecar::EventCursor;
#[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_PROMPT, post(structured_prompt))
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route(
routes::patterns::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(
State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>,
+261 -15
View File
@@ -2,6 +2,7 @@
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
use maestro_protocol::{
ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind,
@@ -37,9 +38,12 @@ pub struct Projection {
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
state: ReducedState,
active_turn: Option<u64>,
active_turn_started_at: Option<DateTime<Utc>>,
active_turn_context_tokens: Option<u64>,
turn_failed: bool,
next_turn: u64,
turn_tool_calls: HashSet<String>,
anonymous_content_row: Option<(String, String)>,
}
impl Projection {
@@ -60,6 +64,7 @@ impl Projection {
SessionUpdate::ToolCall(tool) => self.tool_call(tool),
SessionUpdate::ToolCallUpdate(update) => self.tool_update(update),
SessionUpdate::Plan(plan) => {
self.anonymous_content_row = None;
let data = to_value(plan);
self.state.plan = Some(data.clone());
Some(self.replace("plan".into(), TranscriptRowKind::Plan, data))
@@ -77,16 +82,20 @@ impl Projection {
SessionUpdate::ConfigOptionUpdate(options) => {
let value = to_value(options);
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
self.state.config_options = Some(value);
None
}
// ACP deliberately adds notification variants over time. Known but
// non-transcript updates become a generic notice instead of being
// dropped or forcing clients to understand ACP's enum.
_ => Some(self.append(
format!("notice:{seq}"),
TranscriptRowKind::Notice,
to_value(&notification.update),
)),
_ => {
self.anonymous_content_row = None;
Some(self.append(
format!("notice:{seq}"),
TranscriptRowKind::Notice,
to_value(&notification.update),
))
}
};
ProjectionUpdate {
transcript,
@@ -122,12 +131,19 @@ impl Projection {
/// Start a prompt turn and return its monotonically increasing token.
pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) {
self.anonymous_content_row = None;
self.next_turn += 1;
let turn = self.next_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.state.turn.responding = 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.state.turn.active_tool_calls.clear();
(
@@ -156,10 +172,44 @@ impl Projection {
self.turn_failed = lifecycle == TurnLifecycle::Failure;
self.state.turn.responding = 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.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 {
transcript: None,
transcript: Some(row),
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) {
self.row_index.insert(row.id.clone(), self.rows.len());
self.rows.push(row.clone());
@@ -245,12 +296,32 @@ impl Projection {
kind: TranscriptRowKind,
prefix: &str,
) -> 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 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() {
let row = &mut self.rows[index];
append_text(&mut row.data, &data);
@@ -260,6 +331,7 @@ impl Projection {
}
fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> {
self.anonymous_content_row = None;
let id = format!("tool:{}", tool.tool_call_id);
let tool_id = tool.tool_call_id.to_string();
let mut canonical = tool.clone();
@@ -290,6 +362,7 @@ impl Projection {
&mut self,
update: &maestro_acp::schema::ToolCallUpdate,
) -> Option<TranscriptDelta> {
self.anonymous_content_row = None;
let tool_id = update.tool_call_id.to_string();
let Some(tool) = self.tools.get_mut(&tool_id) else {
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 {
serde_json::to_value(value).expect("ACP schema values serialize")
}
@@ -426,7 +503,7 @@ fn model_from_options(value: &Value) -> Option<String> {
#[cfg(test)]
mod tests {
use maestro_acp::schema::SessionNotification;
use maestro_protocol::{TranscriptDelta, TranscriptRowKind};
use maestro_protocol::{TranscriptDelta, TranscriptRow, TranscriptRowKind};
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]
fn tool_update_patches_the_original_stable_row() {
let mut projection = Projection::default();
@@ -614,6 +822,10 @@ mod tests {
assert_eq!(state.context.as_ref().unwrap()["size"], 200);
assert_eq!(state.mode.as_deref(), Some("plan"));
assert_eq!(state.model.as_deref(), Some("opus"));
assert_eq!(
state.config_options.as_ref().unwrap()["configOptions"][0]["currentValue"],
"opus"
);
assert_eq!(
state.plan.as_ref().unwrap()["entries"],
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(
terminal: TurnLifecycle,
unfinished_id: &str,
@@ -703,7 +947,9 @@ mod tests {
projection.turn_lifecycle(terminal, turn);
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.apply(
@@ -829,7 +1075,7 @@ mod tests {
panic!("approval row")
};
let mut recovered = Projection::default();
let update = recovered.restore_approval(row.clone());
let update = recovered.restore_row(row.clone());
assert_eq!(
update.transcript,
Some(TranscriptDelta::Append { row: row.clone() })
+168 -33
View File
@@ -15,11 +15,12 @@ use std::task::{Context, Poll};
use std::time::Duration;
use chrono::Utc;
use maestro_acp::{AcpSession, PermissionRequest, SpawnConfig};
use maestro_acp::{AcpAdapter, AcpSession, PermissionRequest, SpawnConfig};
use maestro_protocol::display::command_tool;
use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, WorktreeSpec, NAME_KEY,
NAME_SOURCE_KEY,
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
TranscriptRowKind, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
};
use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify};
use uuid::Uuid;
@@ -59,6 +60,16 @@ pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// actually go.
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
fn structured_adapter(command: &str) -> Result<AcpAdapter, String> {
match command_tool(command) {
Some("claude") => Ok(AcpAdapter::ClaudeCode),
Some("codex") => Ok(AcpAdapter::Codex),
_ => Err(format!(
"structured view supports Claude Code or Codex commands; cannot select an ACP adapter for {command:?}"
)),
}
}
/// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)]
pub struct SessionRecord {
@@ -218,6 +229,15 @@ pub enum QueueAcpPrompt {
Failed(String),
}
/// Result of changing a live structured session option.
#[derive(Debug)]
pub enum SetAcpConfig {
Updated,
InvalidValue,
Unavailable,
Failed(String),
}
impl SessionError {
pub fn to_error_info(&self) -> ErrorInfo {
let code = match self {
@@ -648,6 +668,7 @@ impl SessionManager {
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
return Ok(false);
};
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected);
drop(stream);
sync_structured_activity(&self.inner, id).await;
@@ -719,6 +740,27 @@ impl SessionManager {
if stream.projection.turn_active() {
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 _ = stream.projected_live.send(update);
match acp.prompt(text.clone(), turn) {
@@ -744,6 +786,7 @@ impl SessionManager {
.expect(
"newly claimed turn remains current while stream is locked",
);
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected);
drop(stream);
sync_structured_activity(&self.inner, id).await;
@@ -789,6 +832,7 @@ impl SessionManager {
}
cancel_pending_approvals(&mut stream);
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected);
}
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.
pub async fn cancel_acp_turn(&self, id: Uuid) -> Result<bool, SessionError> {
let (acp, stream) = {
@@ -863,6 +949,7 @@ impl SessionManager {
.projection
.turn_lifecycle(TurnLifecycle::Cancel, turn)
.expect("active turn remains current while stream is locked");
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected);
drop(stream);
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
// operator sees what to fix instead of a silently missing session.
let (acp, acp_failure) = match req.view {
SessionView::Structured => {
match maestro_acp::spawn(SpawnConfig::new(
&effective_cwd,
session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
))
SessionView::Structured => match structured_adapter(&req.command) {
Err(error) => (None, Some(error)),
Ok(adapter) => match maestro_acp::spawn(
SpawnConfig::new(
&effective_cwd,
session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)
.adapter(adapter),
)
.await
{
Ok(acp) => (Some(Arc::new(acp)), None),
@@ -1189,8 +1280,8 @@ impl SessionManager {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
(None, Some(e.to_string()))
}
}
}
},
},
SessionView::Terminal => {
let spawned = {
let tmux = self.inner.tmux.clone();
@@ -1598,23 +1689,30 @@ impl SessionManager {
let (acp, failure) = if recoverable {
match session.acp_session_id.clone() {
Some(acp_session_id) => {
let config = SpawnConfig::new(
&session.cwd,
session_env
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
)
.load_session(acp_session_id);
match maestro_acp::spawn(config).await {
Ok(acp) => (Some(Arc::new(acp)), None),
Err(error) => {
tracing::warn!(%id, %error, "structured session: adapter failed to resume");
(None, Some(error.to_string()))
Some(acp_session_id) => match structured_adapter(&session.command) {
Err(error) => {
tracing::warn!(%id, %error, "structured session: unsupported command during recovery");
(None, Some(error))
}
Ok(adapter) => {
let config = SpawnConfig::new(
&session.cwd,
session_env
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
)
.load_session(acp_session_id)
.adapter(adapter);
match maestro_acp::spawn(config).await {
Ok(acp) => (Some(Arc::new(acp)), None),
Err(error) => {
tracing::warn!(%id, %error, "structured session: adapter failed to resume");
(None, Some(error.to_string()))
}
}
}
}
},
None => (
None,
Some("structured session has no stored ACP session id".into()),
@@ -2118,10 +2216,33 @@ fn start_acp_event_pump(
fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> ProjectionUpdate {
match &event.event {
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) {
let events = {
let sessions = inner.sessions.lock().await;
@@ -2870,15 +2991,18 @@ mod tests {
use std::task::Poll;
use std::time::Duration;
use maestro_acp::schema::{
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
ToolCallUpdate, ToolCallUpdateFields,
use maestro_acp::{
schema::{
PermissionOption, PermissionOptionKind, RequestPermissionOutcome,
RequestPermissionRequest, ToolCallUpdate, ToolCallUpdateFields,
},
AcpAdapter,
};
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionView};
use super::{
apply_structured_activity, cancel_pending_approvals, permission_outcome,
record_permission_request, run_structured_activity_pass,
record_permission_request, run_structured_activity_pass, structured_adapter,
sync_structured_activity_from_stream, AcpDispatchState, AcpEventStream, ApprovalDecision,
RecordOutcome, SessionManager,
};
@@ -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 {
let (live, _live_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)]
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
pub enum PersistedEvent {
User {
row: TranscriptRow,
},
Approval {
row: TranscriptRow,
},
Turn {
row: TranscriptRow,
},
#[serde(skip)]
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 maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND};
use maestro_acp::{AcpAdapter, ADAPTER_BIN};
use maestro_daemon::projection::TurnLifecycle;
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
use maestro_daemon::worktree::WorktreeManager;
@@ -38,6 +38,7 @@ impl Fake {
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir }
}
@@ -96,10 +97,18 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
}
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
structured_command(cwd, env, "claude")
}
fn structured_command(
cwd: &Path,
env: HashMap<String, String>,
command: &str,
) -> CreateSessionRequest {
CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.to_path_buf(),
command: "claude".into(),
command: command.into(),
env,
metadata: HashMap::new(),
worktree: None,
@@ -130,7 +139,11 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let session = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(
cwd.path(),
env,
"codex --dangerously-bypass-approvals-and-sandbox",
))
.await
.expect("create");
@@ -215,7 +228,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(cwd.path(), env, "codex"))
.await
.unwrap();
@@ -349,12 +362,75 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
.await
.expect("finish lifecycle 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());
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");
}
#[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]
async fn stale_turn_completion_cannot_end_the_next_turn() {
let fx = DaemonFixture::new();
@@ -507,7 +583,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let session = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("create");
let acp_id = session.acp_session_id.clone().expect("acp id");
@@ -621,7 +697,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
command: "codex".into(),
env: fake.env(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
@@ -716,15 +792,18 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
let session = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("a failed session, not an error");
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
assert!(detail.contains(ADAPTER_BIN), "{detail}");
assert!(detail.contains(INSTALL_COMMAND), "{detail}");
assert!(detail.contains(AcpAdapter::Codex.binary()), "{detail}");
assert!(
detail.contains(AcpAdapter::Codex.install_command()),
"{detail}"
);
assert_eq!(session.acp_session_id, None);
// It is a real session: listed, on disk, and dismissable like any other
@@ -738,6 +817,28 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
assert!(!fx.manager.has(session.id).await);
}
#[tokio::test]
async fn unsupported_structured_command_fails_without_launching_claude() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured_command(cwd.path(), fake.env(), "opencode"))
.await
.expect("failed structured session is persisted");
assert_eq!(session.status, SessionStatus::Dead);
let detail = session.activity_detail.expect("selection error");
assert!(detail.contains("Claude Code or Codex"), "{detail}");
assert!(
fake.log().is_empty(),
"unsupported command launched an adapter"
);
fx.manager.kill(session.id, false).await.expect("dismiss");
}
#[tokio::test]
async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
let fx = DaemonFixture::new();
@@ -0,0 +1,16 @@
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
+18
View File
@@ -824,6 +824,15 @@ pub struct TurnState {
pub thinking: bool,
#[serde(default)]
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.
@@ -846,6 +855,8 @@ pub struct ReducedState {
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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.
@@ -871,6 +882,13 @@ pub struct StructuredPromptRequest {
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
+6
View File
@@ -91,6 +91,11 @@ pub fn structured_cancel(id: Uuid) -> String {
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.
pub fn structured_approval(id: Uuid, nonce: &str) -> String {
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_PROMPT: &str = "/api/sessions/:id/structured/prompt";
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";
}
+3 -1
View File
@@ -3053,17 +3053,19 @@ mod tests {
let request = |view| CreateSessionRequest {
view,
cwd: std::path::PathBuf::from("/srv/work"),
command: "claude".into(),
command: "codex".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
let structured_id = Uuid::from_u128(188);
app.state.mode = Mode::Insert;
app.submit_spawn(request(SessionView::Structured)).await;
assert!(app.catalog.snapshot().sessions.is_empty());
assert!(app.structured.contains_key(&structured_id));
assert!(app.emulators.get(structured_id).is_none());
assert_eq!(app.state.mode, Mode::Normal);
let terminal_id = Uuid::from_u128(187);
app.submit_spawn(request(SessionView::Terminal)).await;
+60 -16
View File
@@ -91,7 +91,7 @@ pub enum DraftField {
/// Disclosure collapsing the optional view and worktree controls below.
/// Closed by default; Enter / Space toggles.
Advanced,
/// ACP-backed transcript instead of a terminal. Claude Code only.
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
StructuredToggle,
/// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle,
@@ -256,7 +256,7 @@ pub struct DraftPane {
/// take the default target.
pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Off by default and supported only
/// when the resolved command is Claude Code.
/// when the resolved command is Claude Code or Codex.
pub structured_view: bool,
/// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
@@ -673,7 +673,7 @@ impl DraftPane {
}
fn supports_structured_view(&self) -> bool {
command_tool(self.command()) == Some("claude")
matches!(command_tool(self.command()), Some("claude" | "codex"))
}
fn clear_unsupported_structured_view(&mut self) {
@@ -1888,11 +1888,28 @@ mod tests {
}
#[test]
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
fn structured_view_is_default_off_and_visible_for_supported_agents() {
let mut draft = DraftPane {
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
},
SpawnPreset {
id: "opencode".into(),
label: "OpenCode".into(),
command: "opencode".into(),
},
]);
assert!(!draft.structured_view);
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
@@ -1901,6 +1918,12 @@ mod tests {
draft.toggle_structured_view();
assert!(draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "codex");
assert!(
draft.structured_view,
"Codex keeps the structured selection"
);
draft.cycle_preset(1);
assert_eq!(draft.command(), "opencode");
assert!(
!draft.structured_view,
@@ -1928,9 +1951,9 @@ mod tests {
/// A custom command typed into the free-text field, run through the same
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
/// env prefix, or a wrapper still resolves to Claude, so the toggle stays
/// offered and the request carries `Structured`; an unrelated program
/// does not.
/// env prefix, or a wrapper still resolves to Claude Code or Codex, so
/// the toggle stays offered and the request carries `Structured`; an
/// unrelated program does not.
#[test]
fn structured_view_follows_command_resolution_for_custom_commands() {
let cases: &[(&str, bool)] = &[
@@ -1952,6 +1975,16 @@ mod tests {
("yarn --silent dlx claude-code", true),
("bun x claude-code", true),
("\"/opt/my agent/claude\" --resume", true),
("codex", true),
("codex --full-auto", true),
("CODEX=1 codex", true),
("/usr/local/bin/codex exec", true),
("npx codex", true),
("sudo -u root codex", true),
("env -i codex", true),
("command codex", true),
("pnpm --silent dlx codex", true),
("\"/opt/my agent/codex\" exec", true),
("bash", false),
("opencode", false),
("/usr/local/bin/bash -l", false),
@@ -1999,7 +2032,7 @@ mod tests {
/// A configured preset (the spawn-form "alias" for a command) is gated
/// the same way as free text: a preset whose command resolves to Claude
/// through a wrapper still offers structured view.
/// Code or Codex through a wrapper still offers structured view.
#[test]
fn structured_view_follows_command_resolution_for_presets() {
let mut draft = DraftPane {
@@ -2014,6 +2047,11 @@ mod tests {
label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(),
},
SpawnPreset {
id: "codex-wrapped".into(),
label: "Codex (npx)".into(),
command: "npx codex exec".into(),
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
@@ -2027,7 +2065,13 @@ mod tests {
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching to a preset that does not resolve to Claude clears the
// Switching between supported presets preserves the selection.
draft.cycle_preset(1);
assert_eq!(draft.command(), "npx codex exec");
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching to a preset that does not resolve to an ACP agent clears the
// stale selection instead of letting an invalid request through.
draft.cycle_preset(1);
assert_eq!(draft.command(), "aider");
@@ -2036,11 +2080,11 @@ mod tests {
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from Claude clears an armed
/// Editing the custom-command text away from a supported agent clears an armed
/// selection immediately, matching the preset-switch case above — an
/// invalid `Structured` request can never reach `build_request`.
#[test]
fn structured_view_clears_when_custom_command_edited_away_from_claude() {
fn structured_view_clears_when_custom_command_edited_away_from_supported_agent() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
@@ -2049,22 +2093,22 @@ mod tests {
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("claude");
draft.custom_command = crate::text_input::TextInput::with_value("codex");
draft.toggle_structured_view();
assert!(draft.structured_view);
typed(&mut draft, " -p");
assert_eq!(draft.command(), "claude -p");
assert!(draft.structured_view, "still Claude, selection survives");
assert_eq!(draft.command(), "codex -p");
assert!(draft.structured_view, "still Codex, selection survives");
for _ in 0.."claude -p".chars().count() {
for _ in 0.."codex -p".chars().count() {
draft.on_input(&InputKey::Backspace);
}
typed(&mut draft, "bash");
assert_eq!(draft.command(), "bash");
assert!(
!draft.structured_view,
"editing the program away from claude clears the stale selection"
"editing the program away from Codex clears the stale selection"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
+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
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
daemon is the ACP **client**; the adapter process, `claude-code-acp`, is the
ACP **agent** — it wraps Claude Code's own SDK and translates the model's
tool calls into ACP `session/update` notifications instead of drawing them to
a pane. `crates/maestro-acp` owns the daemon's side of that connection;
daemon is the ACP **client**; the provider adapter process is the ACP
**agent**. It translates the provider's events into ACP `session/update`
notifications instead of drawing them to a pane. `crates/maestro-acp` owns the
daemon's side of that connection;
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two
projections below.
@@ -37,30 +37,26 @@ ACP handle instead of a tmux pane).
## The adapter
`ADAPTER_BIN` (`claude-code-acp`) is resolved by walking `PATH` — the
session's own `PATH` env var if the spawn request set one, the daemon's
otherwise — and taking the first executable file named `claude-code-acp` on
it. Install it once per host:
The resolved structured command selects a provider adapter. Its binary is
resolved by walking the session's `PATH` when the spawn request sets one, or
the daemon's `PATH` otherwise, and taking the first executable with that name.
```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
```
| Command | Adapter binary | Install command | Provider detail |
| --- | --- | --- | --- |
| `claude` | `claude-code-acp` | `npm install -g --prefix ~/.local @zed-industries/claude-code-acp` | Needs a logged-in Claude Code. |
| `codex` | `codex-acp` | `npm install -g --prefix ~/.local @agentclientprotocol/codex-acp` | Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration. |
That is `maestro_acp::INSTALL_COMMAND`, and it is not just documentation: it
is baked into `AcpError::AdapterMissing`'s message. When the adapter cannot
be found, spawn still creates the session — **Dead**, with that install
command as its `activity_detail` — so the sidebar names the fix instead of
the session silently never appearing (see `docs/INSTALL.md` for the full
install writeup, including the systemd `PATH` caveat). The adapter also needs
a logged-in Claude Code (`claude /login`); a missing login surfaces the same
way, via the adapter's own stderr tail in the failure message.
The selected install command is baked into `AcpError::AdapterMissing`. When an
adapter cannot be found, spawn still creates a **Dead** session with that
command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
caveat). Provider authentication failures surface through the adapter's stderr
tail in the same field.
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
slow machine to boot a node process and the Claude Code SDK behind it, never
long enough to hang a spawn indefinitely. Any handshake failure — missing
adapter, early exit, a protocol error, or the timeout — leaves no orphaned
process: `spawn` closes and reaps the child itself before returning the
error.
slow machine to boot an adapter and its provider process, never long enough
to hang a spawn indefinitely. Any handshake failure — missing adapter, early
exit, a protocol error, or the timeout — leaves no orphaned process: `spawn`
closes and reaps the child itself before returning the error.
## The ACP handshake
@@ -69,17 +65,17 @@ declares, deliberately, almost nothing:
| Capability | Declared | Why |
| --- | --- | --- |
| `fs.readTextFile` | `false` | The adapter uses Claude Code's own Read tool instead of asking the daemon to read a file on its behalf — the same tool a terminal-view Claude Code session uses. |
| `fs.writeTextFile` | `false` | Same reasoning, for Write. |
| `terminal` | `false` | The adapter runs Claude Code's own Bash tool rather than asking the daemon to open a terminal for it. |
| `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
| `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
| `terminal` | `false` | Providers run commands through their own tool surface. |
Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because `claude-code-acp` has
never asked for it, not because it was weighed and refused.
default; the daemon does not advertise support because neither supported
adapter needs it, not because it was weighed and refused.
The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below.
@@ -96,6 +92,14 @@ flight is dropped on arrival, deliberately: the daemon's own event log
already holds that history, and forwarding it too would duplicate every row
in the projections below.
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
shell commands, file changes, MCP calls, web search, image generation/view,
reasoning, plans, and token usage into standard ACP chunks and tool calls;
those fold into the same eight rows. Codex's `available_commands_update` and
`session_info_update`, like equivalent Claude updates, remain opaque
`Notice` rows. Its `session/load` capability is required for daemon recovery
just as it is for Claude.
## The eight transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
@@ -106,14 +110,14 @@ crate.
| 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. |
| `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. |
| `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / `thought:<seq>`, coalesced the same way as `AgentText`. |
| `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>`, 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>` / 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. |
| `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. |
| `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
`agent-client-protocol` dependency's schema already deserializes (it
@@ -147,7 +151,10 @@ choices, not daemon ones.
TUI renderers can never disagree about it.
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
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
below) — a client never sees an ACP permission request before the daemon
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
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
presentation for `Notice`. `Plan` is the exception: both clients omit it
from the transcript entirely and render the current plan in a separate
plan rail (web) / status line (TUI) instead.
- How `responding` vs. `thinking` are distinguished visually, if at all.
from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
source for composer `/` autocomplete at any whitespace-delimited token under
the caret. Completing a command replaces only that token. The client omits
those state-only notices, along with `session_info_update`, from the visible
transcript.
- How live `responding`, `thinking`, tool activity, elapsed time, and context
usage are drawn. The web client keeps these in a compact strip above the
composer and renders each durable turn footer inline with the transcript.
- How the operator is asked to resolve an `Approval` row — the daemon only
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
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
notifications and daemon-generated rows alike (`sidecar.rs`'s
`PersistedEvent` envelope is what lets an `Approval` row share a cursor
sequence with raw ACP JSON without pretending to be ACP traffic). Two ways to
`PersistedEvent` envelope is what lets `User` and `Approval` rows share a cursor
sequence with raw ACP JSON without pretending to be ACP traffic; the envelope
also carries durable `Turn` footers). Two ways to
read it:
- **`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
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:
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
nonce that would make it "pending" never made it to disk in the first
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
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
queuing — one active turn per session, never a hidden queue of prompts.
- 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
ACP `SessionNotification` (kept in its original JSON so old logs stay
readable) or a `{"maestroEvent": "approval", "row": …}` envelope for a
daemon-generated row. Both share the same 1-based line-position cursor. A
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
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
it keeps its position and is skipped as malformed rather than corrupting
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",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0"
"@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
},
"devDependencies": {
"happy-dom": "^20.11.6",
@@ -1222,6 +1223,18 @@
"@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": {
"version": "2.1.3",
"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",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0"
"@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
},
"devDependencies": {
"happy-dom": "^20.11.6",
+10
View File
@@ -20,6 +20,9 @@ importers:
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
marked:
specifier: ^18.0.11
version: 18.0.11
devDependencies:
happy-dom:
specifier: ^20.11.6
@@ -424,6 +427,11 @@ packages:
magic-string@0.30.21:
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:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -864,6 +872,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.11: {}
ms@2.1.3: {}
nanoid@3.3.12: {}
+16
View File
@@ -12,6 +12,7 @@ import type {
RecentDirs,
Session,
StructuredApprovalDecision,
StructuredConfigRequest,
StructuredPromptRequest,
Uuid,
} 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
* merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough
+25
View File
@@ -130,6 +130,31 @@ describe("DaemonLink", () => {
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 () => {
const sched = manual();
const reload = vi.fn();
+11
View File
@@ -148,6 +148,17 @@ export class DaemonLink {
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.
*
+15
View File
@@ -132,6 +132,21 @@ describe("CatalogStore reducer", () => {
});
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", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets);
+17
View File
@@ -59,6 +59,23 @@ export class CatalogStore {
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. */
backoff(): number {
return this.backoffMs;
+13
View File
@@ -107,6 +107,19 @@ void link.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
// without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) {
+5
View File
@@ -52,6 +52,11 @@ export function structuredCancel(id: Uuid): string {
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
* request. */
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: {} } });
});
it("requests structured view only for Claude Code", () => {
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({
req: {
cwd: base.cwd,
command: "claude",
metadata: { name: "amber-fox" },
view: "Structured",
},
});
it.each(["claude", "FOO=1 /usr/local/bin/codex", "env -i codex"])(
"requests structured view for resolved %s commands",
(command) => {
expect(buildSpawnRequest({ ...base, command, structuredView: true }, null)).toEqual({
req: {
cwd: base.cwd,
command,
metadata: { name: "amber-fox" },
view: "Structured",
},
});
},
);
it("does not request structured view for unsupported commands", () => {
expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({
+9 -2
View File
@@ -15,6 +15,13 @@ import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
function supportsStructuredView(command: string): boolean {
const tool = splitCommand(command).tool;
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
}
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing";
@@ -68,7 +75,7 @@ export function buildSpawnRequest(
metadata,
};
if (values.structuredView && splitCommand(values.command).tool === "claude") {
if (values.structuredView && supportsStructuredView(values.command)) {
req.view = "Structured";
}
@@ -369,7 +376,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let commandTouched = false;
function updateStructuredAvailability(): void {
const supported = splitCommand(command).tool === "claude";
const supported = supportsStructuredView(command);
structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false;
}
+25 -11
View File
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
vi.unstubAllGlobals();
});
it("is default-off and shown only for the resolved Claude Code command", async () => {
it("is default-off and shown for resolved Claude Code and Codex commands", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
@@ -59,26 +59,33 @@ describe("spawn form structured view", () => {
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click();
expect(wrap.hidden).toBe(true);
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click();
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(false);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(true);
});
it("clears a checked value when the command changes away from Claude Code", async () => {
it("clears a checked value when Codex changes to an unsupported command", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "env -i codex";
command.dispatchEvent(new Event("input"));
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
checkbox.click();
expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "opencode";
command.dispatchEvent(new Event("input"));
@@ -86,7 +93,7 @@ describe("spawn form structured view", () => {
expect(checkbox.checked).toBe(false);
});
it("emits Structured in the submitted request when selected", async () => {
it.each(["claude", "codex"])("emits Structured for a selected %s command", async (tool) => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
@@ -95,13 +102,20 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
if (tool === "codex") {
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
}
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit();
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({
command: "claude",
command: tool,
view: "Structured",
});
});
+276 -84
View File
@@ -8,14 +8,20 @@ vi.mock("./api.js", async () => {
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
cancelStructuredTurn: 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 { CatalogStore } from "./events.js";
import type { CatalogStore } from "./events.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
* events.test.ts's FakeSocket, adapted for JSON structured events). */
@@ -109,7 +115,7 @@ describe("structured connection: data flow", () => {
conn.dispose();
});
it("keeps snapshot plan rows out of the transcript while rendering the plan rail", () => {
it("keeps state-only plan rows out of the transcript", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -138,7 +144,56 @@ describe("structured connection: data flow", () => {
expect(transcript.textContent).toContain("before plan");
expect(transcript.textContent).toContain("after 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();
});
@@ -194,8 +249,7 @@ describe("structured connection: data flow", () => {
"user:1",
"agent:1",
]);
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("updated");
expect(conn.el.querySelector(".structured-rail")!.textContent).not.toContain("first");
expect(transcript.textContent).not.toContain("updated");
ws.emit({
type: "projection_update",
@@ -216,7 +270,7 @@ describe("structured connection: data flow", () => {
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([
"agent:1",
]);
@@ -247,6 +301,31 @@ describe("structured connection: data flow", () => {
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")!.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", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -358,6 +437,142 @@ describe("structured connection: data flow", () => {
describe("structured connection: turn/composer gating", () => {
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", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -368,9 +583,46 @@ describe("structured connection: turn/composer gating", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(true);
expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
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();
});
@@ -701,9 +953,9 @@ describe("structured connection: reconnection/error/loading", () => {
});
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(conn.el.querySelector(".structured-rail")!.textContent).toContain("reconnected");
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
@@ -935,7 +1187,6 @@ describe("structured connection: reconnection/error/loading", () => {
describe("structured connection: transcript scrolling", () => {
let frames: Map<number, FrameRequestCallback>;
let nextFrame: number;
let resizeCallback: ResizeObserverCallback;
beforeEach(() => {
vi.clearAllMocks();
@@ -950,14 +1201,6 @@ describe("structured connection: transcript scrolling", () => {
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
frames.delete(id);
});
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
});
});
afterEach(() => {
@@ -986,13 +1229,6 @@ describe("structured connection: transcript scrolling", () => {
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", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -1075,30 +1311,6 @@ describe("structured connection: transcript scrolling", () => {
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", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -1211,14 +1423,11 @@ describe("structured connection: transcript scrolling", () => {
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.dispatchEvent(new Event("input"));
resizeBody(500);
expect(frames.size).toBe(0);
conn.onMount({ focus: false });
expect(frames.size).toBe(2);
conn.dispose();
expect(frames.size).toBe(0);
resizeBody(700);
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
runFrames();
expect(values.top).toBe(25);
});
@@ -1227,6 +1436,18 @@ describe("structured connection: transcript scrolling", () => {
describe("structured connection: pane lifecycle", () => {
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", () => {
const conn = mount({ sockets: [new FakeSocket()] });
expect(() => conn.sendInput("\x0b")).not.toThrow();
@@ -1264,41 +1485,12 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose();
});
it("dispose closes the socket and unsubscribes from the store", () => {
const store = new CatalogStore({ socket: () => new FakeSocket() as unknown as WebSocket });
const unsubscribe = vi.spyOn(store, "subscribe");
it("dispose closes the socket", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store });
expect(unsubscribe).toHaveBeenCalled();
const conn = mount({ sockets: [ws] });
conn.dispose();
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", () => {
+424 -95
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 —
// 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
@@ -10,19 +10,24 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
setStructuredConfig,
structuredWsUrl,
} from "./api.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 type { PaneConnection } from "./pane.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 {
AcpPlan,
AcpUsage,
AcpSessionConfigChoice,
AcpSessionConfigOption,
ReducedState,
Session,
StructuredApprovalDecision,
StructuredEvent,
TranscriptDelta,
@@ -47,11 +52,23 @@ const idleTurn = (): ReducedState["turn"] => ({
active_tool_calls: [],
});
interface AvailableCommand {
name: string;
description: string;
input?: { hint?: string };
}
interface CommandToken {
start: number;
end: number;
query: string;
}
export function createStructuredConnection(
sessionId: Uuid,
opts: StructuredOptions = {},
info?: AttachInfo,
store?: CatalogStore,
_store?: CatalogStore,
socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)),
): PaneConnection {
const el = document.createElement("div");
@@ -60,31 +77,53 @@ export function createStructuredConnection(
<header class="attach-header">
<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="st-pills"></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>
</header>
<div class="structured-body">
<div class="structured-scroll">
<div class="structured-rows"></div>
<div class="structured-main">
<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">
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"></textarea>
<button type="submit" class="btn btn-primary structured-send">Send</button>
<div class="structured-input-wrap">
<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>
</form>
</div>
<aside class="structured-rail"></aside>
</div>
`;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const pillsEl = el.querySelector(".st-pills") 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 scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
const railEl = el.querySelector(".structured-rail") as HTMLElement;
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
const activityEl = el.querySelector(".structured-activity") as HTMLDivElement;
const activityText = el.querySelector(".structured-activity-text") as HTMLSpanElement;
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 sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
@@ -108,15 +147,22 @@ export function createStructuredConnection(
const rowIndex = new Map<string, number>();
const rowEls = new Map<string, HTMLElement>();
const expandedRowIds = new Set<string>();
const expandedGroupIds = new Set<string>();
const groupEls = new Map<string, HTMLElement>();
let state: ReducedState = { turn: idleTurn() };
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 awaitingBeginGeneration: number | null = null;
let acceptedSubmission: { generation: number; socketGeneration: number } | null = null;
let socketGeneration = 0;
let stateSocketGeneration = 0;
let disposed = false;
let optimisticTurnStartedAt: number | null = null;
let activityTimer: number | null = null;
// Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -171,7 +217,9 @@ export function createStructuredConnection(
}
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();
}
}
scrollEl.addEventListener("scroll", onTranscriptScroll);
@@ -230,41 +278,74 @@ export function createStructuredConnection(
return el;
}
function insertRowElement(row: TranscriptRow, el: HTMLElement): void {
const index = rowIndex.get(row.id) ?? rows.length - 1;
let next: HTMLElement | null = null;
for (let i = index + 1; i < rows.length; i++) {
next = rowEls.get(rows[i].id) ?? null;
if (next) break;
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
const groups = new Map<string, TranscriptRow[]>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
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);
rowsEl.insertBefore(el, next);
return groups;
}
function mountRow(row: TranscriptRow): void {
const el = buildRowElement(row);
if (el) insertRowElement(row, el);
}
function rebuildTranscript(): void {
const active = document.activeElement as HTMLElement | null;
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 {
const old = rowEls.get(row.id);
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
const restoreFocus = document.activeElement === oldToolHead;
const el = buildRowElement(row);
if (!el) {
old?.remove();
rowEls.delete(row.id);
expandedRowIds.delete(row.id);
return;
rowEls.clear();
groupEls.clear();
const children: HTMLElement[] = [];
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const groupRows = groups.get(groupId) ?? [];
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
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);
finishTranscriptLayout();
},
);
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) {
rowEls.set(row.id, el);
old.replaceWith(el);
} else {
insertRowElement(row, el);
rowsEl.replaceChildren(...children);
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 {
@@ -277,9 +358,8 @@ export function createStructuredConnection(
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
rowIndex.clear();
rows.forEach((row, i) => rowIndex.set(row.id, i));
rowEls.clear();
rowsEl.replaceChildren();
for (const row of rows) mountRow(row);
rebuildTranscript();
syncAvailableCommands();
}, forceBottom);
receivedSnapshot = true;
}
@@ -290,16 +370,13 @@ export function createStructuredConnection(
if (delta.type === "append") {
rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row);
mountRow(delta.row);
} else if (delta.type === "patch") {
const i = rowIndex.get(delta.row.id);
if (i === undefined) {
rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row);
mountRow(delta.row);
} else {
rows[i] = delta.row;
remountRow(delta.row);
}
} else {
const i = rowIndex.get(delta.id);
@@ -308,38 +385,140 @@ export function createStructuredConnection(
expandedRowIds.delete(delta.id);
rowIndex.clear();
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
rowEls.get(delta.id)?.remove();
rowEls.delete(delta.id);
}
rebuildTranscript();
syncAvailableCommands();
});
}
function toolRowCount(): number {
return rows.reduce((n, r) => n + (r.kind === "tool_call" || r.kind === "tool_diff" ? 1 : 0), 0);
function sessionConfigOptions(): AcpSessionConfigOption[] {
return state.config_options?.configOptions ?? [];
}
function renderPills(): void {
const parts: string[] = [];
if (state.model) parts.push(state.model);
if (state.mode) parts.push(state.mode);
pillsEl.textContent = parts.join(" · ");
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
return (option.options ?? []).flatMap((choice) =>
"options" in choice ? choice.options : [choice],
);
}
function renderRailNow(): void {
renderRail(railEl, {
plan: (state.plan ?? null) as AcpPlan | null,
turn: state.turn,
toolRowCount: toolRowCount(),
usage: (state.usage ?? null) as AcpUsage | null,
session: latestSession,
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;
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
configTrigger.disabled = configBusy;
configMenu.replaceChildren();
for (const option of ordered) {
const row = document.createElement("label");
row.className = "structured-config-row";
const name = document.createElement("span");
name.className = "structured-config-name";
name.textContent = option.name;
row.append(name);
if (option.type === "select") {
const select = document.createElement("select");
select.className = "structured-config-select";
select.disabled = configBusy;
for (const choice of configChoices(option)) {
const item = document.createElement("option");
item.value = choice.value;
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 {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.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 {
@@ -363,15 +542,34 @@ export function createStructuredConnection(
updateTranscriptLayout(() => {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId;
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
if (prev !== null || newPendingId !== null) rebuildTranscript();
});
}
renderPills();
renderRailNow();
renderConfig();
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 ----
let errorEl: HTMLElement | null = null;
function showError(message: string): void {
@@ -388,19 +586,166 @@ export function createStructuredConnection(
}
// ---- 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 {
updateTranscriptLayout(() => {
input.style.height = "auto";
input.style.overflowY = "hidden";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
if (input.scrollHeight > 160) input.style.overflowY = "auto";
});
}
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) => {
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) {
e.preventDefault();
composerForm.requestSubmit();
}
});
input.addEventListener("blur", () => {
window.setTimeout(() => {
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 0);
});
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
@@ -408,6 +753,7 @@ export function createStructuredConnection(
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now();
acceptedSubmission = null;
input.value = "";
autoResize();
@@ -445,24 +791,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 ----
let ws: WebSocket | null = null;
let reconnectMs = INITIAL_RECONNECT_MS;
@@ -611,17 +939,18 @@ export function createStructuredConnection(
dispose() {
if (disposed) return;
disposed = true;
if (activityTimer !== null) window.clearInterval(activityTimer);
mountToken++;
scrollWorkActive = false;
cancelScrollFrame();
cancelFocusFrame();
cancelReconnect();
resizeObserver.disconnect();
unsubscribeStore?.();
closeCurrentSocket();
expandedRowIds.clear();
expandedGroupIds.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true);
document.removeEventListener("pointerdown", closeConfigOnOutsidePress);
el.remove();
},
};
+103 -101
View File
@@ -2,12 +2,12 @@
import { describe, expect, it, vi } from "vitest";
import {
branchFromWorktreePath,
buildToolGroup,
buildTranscriptRow,
renderInlineMarkdown,
renderRail,
renderMarkdown,
} from "./structuredRender.js";
import type { Session, TranscriptRow } from "./types.js";
import type { TranscriptRow } from "./types.js";
function row<K extends TranscriptRow["kind"]>(
kind: K,
@@ -24,6 +24,7 @@ describe("buildTranscriptRow: user/agent/thought", () => {
);
expect(el.className).toContain("st-row-user");
expect(el.textContent).toContain("hi <b>there</b>");
expect(el.querySelector(".st-who")).toBeNull();
// never actual markup from user input
expect(el.querySelector("b")).toBeNull();
});
@@ -55,6 +56,21 @@ describe("buildTranscriptRow: user/agent/thought", () => {
const el = buildTranscriptRow(row("user", { content: { type: "text", text: 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", () => {
@@ -67,6 +83,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", () => {
it("shows glyph, name, argument and no meta for a completed non-diff call", () => {
const el = buildTranscriptRow(
@@ -128,6 +162,44 @@ describe("buildTranscriptRow: tool calls", () => {
head.click();
expect(head.getAttribute("aria-expanded")).toBe("false");
});
it("shows raw command, input, and output as separately copyable details", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Bash(test)",
rawInput: { command: "printf ok", timeout_ms: 1000 },
rawOutput: { stdout: "ok", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const labels = [...el.querySelectorAll(".st-tool-detail-label")].map((node) => node.textContent);
expect(labels).toEqual(["Command", "Input", "Output"]);
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
expect(el.querySelectorAll(".st-tool-detail pre[role=button]")).toHaveLength(3);
});
});
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.hidden).toBe(true);
head.click();
expect(body.hidden).toBe(false);
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", () => {
@@ -159,7 +231,7 @@ describe("buildTranscriptRow: tool diffs", () => {
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
// 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", () => {
@@ -279,6 +351,24 @@ describe("buildTranscriptRow: approval", () => {
});
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", () => {
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
"[notice]",
@@ -287,104 +377,16 @@ describe("buildTranscriptRow: notice / unknown kinds", () => {
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", () => {
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
});
});
+238 -153
View File
@@ -4,18 +4,16 @@
// unit-testable without a WebSocket.
import { escapeHtml } from "./html.js";
import { marked, Renderer } from "marked";
import { computeDiffLines, diffStats } from "./structuredDiff.js";
import type {
AcpContentBlock,
AcpPermissionOption,
AcpPlan,
AcpToolCallContent,
AcpToolCallLike,
AcpUsage,
InTheWay,
Session,
StructuredApprovalDecision,
TranscriptRow,
TurnMeta,
} from "./types.js";
// ---------------------------------------------------------------------------
@@ -98,10 +96,7 @@ function contentBlockText(block: AcpContentBlock | undefined): string {
}
}
/** A tiny, dependency-free inline renderer: `**bold**`, `*italic*`, and
* `` `code` `` become real markup; everything else stays escaped plain
* text. No block-level markdown (lists, headings) — agent prose is
* paragraphs, not documents. */
/** Small inline renderer retained for compact labels and focused tests. */
export function renderInlineMarkdown(text: string): string {
const escaped = escapeHtml(text);
return escaped
@@ -110,15 +105,88 @@ export function renderInlineMarkdown(text: string): string {
.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 {
const paragraphs = text.split(/\n{2,}/);
for (const para of paragraphs) {
if (!para) continue;
const p = document.createElement("p");
p.innerHTML = renderInlineMarkdown(para);
container.appendChild(p);
container.innerHTML = renderMarkdown(text);
for (const table of Array.from(container.querySelectorAll("table"))) {
const scroller = el("div", "st-markdown-table-wrap");
table.replaceWith(scroller);
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();
});
}
// ---------------------------------------------------------------------------
@@ -131,9 +199,6 @@ function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "though
const wrap = el("div", `st-row st-row-${variant}`);
const body = el("div", "st-body");
if (variant === "user") {
const who = el("div", "st-who");
who.textContent = "You";
body.appendChild(who);
const line = el("div", "st-user-text");
line.textContent = text;
body.appendChild(line);
@@ -143,6 +208,8 @@ function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "though
body.textContent = text;
}
wrap.appendChild(body);
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
makeCopyable(wrap, text, label);
return wrap;
}
@@ -151,6 +218,7 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
const label = el("div", "st-diff-path");
label.textContent = diff.path;
label.title = diff.path;
makeCopyable(label, diff.path, "path");
wrap.appendChild(label);
const scroller = el("div", "st-diff");
const pre = document.createElement("pre");
@@ -162,10 +230,44 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
pre.appendChild(document.createTextNode("\n"));
}
scroller.appendChild(pre);
makeCopyable(scroller, pre.textContent ?? "", "diff");
wrap.appendChild(scroller);
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 toolInput(tool: AcpToolCallLike): { command: string | null; input: string | null } {
const raw = tool.rawInput;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { command: null, input: printable(raw) };
}
const record = raw as Record<string, unknown>;
const command = typeof record.command === "string" ? record.command : null;
if (!command) return { command: null, input: printable(raw) };
const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== "command"));
return { command, input: Object.keys(rest).length > 0 ? printable(rest) : null };
}
function 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);
}
/** `+added -removed` across every diff block in a tool call's content —
* real derived data, never a fabricated duration. */
function toolDiffMeta(content: AcpToolCallContent[]): string {
@@ -234,13 +336,20 @@ function buildToolRow(
const buildOut = () => {
if (outBuilt) return;
outBuilt = true;
const { command, input } = toolInput(tool);
if (command) appendToolDetail(out, "Command", command);
if (input !== null) appendToolDetail(out, "Input", input);
const rawOutput = printable(tool.rawOutput);
if (rawOutput !== null) appendToolDetail(out, "Output", rawOutput);
const texts = content
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
.map((c) => contentBlockText(c.content))
.filter(Boolean);
if (texts.length > 0) {
const contentText = texts.join("\n");
if (contentText && contentText !== rawOutput) {
const textPanel = el("div", "st-tool-text");
textPanel.textContent = texts.join("\n");
textPanel.textContent = contentText;
makeCopyable(textPanel, contentText, "output");
out.appendChild(textPanel);
}
for (const block of content) {
@@ -270,14 +379,110 @@ function buildToolRow(
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");
body.hidden = !expanded;
body.append(...children);
head.addEventListener("click", () => {
expanded = !expanded;
head.setAttribute("aria-expanded", String(expanded));
body.hidden = !expanded;
onExpandedChange?.(expanded);
});
wrap.append(head, body);
return wrap;
}
function buildPlainRow(row: TranscriptRow): HTMLElement {
const wrap = el("div", "st-row st-row-notice");
const body = el("div", "st-body");
body.textContent = `[${row.kind}]`;
wrap.appendChild(body);
makeCopyable(wrap, body.textContent, "notice");
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 {
pending: boolean;
/** Resolves once the decision has been submitted; rejects when the POST
@@ -367,8 +572,8 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
return wrap;
}
/** Build the DOM for one transcript row, or `null` for a plan shown only in
* the rail. `approval` is consulted only for an `approval` row; tool
/** Build the DOM for one transcript row, or `null` for state-only plan rows.
* `approval` is consulted only for an `approval` row; tool
* expansion is supplied and reported explicitly. */
export function buildTranscriptRow(
row: TranscriptRow & { kind: "plan" },
@@ -377,7 +582,13 @@ export function buildTranscriptRow(
onToolExpandedChange?: (expanded: boolean) => void,
): null;
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,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
@@ -414,6 +625,10 @@ export function buildTranscriptRow(
break;
case "plan":
return null;
case "notice":
if (isStateOnlyNotice(row)) return null;
built = turnMeta(row) ? buildTurnMetaRow(turnMeta(row)!) : buildPlainRow(row);
break;
default:
built = buildPlainRow(row);
break;
@@ -421,133 +636,3 @@ export function buildTranscriptRow(
built.dataset.rowId = row.id;
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);
}
+598 -135
View File
@@ -1892,6 +1892,7 @@ input.leaf-rename:focus {
align-items: center;
gap: var(--sp-2);
height: var(--bar-h);
flex: 0 0 var(--bar-h);
padding: 0 var(--sp-2);
border-bottom: 1px solid var(--border);
background: var(--surface);
@@ -2168,176 +2169,209 @@ input.leaf-rename:focus {
/* ---------- structured view (#140) ----------
`.attach.structured` reuses `.attach`'s flex column, `.attach-header` and
`.attach-state` verbatim (attach.ts and structured.ts share paneTitle.ts's
header markup) — only the body differs: a transcript column + rail grid
instead of an xterm surface. Hierarchy is deliberate: the approval is the
header markup) — only the body differs: a transcript and composer instead
of an xterm surface. Hierarchy is deliberate: the approval is the
only lifted, card-like element on the page; everything else is either a
tinted block (user), plain text (agent), or a thin bordered row (tool). */
.st-pills {
font-size: var(--fs-11);
.structured-config {
position: relative;
flex: 0 0 40px;
}
.structured-config-trigger {
width: 40px;
height: 40px;
display: inline-flex;
justify-content: center;
align-items: center;
padding: 0;
border: 0;
border-radius: var(--r-2);
background: transparent;
color: var(--muted);
white-space: nowrap;
cursor: pointer;
}
.structured-config-trigger svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
}
.structured-config-trigger:hover,
.structured-config-trigger[aria-expanded="true"] {
color: var(--ink);
background: var(--surface-3);
}
.structured-config-trigger:focus-visible,
.structured-config-select:focus-visible,
.structured-config-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.structured-config-menu {
position: absolute;
z-index: 20;
bottom: calc(100% + var(--sp-2));
right: 0;
width: min(320px, 86vw);
padding: var(--sp-2);
border: 1px solid var(--border-strong);
border-radius: var(--r-2);
background: var(--surface);
box-shadow: var(--shadow-2);
}
.structured-config-row {
display: grid;
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.6fr);
align-items: center;
gap: var(--sp-3);
min-height: 34px;
padding: var(--sp-1) var(--sp-2);
color: var(--ink);
font-size: var(--fs-12);
}
.structured-config-row + .structured-config-row {
border-top: 1px solid var(--border);
}
.structured-config-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structured-config-select {
min-width: 0;
height: 28px;
padding: 0 var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
background: var(--surface-2);
color: var(--ink);
font: inherit;
}
.structured-config-toggle {
justify-self: end;
width: 17px;
height: 17px;
accent-color: var(--accent);
}
.structured-body {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 232px;
display: flex;
}
.structured-body.st-narrow {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
.attach.structured,
.attach.structured .attach-title,
.structured-input {
font-family: var(--font-sans);
}
.structured-body.st-narrow .structured-rail {
border-left: 0;
border-top: 1px solid var(--border);
max-height: 40%;
.st-row-agent code,
.st-tool-text,
.st-tool-detail pre,
.st-diff,
.st-approval-path {
font-family: var(--font-mono);
}
.structured-main {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
flex: 1 1 auto;
}
.structured-scroll {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.structured-rows {
padding: var(--sp-3) var(--sp-3) 0;
display: flex;
flex-direction: column;
gap: var(--sp-3);
min-width: 0;
}
.structured-rail {
border-left: 1px solid var(--border);
background: var(--surface-2);
padding: var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-5);
min-width: 0;
overflow-y: auto;
}
.structured-rail h3 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-11);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--faint);
font-weight: 500;
}
.st-plan {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.st-plan li {
display: flex;
gap: var(--sp-2);
font-size: var(--fs-12);
line-height: 1.4;
}
.st-plan-mark {
.structured-activity {
flex: 0 0 auto;
width: 12px;
text-align: center;
}
.st-plan-done {
color: var(--faint);
}
.st-plan-now {
color: var(--ink);
}
.st-plan-now .st-plan-mark,
.st-plan-done .st-plan-mark {
color: var(--live);
}
.st-plan-todo {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 30px;
padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
color: var(--muted);
}
.st-plan-todo .st-plan-mark {
color: var(--border-strong);
}
.st-kv {
display: flex;
flex-direction: column;
gap: var(--sp-2);
font-size: var(--fs-12);
}
.st-kv-row {
display: flex;
justify-content: space-between;
gap: var(--sp-2);
}
.st-kv-k {
color: var(--faint);
}
.st-kv-v {
color: var(--ink);
background: var(--surface-2);
font-size: var(--fs-11);
font-variant-numeric: tabular-nums;
}
.st-meter {
height: 4px;
background: var(--surface-3);
border-radius: var(--r-1);
overflow: hidden;
margin-top: var(--sp-2);
.structured-activity[hidden] {
display: none;
}
.st-meter i {
display: block;
height: 100%;
.structured-activity-pulse {
position: relative;
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--live);
box-shadow: 0 0 0 0 var(--live-glow);
animation: structured-thinking 1.6s ease-out infinite;
}
@keyframes structured-thinking {
50% {
box-shadow: 0 0 0 5px transparent;
}
}
/* every transcript row carries its own layout; none of them are cards */
.st-row {
display: flex;
gap: var(--sp-3);
padding: var(--sp-3);
min-width: 0;
}
.st-body {
flex: 1 1 auto;
min-width: 0;
}
/* user turn: the only tinted block besides the approval — an accent rail,
never a full border, so it still reads lighter than the approval card. */
/* User turns read as compact chat bubbles while the padded row remains the
full hover/copy target. */
.st-row-user {
border-left: 2px solid var(--accent);
background: var(--surface-2);
padding: var(--sp-3);
justify-content: flex-end;
}
.st-who {
font-size: var(--fs-11);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--faint);
margin-bottom: var(--sp-1);
.st-row-user .st-body {
flex: 0 1 auto;
max-width: min(86%, 72ch);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-2);
background: var(--surface-3);
}
.st-row-user:hover {
background: var(--surface-2);
}
.st-user-text {
@@ -2355,6 +2389,88 @@ input.leaf-rename:focus {
margin-bottom: 0;
}
.st-row-agent .st-body > :first-child {
margin-top: 0;
}
.st-row-agent .st-body > :last-child {
margin-bottom: 0;
}
.st-row-agent .st-body h1,
.st-row-agent .st-body h2,
.st-row-agent .st-body h3,
.st-row-agent .st-body h4 {
margin: var(--sp-4) 0 var(--sp-2);
color: var(--ink);
font-size: var(--fs-14);
line-height: 1.35;
}
.st-row-agent .st-body ul,
.st-row-agent .st-body ol {
margin: var(--sp-2) 0;
padding-left: var(--sp-5);
}
.st-row-agent .st-body li + li {
margin-top: var(--sp-1);
}
.st-row-agent .st-body pre {
margin: var(--sp-2) 0;
padding: var(--sp-3);
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--surface-2);
}
.st-row-agent .st-body pre code {
padding: 0;
border: 0;
background: transparent;
}
.st-row-agent .st-body a {
color: var(--ink);
text-decoration-color: var(--border-strong);
text-underline-offset: 2px;
}
.st-markdown-table-wrap {
max-width: 100%;
margin: var(--sp-2) 0;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--r-2);
}
.st-row-agent table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-12);
}
.st-row-agent th,
.st-row-agent td {
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
white-space: nowrap;
}
.st-row-agent th {
color: var(--ink);
background: var(--surface-2);
font-weight: 600;
}
.st-row-agent tr:last-child td {
border-bottom: 0;
}
.st-row-agent code {
background: var(--surface-2);
border: 1px solid var(--border);
@@ -2375,8 +2491,90 @@ input.leaf-rename:focus {
font-size: var(--fs-12);
}
.st-turn-meta {
align-self: flex-start;
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0 var(--sp-3) var(--sp-2);
padding: var(--sp-1) var(--sp-2);
border-radius: var(--r-1);
color: var(--faint);
font-size: var(--fs-11);
font-variant-numeric: tabular-nums;
}
.st-turn-meta:hover {
background: var(--surface-2);
color: var(--muted);
}
.st-turn-meta-dot {
width: 5px;
height: 5px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--live);
}
.st-turn-meta-cancelled .st-turn-meta-dot {
background: var(--warn);
}
.st-turn-meta-failed .st-turn-meta-dot {
background: var(--danger);
}
.st-copyable {
position: relative;
border-radius: var(--r-1);
cursor: copy;
transition: background var(--dur-fast) var(--ease-out);
}
.st-copyable::after {
content: "copy";
position: absolute;
top: var(--sp-1);
right: var(--sp-1);
padding: 1px var(--sp-1);
border-radius: var(--r-1);
background: var(--surface-3);
color: var(--faint);
font-size: var(--fs-11);
font-style: normal;
line-height: 1.4;
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-fast) var(--ease-out);
}
.st-copyable:hover,
.st-copyable:focus-visible {
background: var(--surface-2);
outline: none;
}
.st-copyable:hover::after,
.st-copyable:focus-visible::after,
.st-copyable[data-copy-state="copied"]::after,
.st-copyable[data-copy-state="error"]::after {
opacity: 1;
}
.st-copyable[data-copy-state="copied"]::after {
content: "copied";
color: var(--live);
}
.st-copyable[data-copy-state="error"]::after {
content: "copy failed";
color: var(--danger);
}
/* tool rows: bordered, compact, deliberately not lifted */
.st-tool {
flex: 1 1 auto;
border: 1px solid var(--border);
border-radius: var(--r-2);
min-width: 0;
@@ -2401,6 +2599,11 @@ input.leaf-rename:focus {
background: var(--surface-2);
}
.st-row-tool:hover,
.st-row-approval:hover {
background: var(--surface-2);
}
.st-tool-glyph {
width: 16px;
flex: 0 0 auto;
@@ -2460,6 +2663,101 @@ input.leaf-rename:focus {
overflow-x: auto;
}
.st-tool-detail {
min-width: 0;
}
.st-tool-detail-label {
margin-bottom: var(--sp-1);
color: var(--faint);
font-size: var(--fs-11);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.st-tool-detail pre {
margin: 0;
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
background: var(--bg);
color: var(--muted);
font: inherit;
font-size: var(--fs-12);
white-space: pre-wrap;
overflow-wrap: anywhere;
overflow-x: auto;
}
.st-tool-group {
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--r-2);
background: color-mix(in oklab, var(--surface-2) 55%, transparent);
padding: var(--sp-2) var(--sp-3);
transition: background var(--dur-fast) var(--ease-out);
}
.st-tool-group:hover {
background: var(--surface-2);
}
.st-tool-group-head {
appearance: none;
width: 100%;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0;
min-width: 0;
}
.st-tool-group-head:hover {
background: var(--surface-2);
}
.st-tool-group-glyph {
color: var(--faint);
transition: transform var(--dur-fast) var(--ease-out);
}
.st-tool-group-head[aria-expanded="true"] .st-tool-group-glyph {
transform: rotate(90deg);
}
.st-tool-group-title {
flex: 0 0 auto;
font-weight: 500;
}
.st-tool-group-names {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-tool-group-body {
padding-top: var(--sp-2);
display: flex;
flex-direction: column;
}
.st-tool-group-body > .st-row-tool {
padding: var(--sp-1) 0;
}
.st-tool-group-body[hidden] {
display: none;
}
.st-diff-block {
display: flex;
flex-direction: column;
@@ -2518,7 +2816,6 @@ input.leaf-rename:focus {
operator owes a decision on */
.st-approval {
border: 1px solid var(--warn);
border-left-width: 3px;
border-radius: var(--r-2);
background: color-mix(in oklab, var(--warn) 12%, var(--surface));
box-shadow: var(--shadow-1);
@@ -2610,42 +2907,191 @@ input.leaf-rename:focus {
border-color: var(--danger);
}
/* composer: sticky at the bottom of the scrolling transcript column */
/* The composer is a sibling of the scrolling transcript, not part of it: it
stays reachable at the pane edge without changing transcript scroll math. */
.structured-composer {
position: sticky;
bottom: 0;
margin-top: var(--sp-3);
padding: var(--sp-3);
border-top: 1px solid var(--border);
flex: 0 0 auto;
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
padding: var(--sp-1);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
background: var(--surface);
display: flex;
align-items: flex-end;
gap: var(--sp-2);
gap: var(--sp-1);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
var(--shadow-1);
transition:
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.structured-composer:focus-within {
border-color: var(--accent);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
0 0 0 3px var(--accent-tint),
var(--shadow-1);
}
.structured-input-wrap {
position: relative;
flex: 1 1 auto;
min-width: 0;
}
.structured-input {
flex: 1 1 auto;
display: block;
width: 100%;
min-width: 0;
max-height: 160px;
min-height: 40px;
resize: none;
padding: var(--sp-2) var(--sp-3);
overflow-y: hidden;
padding: 10px var(--sp-2);
border: 0;
border-radius: var(--r-2);
background: transparent;
color: var(--ink);
font-size: var(--fs-13);
line-height: 20px;
}
.structured-composer .structured-send,
.structured-composer .structured-cancel {
height: 40px;
min-height: 40px;
border-radius: var(--r-2);
font-family: var(--font-sans);
}
.structured-composer .structured-send {
gap: 6px;
padding: 0 var(--sp-3);
border-color: var(--accent);
background: var(--accent);
color: var(--accent-ink);
font-weight: 650;
box-shadow: inset 0 1px 0 color-mix(in oklab, white 24%, transparent);
}
.structured-composer .structured-send::before,
.structured-composer .structured-send::after,
.structured-composer .structured-cancel::before,
.structured-composer .structured-cancel::after {
content: none;
}
.structured-composer .structured-send svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--dur-fast) var(--ease-out);
}
.structured-composer .structured-send:hover {
background: var(--accent-strong);
border-color: var(--accent-strong);
color: var(--accent-ink);
}
.structured-composer .structured-send:hover svg {
transform: translateX(2px);
}
.structured-composer .structured-cancel {
padding: 0 var(--sp-3);
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
color: var(--danger);
}
.structured-composer .structured-cancel:hover {
background: var(--danger-tint);
}
@media (max-width: 520px) {
.structured-config-menu {
right: calc(-1 * (var(--control-h) + var(--sp-2)));
}
}
.structured-command-menu {
position: absolute;
z-index: var(--z-sticky);
right: 0;
bottom: calc(100% + var(--sp-2));
left: 0;
max-height: min(42vh, 320px);
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: var(--r-2);
background: var(--bg);
background: var(--surface);
box-shadow: var(--shadow-2);
}
.structured-command-menu[hidden] {
display: none;
}
.structured-command-option {
min-height: var(--tap);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.structured-command-option:last-child {
border-bottom: 0;
}
.structured-command-option:hover,
.structured-command-option[aria-selected="true"] {
background: var(--accent-tint);
}
.structured-command-title {
display: flex;
align-items: baseline;
gap: var(--sp-2);
min-width: 0;
}
.structured-command-name {
flex: 0 0 auto;
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--fs-13);
font-weight: 600;
}
.structured-command-hint,
.structured-command-description {
overflow: hidden;
color: var(--muted);
font-size: var(--fs-11);
text-overflow: ellipsis;
white-space: nowrap;
}
.structured-command-hint {
min-width: 0;
}
.structured-command-description {
margin-top: var(--sp-1);
}
.structured-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-tint);
box-shadow: none;
}
.structured-input:disabled {
color: var(--faint);
background: var(--surface-2);
background: transparent;
}
/* `.btn` sets `display: inline-flex`, which outranks the UA sheet's
@@ -2654,6 +3100,10 @@ input.leaf-rename:focus {
display: none;
}
.structured-send[hidden] {
display: none;
}
.structured-error {
margin: 0 var(--sp-3);
padding: var(--sp-2) var(--sp-3);
@@ -2664,9 +3114,22 @@ input.leaf-rename:focus {
}
@media (prefers-reduced-motion: reduce) {
.act {
.act,
.st-copyable,
.st-copyable::after,
.st-tool-group-glyph {
transition-duration: 0.01ms;
}
.structured-activity-pulse {
animation: none;
}
}
@media (hover: none) {
.st-copyable::after {
opacity: 0.7;
}
}
/* ---------- modal ---------- */
+41
View File
@@ -422,6 +422,8 @@ export interface TurnState {
responding: boolean;
thinking: boolean;
active_tool_calls: string[];
started_at?: string | null;
started_context_tokens?: number | null;
}
/** A whole-session view derived from ACP notifications. `plan`/`usage`/
@@ -436,6 +438,7 @@ export interface ReducedState {
context?: AcpUsage | null;
model?: string | null;
mode?: string | null;
config_options?: { configOptions: AcpSessionConfigOption[] } | null;
}
/** Server messages on `WS /api/structured/:id`. */
@@ -449,6 +452,33 @@ export interface StructuredPromptRequest {
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
* string (a bracketless Rust enum), not `{decision: ...}`. */
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
@@ -543,6 +573,17 @@ export interface AcpUsage {
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 {
optionId: string;
name: string;