Compare commits

...
Author SHA1 Message Date
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
Miguel Palhas 4c8217385a fix(maestro): scope assignment-skipping to wrappers that accept it
`command FOO=1 claude` and `nohup FOO=1 claude` are invalid shells (they
exec `FOO=1` as the program, exit 127), but the resolver skipped the
assignment unconditionally after any wrapper. Assignment prefixes are
only real at the start of a simple command or after a wrapper that itself
interprets them as env vars (env, sudo) or a keyword that restarts the
assignment grammar (time) — not after command, nohup, or a package
manager's subcommand, which just exec their next argument as the program.

Attach an accepts_assignment flag to each wrapper transition in both the
Rust and TS resolvers and gate the assignment-skip loop on the flag from
the most recently consumed wrapper. Extends the shared fixture with the
two reported cases plus every other wrapper transition and several nested
combinations (sudo+time valid, sudo+command / command+nohup / time+command
invalid, nohup+env valid) to lock the per-wrapper policy in for both
languages.
2026-09-05 14:25:57 +01:00
Miguel Palhas 758bacf3fc fix(maestro): harden command resolution 2026-09-05 14:05:52 +01:00
Miguel PalhasandClaude Sonnet 5 0af91296df fix(maestro): close command-resolution gaps from #199 review
Fixes six issues in the shared command resolver (Rust display.rs and
its TS mirror toolIcon.ts), all locked in by shared fixture cases:

- An unescaped newline now ends resolution like `;` does, instead of
  being folded in as whitespace; a backslash-newline is a line
  continuation, removed entirely rather than embedded or splitting
  the word.
- Assignment recognition (`FOO=bar`) now requires the `=` to come from
  plain, unquoted/unescaped source text. `"FOO=1"` and `FOO\=1` are
  executable words named `FOO=1`, not assignments — quote removal was
  losing that provenance.
- `command -v`/`-V` (lookup only, no execution), `env -S` (embeds a
  command string) and `npx -c`/`--call` (same) no longer resolve
  through to a program they don't actually run. Restricted bash
  `time`'s options to `-p`, the only flag the keyword itself parses
  (`-o` etc. belong to external GNU time) — this corrects a
  previously-wrong fixture case.
- `env`, `sudo` and `command`'s options may now bundle (`env -iv`,
  `sudo -nE`) or take an attached value (`sudo -uroot`), the way real
  getopt-based tools do, with missing-operand forms safely rejected.
- pnpm/yarn `dlx` and `bun x`/`bunx` now consume their own trailing
  options (`--package foo`/`--package=foo`, `--bun`) before the
  executable, matching each tool's documented CLI syntax.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:51:43 +01:00
Miguel Palhas 370edfa763 fix(maestro): resolve command wrappers with a shell-aware tokenizer (#199)
command_program/command_tool whitespace-split and only skipped whole
wrapper words, so quoted paths broke, sudo -u/env -i/nohup/time/npx
--yes/pnpm|yarn --silent dlx/bun x were rejected, and pnpm claude /
yarn claude (a package.json script, not `dlx claude`) resolved as a
Claude launch. Replace it with a tokenizer that honours quoting and
escaping without evaluating any expansion, and per-wrapper option
tables (env, sudo, command, nohup, time, npx, pnpm, yarn, bun) that
require dlx/x before looking through a package-manager wrapper.
Ambiguous shell grammar (pipelines, list operators, redirections,
substitution, unterminated quotes) is rejected outright rather than
guessed through; command_program still degrades to the naive first
word for display, but command_tool returns None.

Mirror the same resolver in web/src/toolIcon.ts and add
fixtures/command-resolution.json as a parity fixture both test
suites assert against.
2026-09-05 13:19:28 +01:00
Miguel Palhas e7086512e7 test(maestro): lock in shared command resolution for the TUI structured gate (#199)
The TUI's structured-view gate already delegates to command_tool, the
same resolver web/spawnForm.ts uses via splitCommand, so paths, env
assignments, wrappers, and configured presets already resolve like
daemon dispatch. Add coverage for those forms plus stale-selection
clearing on both preset switches and custom-command edits, so a
regression back to a naive basename match would be caught.
2026-09-05 12:59:03 +01:00
Miguel Palhas aca8d39886 Merge issue #198: keep TUI structured viewport stable 2026-09-05 12:46:31 +01:00
Miguel Palhas 6ae7c3eb97 fix(maestro): anchor structured TUI viewport to row identity
follow_selection=false stored scroll as a raw rendered-line offset into
lines rebuilt fresh every frame. A ProjectionUpdate::Patch growing a
streaming row above the viewport shifted every line below it, so the
raw offset kept pointing at a numerically-same but visually different
line and the viewport drifted even though the operator had scrolled
away deliberately.

Cache each frame's row-id-per-line layout and, before applying scroll
next frame, translate the old raw offset into (row id, intra-row line)
against that cache, then relocate it in the freshly rebuilt lines. Rows
above or intersecting the viewport growing, shrinking, or being
patched now shift the offset instead of the visible content. Falls
back to the nearest surviving neighbor row when the anchor row itself
is removed, and to the clamped raw offset when nothing nearby
survived. follow_selection and bottom-follow are unaffected — they
already recompute scroll from the live selection/tail each frame.
2026-09-05 12:39:21 +01:00
Miguel Palhas a73eb6284f Merge issue #197: remove structured lock inversion 2026-09-05 12:22:55 +01:00
Miguel Palhas 57efb615f6 fix(maestro): key activity watermark by stream 2026-09-05 12:19:16 +01:00
Miguel Palhas fe26dab6a8 fix(maestro): remove structured lock inversion 2026-09-05 11:55:25 +01:00
Miguel Palhas 8eecd9961e Merge issue #196: allow concurrent structured handshakes 2026-09-05 11:39:21 +01:00
Miguel Palhas ac2eb01bda fix(maestro): allow concurrent ACP handshakes 2026-09-05 11:29:44 +01:00
Miguel Palhas 1576380080 Merge issue #195: keep structured transcript pinned 2026-09-05 11:12:02 +01:00
Miguel Palhas 85feb433ae fix(web): follow composer and rail layout 2026-09-05 11:09:42 +01:00
Miguel Palhas a11933b7bf fix(web): keep structured transcript pinned 2026-09-05 11:00:15 +01:00
Miguel Palhas fa40eb7378 Merge issue #194: omit plan transcript rows 2026-09-05 10:49:28 +01:00
Miguel Palhas b680266990 docs(maestro): fix Plan row rendering claim in STRUCTURED-VIEW
Plan rows are omitted from the transcript in both clients and rendered
in a separate plan rail/status line, not as a transcript checklist.
2026-09-05 10:48:15 +01:00
Miguel Palhas ded1f050b2 fix(web): omit plan transcript rows 2026-09-05 10:41:00 +01:00
Miguel Palhas a58ebfed93 Merge issue #193: preserve expanded cards on reconnect 2026-09-05 10:32:48 +01:00
Miguel Palhas 31b429a840 fix(web): preserve expanded structured cards 2026-09-05 10:29:40 +01:00
Miguel Palhas 12bd68a2af Merge issue #192: reject structured PTY operations 2026-09-05 10:24:34 +01:00
Miguel Palhas f765d415d6 fix(maestro): reject structured PTY access 2026-09-05 10:20:41 +01:00
Miguel Palhas d3e3010245 Merge issue #143: offer structured view in spawn forms 2026-09-05 09:53:21 +01:00
Miguel Palhas 7a0dbbf7f2 feat(maestro): offer structured spawn view 2026-09-05 09:48:42 +01:00
Miguel Palhas 84fa4a3076 Merge issue #191: render accurate structured diffs 2026-09-05 09:36:32 +01:00
43 changed files with 6388 additions and 930 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');
+53 -2
View File
@@ -19,6 +19,8 @@ Environment knobs, all optional:
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
`session/new`
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
`session/new`
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
so the SIGTERM path is exercised)
@@ -34,6 +36,7 @@ Environment knobs, all optional:
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
FAKE_ACP_PROMPT_FAIL_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
@@ -76,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):
@@ -133,8 +171,11 @@ for raw in sys.stdin:
"authMethods": [],
}})
elif method == "session/new":
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
while gate and not os.path.exists(gate):
time.sleep(0.01)
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
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)
@@ -147,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());
}
+45 -1
View File
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
pub enum AttachError {
#[error("ws: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
#[error("daemon returned {status} ({code}): {message}")]
Rejected {
status: String,
code: String,
message: String,
},
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("termios: {0}")]
@@ -32,6 +38,21 @@ pub enum AttachError {
}
impl AttachError {
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
if let Some(body) = response.body().as_deref() {
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
return Self::Rejected {
status: response.status().to_string(),
code: error_body.error.code,
message: error_body.error.message,
};
}
}
}
Self::WebSocket(error)
}
/// True when the failure was an inability to reach the WS endpoint at the
/// transport level (connection refused), as opposed to an HTTP-level
/// rejection from a daemon that did answer.
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
/// byte that exits cleanly without killing the session.
#[allow(clippy::result_large_err)]
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
let (ws, _resp) = tokio_tungstenite::connect_async(url)
.await
.map_err(AttachError::from_websocket)?;
let (mut sink, mut stream) = ws.split();
let raw = RawTty::enable()?;
@@ -233,4 +256,25 @@ mod tests {
Err(AttachError::InvalidDetachKey(_))
));
}
#[test]
fn attach_surfaces_structured_handshake_errors() {
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
"not_terminal",
"session is not a terminal session",
))
.unwrap();
let response = tokio_tungstenite::tungstenite::http::Response::builder()
.status(409)
.body(Some(body))
.unwrap();
let error =
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
assert_eq!(
error.to_string(),
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
);
}
}
+26 -6
View File
@@ -6,7 +6,7 @@ use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, WorktreeSpec,
Session, SessionStatus, SessionView, WorktreeSpec,
};
use serde_json::Value;
@@ -179,8 +179,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
}
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
)
.ok();
for s in sorted {
@@ -190,6 +190,10 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let view = match s.view {
SessionView::Terminal => "terminal",
SessionView::Structured => "structured",
};
let activity = match s.activity {
ActivityState::Active => "active",
ActivityState::Idle => "idle",
@@ -207,8 +211,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let blocked = blocked_label(s);
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
s.id, status, activity, dir, command
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
s.id, status, view, activity, dir, command
)
.ok();
}
@@ -230,7 +234,7 @@ mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{ActivityState, Session, SessionStatus};
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
use uuid::Uuid;
use super::DaemonInfo;
@@ -286,6 +290,22 @@ mod tests {
assert!(s.contains("idle"), "{s}");
}
#[test]
fn table_exposes_terminal_and_structured_views() {
let terminal = session("/tmp", "bash", 1);
let mut structured = session("/tmp", "claude", 2);
structured.view = SessionView::Structured;
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
let out = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = out.lines().collect();
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
assert!(lines[header + 1].contains("terminal"), "{out}");
assert!(lines[header + 2].contains("structured"), "{out}");
}
#[test]
fn live_cwd_overrides_spawn_dir_in_table() {
let mut s = session("/home/naps/maestro", "bash", 1);
+53 -8
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, 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),
@@ -179,6 +181,7 @@ fn session_error_status(code: &str) -> StatusCode {
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
"not_terminal" => StatusCode::CONFLICT,
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
}
@@ -471,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)>,
@@ -534,12 +568,23 @@ async fn attach_ws(
State(s): State<LocalApiState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
));
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
Ok(subscription) => subscription,
Err(SessionError::NotFound(_)) => {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
));
}
Err(error) => {
let info = error.to_error_info();
return Err(error_response(
session_error_status(&info.code),
&info.code,
&info.message,
));
}
};
let manager = s.manager.clone();
Ok(ws.on_upgrade(move |socket| async move {
+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() })
File diff suppressed because it is too large Load Diff
+6
View File
@@ -263,9 +263,15 @@ pub struct LoggedEvent {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
pub enum PersistedEvent {
User {
row: TranscriptRow,
},
Approval {
row: TranscriptRow,
},
Turn {
row: TranscriptRow,
},
#[serde(skip)]
Notification(Box<SessionNotification>),
}
+296 -10
View File
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use 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();
@@ -950,6 +1051,191 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
assert_eq!(fake.log().matches("signal SIGTERM").count(), 1);
}
#[tokio::test]
async fn structured_handshakes_run_independently() {
let fx = DaemonFixture::new();
let blocked = Fake::new();
let ready = Fake::new();
let cwd = TempDir::new().unwrap();
let gate = cwd.path().join("release-first");
let mut blocked_env = blocked.env();
blocked_env.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate.to_string_lossy().into_owned(),
);
let first_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let first =
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
wait_for_async(Duration::from_secs(5), || async {
blocked.log().contains("recv session/new")
})
.await;
let second = tokio::time::timeout(
Duration::from_secs(5),
fx.manager.create(structured(cwd.path(), ready.env())),
)
.await
.expect("second handshake was blocked by the first")
.expect("second create");
assert_eq!(second.status, SessionStatus::Running);
assert!(!first.is_finished(), "the gate must still block the first");
std::fs::write(&gate, b"release").unwrap();
let first = first.await.expect("first task").expect("first create");
fx.manager.kill(first.id, false).await.expect("kill first");
fx.manager
.kill(second.id, false)
.await
.expect("kill second");
}
#[tokio::test]
async fn shutdown_waits_for_every_admitted_structured_create() {
let fx = DaemonFixture::new();
let a = Fake::new();
let b = Fake::new();
let late = Fake::new();
let cwd = TempDir::new().unwrap();
let gate_a = cwd.path().join("release-a");
let gate_b = cwd.path().join("release-b");
let mut env_a = a.env();
let mut env_b = b.env();
env_a.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate_a.to_string_lossy().into_owned(),
);
env_b.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate_b.to_string_lossy().into_owned(),
);
let create_a = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
};
let create_b = {
let manager = fx.manager.clone();
let cwd = cwd.path().to_path_buf();
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
};
wait_for_async(Duration::from_secs(5), || async {
a.log().contains("recv session/new") && b.log().contains("recv session/new")
})
.await;
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
wait_for_async(Duration::from_secs(5), || async {
matches!(
fx.manager.create(structured(cwd.path(), late.env())).await,
Err(maestro_daemon::SessionError::ShuttingDown)
)
})
.await;
assert!(!shutdown.is_finished());
std::fs::write(&gate_a, b"release").unwrap();
let session_a = create_a.await.expect("create a task").expect("create a");
assert!(
!shutdown.is_finished(),
"shutdown ignored the second create"
);
std::fs::write(&gate_b, b"release").unwrap();
let session_b = create_b.await.expect("create b task").expect("create b");
shutdown.await.expect("shutdown task");
wait_for_exit(a.pid()).await;
wait_for_exit(b.pid()).await;
assert_eq!(
fx.manager.status(session_a.id).await,
Some(SessionStatus::Dead)
);
assert_eq!(
fx.manager.status(session_b.id).await,
Some(SessionStatus::Dead)
);
}
#[tokio::test]
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let gate = repo.path().join("release-create");
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
env.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate.to_string_lossy().into_owned(),
);
let request = CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
env,
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "cancelled-create".into(),
create_branch: true,
}),
};
let create = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.create(request).await })
};
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let id = fake
.log()
.lines()
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.expect("session id in adapter log");
let pid = fake.pid();
create.abort();
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
assert!(create.await.unwrap_err().is_cancelled());
tokio::time::timeout(Duration::from_secs(5), shutdown)
.await
.expect("cancelled admission kept shutdown waiting")
.expect("shutdown task");
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(5), || async {
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
&& !repo.path().join("cancelled-create").exists()
})
.await;
assert!(fx.manager.snapshot().await.is_empty());
let id = id.to_string();
assert!(
std::fs::read_dir(fx.sidecar_dir())
.into_iter()
.flatten()
.all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(&id)),
"cancelled create left a sidecar artifact"
);
}
#[tokio::test]
async fn shutdown_drains_final_notifications_to_the_log() {
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."}}}
+33
View File
@@ -1311,6 +1311,39 @@ async fn attach_ws_streams_pty_bytes() {
assert!(saw_hello, "PTY did not echo through the attach WS");
}
#[tokio::test]
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let response = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
.json(&maestro_protocol::SendInputRequest {
text: "ignored".into(),
submit: false,
})
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = response.json().await.unwrap();
assert_eq!(error.error.code, "not_terminal");
assert_eq!(error.error.message, "session is not a terminal session");
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
.await
.expect_err("structured session must not upgrade to a PTY socket");
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
panic!("expected an HTTP handshake rejection, got {error}");
};
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let body = response.body().as_deref().expect("ApiError response body");
let error: ApiError = serde_json::from_slice(body).unwrap();
assert_eq!(error.error.code, "not_terminal");
assert_eq!(error.error.message, "session is not a terminal session");
}
#[tokio::test]
async fn attach_ws_for_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
+596 -63
View File
@@ -166,45 +166,32 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur
}
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
/// `npx`, `pnpm dlx`, …) are looked through, and quoting/escaping is honoured
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
/// substitution, an unterminated quote) falls back to the naive first word
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
match resolve(command) {
Some(r) => match r.words.get(r.program_index) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there
// rather than rendering an empty fragment.
None => command.trim().to_string(),
},
None => legacy_first_word(command),
}
}
/// Program name (already basenamed and lowercased) -> the tool id its mark
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
/// recognition is deliberately narrow, so an unrecognised program has no tool.
/// `claude-code-acp` — the daemon's actual structured-session child — is
/// deliberately absent: the daemon launches it directly and ignores
/// `req.command`, so it is never itself a valid user-facing launch form.
const BY_PROGRAM: [(&str, &str); 17] = [
("claude", "claude"),
("claude-code", "claude"),
@@ -226,29 +213,491 @@ const BY_PROGRAM: [(&str, &str); 17] = [
];
/// The tool id a command line's icon stands for (`claude`, `codex`,
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
/// the same tools the web's `splitCommand().tool` does.
/// `opencode`, ...), or `None` when the program is not one we have a mark for
/// — including a command line [`resolve`] would only guess at. The command
/// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
/// tools the web's `splitCommand().tool` does.
pub fn command_tool(command: &str) -> Option<&'static str> {
let program = command_program(command).to_lowercase();
let r = resolve(command)?;
let program = basename(r.words.get(r.program_index)?).to_lowercase();
BY_PROGRAM
.iter()
.find(|(name, _)| *name == program)
.map(|(_, tool)| *tool)
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
/// of the word that is the actual program (past every assignment/wrapper it
/// resolved through).
struct Resolution {
words: Vec<String>,
program_index: usize,
}
/// Tokenize `command` and walk past leading `FOO=bar` assignments and known
/// wrapper forms (interleaved in any order/count) to find the program. An
/// assignment run is only consumed at the very start of the line or right
/// after a wrapper whose own semantics accept one there ([`Wrapper::accepts_assignment`]) —
/// `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
/// skipping to `claude`, since `command`/`nohup` just exec their next argument
/// as the program, they don't setenv from an assignment-shaped one (#199).
/// `None` when [`tokenize`] can't safely make sense of the line at all.
fn resolve(command: &str) -> Option<Resolution> {
let words = tokenize(command)?;
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
let mut i = 0;
let mut assignment_allowed = true;
loop {
if assignment_allowed {
while matches!(words.get(i), Some(word) if is_env_assignment(word)) {
i += 1;
}
}
let Some(word) = words.get(i) else { break };
let wrapper = basename(&word.text).to_lowercase();
let Some(w) = wrapper_resolver(&wrapper) else {
break;
};
match (w.resolve)(&refs, i) {
Some(next) if next > i => {
i = next;
assignment_allowed = w.accepts_assignment;
}
// Not a form of the wrapper we recognise (an unknown option, or
// — for `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it):
// the wrapper word itself is the program, not a launcher.
_ => break,
}
}
Some(Resolution {
words: words.into_iter().map(|w| w.text).collect(),
program_index: i,
})
}
/// The naive "first whitespace word, basenamed" reading used only when
/// [`tokenize`] rejects the line outright — still something sane to show,
/// never a wrapper/tool match to act on.
fn legacy_first_word(command: &str) -> String {
match command.split_whitespace().next() {
Some(word) => basename(word).to_string(),
None => command.trim().to_string(),
}
}
/// One token from [`tokenize`]: its dequoted text, and — when both the name
/// and `=` were entirely unquoted/unescaped in the source — the byte length
/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
/// makes the word an executable named `FOO=1`; quoting/escaping only the value
/// preserves the assignment, matching Bash's assignment-word grammar (#199).
#[derive(Debug, Clone, PartialEq, Eq)]
struct Word {
text: String,
plain_eq: Option<usize>,
}
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
/// `None` the moment it needs real shell grammar this resolver doesn't
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
/// separator, like `;` — #199), or an unterminated quote/escape. Never
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
/// backslash immediately before a newline, quoted or not, is a line
/// continuation: removed entirely rather than embedding the newline or
/// ending the word (#199).
fn tokenize(command: &str) -> Option<Vec<Word>> {
let mut words = Vec::new();
let mut current = String::new();
let mut has_current = false;
let mut plain_eq: Option<usize> = None;
let mut assignment_name_is_plain = true;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
match c {
' ' | '\t' | '\r' => {
if has_current {
words.push(Word {
text: std::mem::take(&mut current),
plain_eq: plain_eq.take(),
});
has_current = false;
assignment_name_is_plain = true;
}
}
'\n' => return None,
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
'$' if chars.peek() == Some(&'(') => return None,
'\'' => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
loop {
match chars.next() {
Some('\'') => break,
Some(c2) => current.push(c2),
None => return None, // unterminated single quote
}
}
}
'"' => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
current.push('\\');
current.push(c2);
}
None => return None, // trailing backslash
},
Some(c2) => current.push(c2),
None => return None, // unterminated double quote
}
}
}
'\\' => match chars.next() {
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
current.push(c2);
}
None => return None, // trailing backslash
},
other => {
has_current = true;
if other == '=' && plain_eq.is_none() && assignment_name_is_plain {
plain_eq = Some(current.len());
}
current.push(other);
}
}
}
if has_current {
words.push(Word {
text: current,
plain_eq,
});
}
Some(words)
}
/// A wrapper's known `-`/`--` options: which take no value, and which
/// consume either a following word or a `--flag=value` as theirs. A
/// `-`-prefixed word this table doesn't know halts resolution rather than
/// guessing past it (#199) — callers read that as [`OptionTable::consume`]
/// returning `None`.
struct OptionTable {
flags: &'static [&'static str],
valued: &'static [&'static str],
/// Whether this wrapper's short options may be combined (`-iv`) or take
/// an attached value (`-uroot`), the way real getopt-based tools do
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
/// `dlx`) don't get this — guessing at bundling they don't actually
/// support would resolve through options they'd reject outright.
bundles_short_options: bool,
}
impl OptionTable {
/// Consume this table's options starting at `words[i]`, returning the
/// index just past them (which may equal `i` if there are none) — or
/// `None` on an option this table doesn't recognise, or a valued option
/// missing its operand.
fn consume(&self, words: &[&str], mut i: usize) -> Option<usize> {
loop {
let Some(&word) = words.get(i) else {
return Some(i);
};
if word == "--" {
return Some(i + 1);
}
if word == "-" || !word.starts_with('-') {
return Some(i);
}
if let Some((flag, value)) = word.split_once('=') {
return if self.valued.contains(&flag) && !value.is_empty() {
i += 1;
continue;
} else {
None
};
}
if self.flags.contains(&word) {
i += 1;
continue;
}
if self.valued.contains(&word) {
let value = words.get(i + 1)?;
if value.is_empty() || value.starts_with('-') {
return None;
}
i += 2;
continue;
}
if self.bundles_short_options
&& !word.starts_with("--")
&& word.len() > 2
&& self.matches_short_cluster(word)
{
i += 1;
continue;
}
return None;
}
}
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
/// option with its value attached) — getopt's traditional short-option
/// forms. `false` the moment a character isn't a short option this
/// table knows, or a valued one ends the word with no attached value
/// left to be its operand (#199: rejected rather than guessed to come
/// from the next word instead).
fn matches_short_cluster(&self, word: &str) -> bool {
if !word.is_ascii() {
return false;
}
let bytes = word.as_bytes();
let mut buf = [b'-', 0];
let mut idx = 1;
while idx < bytes.len() {
buf[1] = bytes[idx];
let short = std::str::from_utf8(&buf).expect("ascii buffer");
if self.flags.contains(&short) {
idx += 1;
continue;
}
if self.valued.contains(&short) {
return idx + 1 < bytes.len();
}
return false;
}
true
}
}
const NO_OPTIONS: OptionTable = OptionTable {
flags: &[],
valued: &[],
bundles_short_options: false,
};
const ENV_OPTS: OptionTable = OptionTable {
flags: &[
"-i",
"--ignore-environment",
"-0",
"--null",
"-v",
"--debug",
],
valued: &["-u", "--unset", "-C", "--chdir"],
bundles_short_options: true,
};
const SUDO_OPTS: OptionTable = OptionTable {
flags: &[
"-i",
"--login",
"-n",
"--non-interactive",
"-E",
"--preserve-env",
"-H",
"--set-home",
"-A",
"--askpass",
"-b",
"--background",
"-k",
"--reset-timestamp",
"-K",
"--remove-timestamp",
"-S",
"--stdin",
],
valued: &["-u", "--user", "-g", "--group"],
bundles_short_options: true,
};
/// `-v`/`-V` only print what would run — they never execute it — so
/// resolving through them would claim a program runs that `command` never
/// launches (#199). Only `-p`, which still executes normally, is safe to
/// look through.
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
flags: &["-p"],
valued: &[],
bundles_short_options: true,
};
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
/// `/usr/bin/time`, a different program a bare `time` word doesn't
/// necessarily name. Assuming them here would be guessing which `time`
/// resolves (#199).
fn resolve_time(words: &[&str], i: usize) -> Option<usize> {
let mut next = i + 1;
if words.get(next) == Some(&"-p") {
next += 1;
}
if words.get(next) == Some(&"--") {
next += 1;
}
match words.get(next) {
Some(word) if word.starts_with('-') => None,
_ => Some(next),
}
}
/// `-c`/`--call` embeds a whole shell command string rather than naming the
/// program positionally — resolving past it would mean guessing at a
/// program buried inside that string, so it's left unrecognised and
/// resolution stops at `npx` (#199).
const NPX_OPTS: OptionTable = OptionTable {
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
valued: &["-p", "--package", "--shell"],
bundles_short_options: false,
};
const PNPM_OPTS: OptionTable = OptionTable {
flags: &[
"--silent",
"-s",
"--stream",
"--aggregate-output",
"--no-progress",
],
// `--package`/`--package=value` is documented in this pre-`dlx`
// position (`pnpm --package=x dlx y`), alongside the identical flag
// `dlx` itself also accepts (`PNPM_DLX_OPTS`).
valued: &["-C", "--dir", "--filter", "--package"],
bundles_short_options: false,
};
const YARN_OPTS: OptionTable = OptionTable {
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
valued: &["--cwd"],
bundles_short_options: false,
};
const PNPM_DLX_OPTS: OptionTable = OptionTable {
flags: &["-s", "--silent"],
valued: &["--package"],
bundles_short_options: false,
};
const YARN_DLX_OPTS: OptionTable = OptionTable {
flags: &["-q", "--quiet"],
valued: &["-p", "--package"],
bundles_short_options: false,
};
const BUN_X_OPTS: OptionTable = OptionTable {
flags: &["--bun", "--no-install", "--verbose", "--silent"],
valued: &["-p", "--package"],
bundles_short_options: false,
};
/// A resolver for one wrapper form: given the tokenized command and the
/// index of the wrapper word itself, the index of the word after it (past
/// its options and any subcommand it requires), or `None` if this occurrence
/// isn't a form we resolve through.
type WrapperResolver = fn(&[&str], usize) -> Option<usize>;
/// One recognised wrapper form: how to walk past it, and whether an
/// assignment word is a valid follow-on once it's been walked past.
struct Wrapper {
resolve: WrapperResolver,
/// `true` only for wrappers that themselves interpret a following
/// `FOO=bar` as an environment assignment for the command they launch
/// (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
/// grammar for a brand new simple command (`time`). Every other wrapper
/// here is a plain executable that execs its next argument as the
/// program outright — an assignment-shaped one names a program literally
/// called `FOO=1`, not an env var (#199).
accepts_assignment: bool,
}
fn wrapper_resolver(name: &str) -> Option<Wrapper> {
let (resolve, accepts_assignment): (WrapperResolver, bool) = match name {
"env" => (|words, i| ENV_OPTS.consume(words, i + 1), true),
"sudo" => (|words, i| SUDO_OPTS.consume(words, i + 1), true),
"command" => (|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1), false),
"nohup" => (|words, i| NO_OPTIONS.consume(words, i + 1), false),
"time" => (resolve_time, true),
"npx" => (|words, i| NPX_OPTS.consume(words, i + 1), false),
"pnpm" => (
|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i),
false,
),
"yarn" => (
|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i),
false,
),
"bun" => (resolve_bun, false),
"bunx" => (|words, i| BUN_X_OPTS.consume(words, i + 1), false),
"uvx" => (|_words, i| Some(i + 1), false),
_ => return None,
};
Some(Wrapper {
resolve,
accepts_assignment,
})
}
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
/// — without it, the next word is a `package.json` script name, not a
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
/// itself takes launcher-specific options too, consumed before the executable.
fn resolve_dlx(
opts: &OptionTable,
dlx_opts: &OptionTable,
words: &[&str],
i: usize,
) -> Option<usize> {
let j = opts.consume(words, i + 1)?;
if words.get(j) != Some(&"dlx") {
return None;
}
dlx_opts.consume(words, j + 1)
}
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
/// the executable.
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
if words.get(i + 1) != Some(&"x") {
return None;
}
BUN_X_OPTS.consume(words, i + 2)
}
/// `FOO=bar`, the shell's own prefix for one command's environment —
/// recognised only when the `=` came from plain, unquoted/unescaped source
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
/// an assignment.
fn is_env_assignment(word: &Word) -> bool {
let Some(eq) = word.plain_eq else {
return false;
};
let name = &word.text[..eq];
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn basename(word: &str) -> &str {
@@ -497,38 +946,122 @@ mod tests {
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx");
}
/// The #199 resolver contract: every case in the shared parity fixture,
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
/// resolver regression in either language fails its own suite.
#[test]
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
fn resolves_the_shared_command_resolution_fixture() {
#[derive(serde::Deserialize)]
struct Case {
command: String,
program: String,
tool: Option<String>,
}
#[derive(serde::Deserialize)]
struct Fixture {
cases: Vec<Case>,
}
let raw = include_str!("../../../fixtures/command-resolution.json");
let fixture: Fixture = serde_json::from_str(raw).expect("valid fixture JSON");
assert!(!fixture.cases.is_empty());
for case in &fixture.cases {
assert_eq!(
command_program(&case.command),
case.program,
"program for {:?}",
case.command
);
assert_eq!(
command_tool(&case.command),
case.tool.as_deref(),
"tool for {:?}",
case.command
);
}
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
fn word(text: &str) -> Word {
Word {
text: text.into(),
plain_eq: None,
}
}
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
/// tools the web's `splitCommand().tool` does.
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
#[test]
fn command_tool_names_the_mark_the_card_shows() {
assert_eq!(command_tool("claude --resume"), Some("claude"));
assert_eq!(command_tool("npx opencode"), Some("opencode"));
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
assert_eq!(command_tool("CLAUDE"), Some("claude"));
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
assert_eq!(
tokenize("claude --resume"),
Some(vec![word("claude"), word("--resume")])
);
assert_eq!(
tokenize("\"/opt/my agent/claude\" --resume"),
Some(vec![word("/opt/my agent/claude"), word("--resume")])
);
assert_eq!(
tokenize("FOO=\"bar baz\" claude"),
Some(vec![
Word {
text: "FOO=bar baz".into(),
plain_eq: Some(3),
},
word("claude"),
])
);
assert_eq!(
tokenize("claude --name=$HOME"),
Some(vec![
word("claude"),
Word {
text: "--name=$HOME".into(),
plain_eq: Some(6),
},
]),
"a bare $ is kept literal, never expanded"
);
}
/// Narrow on purpose: an unrecognised program has no mark, so the
/// candidate has no `tool` field to match rather than a wrong one.
/// Real shell grammar this resolver doesn't attempt — pipelines, list
/// operators, subshells/substitution, an unescaped newline, an
/// unterminated quote — is rejected outright rather than guessed
/// through (#199).
#[test]
fn command_tool_is_absent_for_an_unrecognised_program() {
assert_eq!(command_tool("hx src/main.rs"), None);
assert_eq!(command_tool(""), None);
fn tokenize_rejects_ambiguous_shell_grammar() {
for command in [
"claude | tee log.txt",
"claude; echo done",
"claude && echo done",
"echo hi $(claude)",
"claude \"unterminated",
"claude 'unterminated",
"claude \\",
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
] {
assert_eq!(tokenize(command), None, "{command:?}");
}
}
/// A backslash immediately before a newline is a line continuation:
/// removed entirely, joining what it split rather than embedding a
/// literal newline or ending the command (#199).
#[test]
fn tokenize_treats_backslash_newline_as_a_continuation() {
assert_eq!(
tokenize("clau\\\nde --resume"),
Some(vec![word("claude"), word("--resume")])
);
}
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
/// quoting or escaping the `=` itself disqualifies the word, even
/// though the dequoted text looks identical (#199).
#[test]
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
}
}
+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";
}
+263 -11
View File
@@ -41,8 +41,10 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use maestro_protocol::display::{abbreviate_dir, command_tool};
use maestro_protocol::{
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
@@ -86,9 +88,11 @@ pub enum DraftField {
/// apply immediately, mirroring the web form's per-click choice.
WorktreeChoice,
Name,
/// Disclosure collapsing the worktree/branch controls below (#93).
/// 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.
StructuredToggle,
/// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle,
/// New-vs-existing branch source; Left / Right switches.
@@ -251,6 +255,9 @@ pub struct DraftPane {
/// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target.
pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Off by default and supported only
/// when the resolved command is Claude Code.
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.
pub base_repo: Option<PathBuf>,
@@ -311,6 +318,7 @@ impl Default for DraftPane {
name_touched: false,
worktree_enabled: true,
advanced_collapsed: true,
structured_view: false,
base_repo: None,
probed_cwd: None,
probe_seq: 0,
@@ -348,6 +356,7 @@ impl DraftPane {
.position(|p| !p.command.is_empty())
.unwrap_or(0);
self.apply_seed_command();
self.clear_unsupported_structured_view();
}
/// Land a seeded command on the tile that runs it: its own preset when the
@@ -663,6 +672,22 @@ impl DraftPane {
}
}
fn supports_structured_view(&self) -> bool {
command_tool(self.command()) == Some("claude")
}
fn clear_unsupported_structured_view(&mut self) {
if !self.supports_structured_view() {
self.structured_view = false;
}
}
pub fn toggle_structured_view(&mut self) {
if self.supports_structured_view() {
self.structured_view = !self.structured_view;
}
}
/// The chosen working directory: a picked project row, else the raw path
/// input when it holds something.
pub fn cwd(&self) -> Option<PathBuf> {
@@ -694,7 +719,11 @@ impl DraftPane {
}
let worktree = self.build_worktree()?;
Ok(CreateSessionRequest {
view: Default::default(),
view: if self.structured_view && self.supports_structured_view() {
SessionView::Structured
} else {
SessionView::Terminal
},
cwd,
command: command.to_string(),
env: Default::default(),
@@ -754,6 +783,9 @@ impl DraftPane {
order.push(DraftField::Name);
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
if self.supports_structured_view() {
order.push(DraftField::StructuredToggle);
}
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
@@ -795,6 +827,7 @@ impl DraftPane {
}
self.selected_preset =
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
self.clear_unsupported_structured_view();
}
/// Step-one boxes surviving the type-to-filter, in display order. A
@@ -1001,9 +1034,9 @@ impl DraftPane {
// Two fields keep their own Enter instead: `DirPicker`, whose
// highlighted row isn't the picked one until a first Enter chooses
// it (the next Enter, now that it's active, falls through to
// submit); and the disclosure/toggle pair (`Advanced`,
// `WorktreeToggle`), which activate on Enter like a button rather
// than submitting, same as the web form's own `<button>` controls.
// submit); and the disclosure/checkbox fields, which activate on
// Enter like buttons rather than submitting, matching the web
// form controls.
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
let is_other_row = self.selection() >= self.visible_groups().len();
let choose_only =
@@ -1023,6 +1056,10 @@ impl DraftPane {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
self.toggle_structured_view();
DraftInputResult::Consumed
}
InputKey::Enter => DraftInputResult::Submit,
InputKey::Char {
c: ' ',
@@ -1040,6 +1077,14 @@ impl DraftPane {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Char {
c: ' ',
ctrl: false,
..
} if self.focused_field == DraftField::StructuredToggle => {
self.toggle_structured_view();
DraftInputResult::Consumed
}
InputKey::Up if self.focused_field == DraftField::DirPicker => {
self.move_selection(-1);
DraftInputResult::Consumed
@@ -1083,7 +1128,10 @@ impl DraftPane {
other => {
match self.focused_field {
DraftField::Preset | DraftField::Submit => {}
DraftField::CustomCommand => self.custom_command.handle_key(other),
DraftField::CustomCommand => {
self.custom_command.handle_key(other);
self.clear_unsupported_structured_view();
}
// Typing filters the list; the highlight resets to the top
// so a narrowed search always starts at its best match.
DraftField::DirPicker => {
@@ -1121,9 +1169,9 @@ impl DraftPane {
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
// The worktree choice step, the worktree toggle and its body all stay
// behind the "Advanced options" disclosure (#93): collapsed hides
// them, regardless of what they'd otherwise show.
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
// Optional view and worktree controls stay behind the "Advanced
// options" disclosure (#93), regardless of what they'd otherwise show.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice =
@@ -1142,6 +1190,7 @@ impl DraftPane {
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
+ u16::from(show_custom)
+ u16::from(show_raw)
+ u16::from(show_structured)
+ u16::from(show_wt_toggle)
+ u16::from(show_wt_choice) * wt_choice_capacity
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
@@ -1161,6 +1210,9 @@ impl DraftPane {
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
if show_structured {
constraints.push(Constraint::Length(1));
}
if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
@@ -1359,6 +1411,24 @@ impl DraftPane {
advanced_row,
);
if show_structured {
let row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "view:")),
Span::styled(
if self.structured_view {
"[x] structured view"
} else {
"[ ] structured view"
},
field_style(DraftField::StructuredToggle),
),
])),
row,
);
}
if show_wt_choice {
// Step two (#96 rule 3): "+ new" first, then the repository's
// worktrees newest-first.
@@ -1817,6 +1887,188 @@ mod tests {
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
}
#[test]
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
let mut draft = DraftPane {
advanced_collapsed: false,
..DraftPane::default()
};
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));
assert!(rendered.contains("[ ] structured view"), "{rendered}");
draft.toggle_structured_view();
assert!(draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "opencode");
assert!(
!draft.structured_view,
"a hidden value must not stay checked"
);
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(!rendered.contains("structured view"), "{rendered}");
}
#[test]
fn structured_view_request_is_guarded_against_a_stale_command() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
structured_view: true,
..DraftPane::default()
};
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
draft.selected_preset = 1;
assert_eq!(draft.command(), "opencode");
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// A custom command typed into the free-text field, run through the same
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
/// env prefix, or a wrapper still resolves to Claude, so the toggle stays
/// offered and the request carries `Structured`; an unrelated program
/// does not.
#[test]
fn structured_view_follows_command_resolution_for_custom_commands() {
let cases: &[(&str, bool)] = &[
("claude", true),
("claude --resume", true),
("CLAUDE", true),
("/usr/local/bin/claude -p hi", true),
("./bin/claude", true),
("FOO=1 BAR=2 claude", true),
("npx claude-code", true),
("npx --yes claude-code", true),
("sudo claude", true),
("sudo -u root claude", true),
("env -i claude", true),
("nohup claude", true),
("time claude", true),
("command claude", true),
("pnpm --silent dlx claude-code", true),
("yarn --silent dlx claude-code", true),
("bun x claude-code", true),
("\"/opt/my agent/claude\" --resume", true),
("bash", false),
("opencode", false),
("/usr/local/bin/bash -l", false),
// A package-manager wrapper without `dlx`/`x` runs a
// `package.json` script, not the package (#199) — must not be
// mistaken for a Claude launch.
("pnpm claude", false),
("yarn claude", false),
// The daemon's structured-session child, never a valid
// user-facing launch form (#199).
("claude-code-acp", false),
];
for &(command, supported) in cases {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value(command);
assert_eq!(
draft.field_order().contains(&DraftField::StructuredToggle),
supported,
"toggle visibility for {command:?}"
);
draft.toggle_structured_view();
assert_eq!(
draft.structured_view, supported,
"toggle takes effect for {command:?}"
);
let view = draft.build_request().unwrap().view;
assert_eq!(
view,
if supported {
SessionView::Structured
} else {
SessionView::Terminal
},
"request view for {command:?}"
);
}
}
/// A configured preset (the spawn-form "alias" for a command) is gated
/// the same way as free text: a preset whose command resolves to Claude
/// through a wrapper still offers structured view.
#[test]
fn structured_view_follows_command_resolution_for_presets() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude-wrapped".into(),
label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(),
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
},
]);
assert_eq!(draft.command(), "npx claude-code --resume");
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
draft.toggle_structured_view();
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching to a preset that does not resolve to Claude clears the
// stale selection instead of letting an invalid request through.
draft.cycle_preset(1);
assert_eq!(draft.command(), "aider");
assert!(!draft.structured_view, "stale selection must clear");
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from Claude 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() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
focused_field: DraftField::CustomCommand,
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("claude");
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");
for _ in 0.."claude -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"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
#[test]
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
let mut draft = DraftPane {
+301
View File
@@ -90,6 +90,7 @@ impl StructuredPane {
let viewport = usize::from(area.height);
let max_scroll = lines.len().saturating_sub(viewport);
state.scroll = reconcile_scroll(&state.scroll, &state.line_rows, &lines);
if state.follow_selection {
if let Some(selected) = state.selected.as_deref() {
let first = lines
@@ -138,6 +139,7 @@ impl StructuredPane {
Rect::new(area.x, area.y + offset as u16, area.width, 1),
);
}
state.line_rows = lines.iter().map(|line| line.row_id.clone()).collect();
}
fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
@@ -214,6 +216,96 @@ fn error_line(text: &str) -> VisualLine {
}
}
/// Translate `scroll` — a raw line index into `old_rows` (the previous
/// render's row-per-line layout) — into the equivalent index into `lines`
/// (this render's freshly rebuilt layout), anchored on row identity rather
/// than raw position. A row's wrapped lines above or intersecting the
/// viewport growing, shrinking, or moving index therefore shifts `scroll`
/// along with it instead of leaving the viewport looking at different
/// content. Falls back to the nearest surviving neighbour row when the
/// anchor row itself is gone, and to the raw index (clamped) when nothing
/// nearby survived either.
fn reconcile_scroll(scroll: &usize, old_rows: &[Option<String>], lines: &[VisualLine]) -> usize {
if old_rows.is_empty() {
return *scroll;
}
let old_index = (*scroll).min(old_rows.len() - 1);
let anchor = old_rows[old_index].clone();
match anchor {
Some(id) => match find_row_block(lines, &id) {
Some((start, len)) => {
start + (old_index - block_start(old_rows, old_index)).min(len - 1)
}
None => fallback_after_removal(old_rows, lines, old_index),
},
None => {
// Untagged tail lines (connecting/dead/error) carry no row identity;
// approximate by preserving distance from the end of the transcript.
let distance_from_end = old_rows.len() - old_index;
lines.len().saturating_sub(distance_from_end)
}
}
}
/// Index of the first line belonging to `id`, and how many lines it spans.
/// A row's wrapped lines are always contiguous in `lines` — `build_lines`
/// emits them as one run per row — so a single forward scan suffices.
fn find_row_block(lines: &[VisualLine], id: &str) -> Option<(usize, usize)> {
let start = lines
.iter()
.position(|line| line.row_id.as_deref() == Some(id))?;
let mut end = start;
while end + 1 < lines.len() && lines[end + 1].row_id.as_deref() == Some(id) {
end += 1;
}
Some((start, end - start + 1))
}
/// First index of the contiguous run that `index` sits in within `rows`.
fn block_start(rows: &[Option<String>], index: usize) -> usize {
let mut start = index;
while start > 0 && rows[start - 1] == rows[index] {
start -= 1;
}
start
}
/// The anchor row is gone (removed, or replaced wholesale by a fresh
/// snapshot). Search outward from its old position, nearest first, for a
/// still-live neighbour row and land on its first line; if none of the old
/// layout's rows survived, fall back to the raw old index clamped into the
/// new layout.
fn fallback_after_removal(
old_rows: &[Option<String>],
lines: &[VisualLine],
old_index: usize,
) -> usize {
let len = old_rows.len();
let mut distance = 1usize;
loop {
if distance > old_index && old_index + distance >= len {
return old_index.min(lines.len().saturating_sub(1));
}
if distance <= old_index {
if let Some((start, _)) = old_rows[old_index - distance]
.as_ref()
.and_then(|id| find_row_block(lines, id))
{
return start;
}
}
if old_index + distance < len {
if let Some((start, _)) = old_rows[old_index + distance]
.as_ref()
.and_then(|id| find_row_block(lines, id))
{
return start;
}
}
distance += 1;
}
}
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
let mut out = Vec::new();
for row in &state.rows {
@@ -1272,4 +1364,213 @@ mod tests {
assert!(text.contains("adapter exited"), "{text}");
assert!(text.contains("request failed"), "{text}");
}
fn test_pane() -> StructuredPane {
StructuredPane {
handle: StructuredHandle::test_handle(StructuredState::default()),
focused: true,
mode: Mode::Normal,
session_status: SessionStatus::Running,
activity_detail: None,
}
}
fn numbered_rows(state: &mut StructuredState, count: usize) {
for n in 0..count {
state.rows.push(row(
&format!("r{n}"),
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":format!("line {n}")}}),
));
}
}
fn grow_row(state: &mut StructuredState, id: &str) {
let index = state.rows.iter().position(|r| r.id == id).unwrap();
state.rows[index] = row(
id,
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"a very long streaming reply that keeps growing and will wrap across several lines now"}}),
);
}
fn render_transcript_lines(
pane: &StructuredPane,
state: &mut StructuredState,
width: u16,
height: u16,
) -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| pane.render_transcript(frame, frame.area(), state))
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn row_growing_above_the_viewport_keeps_the_visual_anchor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
grow_row(&mut state, "r3");
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after[0].contains("line 10"),
"a row growing above the viewport must not shift what's on screen: {after:?}"
);
}
#[test]
fn appending_a_row_below_the_viewport_keeps_the_visual_anchor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
state.rows.push(row(
"r20",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"line 20"}}),
));
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert_eq!(before, after, "growth below the viewport must not move it");
}
#[test]
fn removing_a_row_above_the_viewport_keeps_the_visual_anchor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
state.rows.retain(|r| r.id != "r3");
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after[0].contains("line 10"),
"a row removed above the viewport must not shift what's on screen: {after:?}"
);
}
#[test]
fn removing_the_anchor_row_falls_back_to_the_nearest_surviving_neighbor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before[0].contains("line 10"), "{before:?}");
state.rows.retain(|r| r.id != "r10");
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after[0].contains("line 9"),
"removing the anchor row should fall back to its nearest surviving neighbor: {after:?}"
);
}
#[test]
fn patching_a_row_above_the_viewport_keeps_the_visual_anchor() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
render_transcript_lines(&pane, &mut state, 30, 5);
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
// viewport expanding into a multi-line diff.
let index = state.rows.iter().position(|r| r.id == "r5").unwrap();
state.rows[index] = row(
"r5",
TranscriptRowKind::ToolDiff,
json!({"title":"Edit(x)","content":[{"type":"diff","path":"x","oldText":"a","newText":"a\nb\nc\nd"}]}),
);
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after[0].contains("line 10"),
"a patched row above the viewport must not shift what's on screen: {after:?}"
);
}
#[test]
fn narrow_width_reconciles_without_panicking() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = false;
numbered_rows(&mut state, 20);
state.scroll = 10;
render_transcript_lines(&pane, &mut state, 1, 3);
grow_row(&mut state, "r3");
render_transcript_lines(&pane, &mut state, 1, 3);
assert!(state.scroll <= state.line_rows.len());
}
#[test]
fn follow_selection_without_a_selection_stays_pinned_to_the_live_edge() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = true;
state.selected = None;
numbered_rows(&mut state, 20);
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(before.last().unwrap().contains("line 19"), "{before:?}");
state.rows.push(row(
"r20",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"line 20"}}),
));
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after.last().unwrap().contains("line 20"),
"bottom-follow must track the live edge as rows are appended: {after:?}"
);
}
#[test]
fn follow_selection_keeps_the_selected_row_visible_when_a_row_above_it_grows() {
let pane = test_pane();
let mut state = StructuredState::default();
state.follow_selection = true;
numbered_rows(&mut state, 20);
state.selected = Some("r15".into());
let before = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
before.iter().any(|line| line.contains("line 15")),
"{before:?}"
);
grow_row(&mut state, "r3");
let after = render_transcript_lines(&pane, &mut state, 30, 5);
assert!(
after.iter().any(|line| line.contains("line 15")),
"the selected row must stay visible while follow_selection is on: {after:?}"
);
}
}
@@ -25,6 +25,12 @@ pub struct StructuredState {
pub selected: Option<String>,
pub expanded: HashSet<String>,
pub scroll: usize,
/// Row id per rendered line from the last render pass, parallel to that
/// pass's `lines`. Lets the next render translate `scroll` — a raw index
/// into that now-stale layout — back into a row identity before mapping
/// it onto the freshly rebuilt layout, so growth/shrinkage above the
/// viewport shifts indices without shifting what's on screen.
pub(crate) line_rows: Vec<Option<String>>,
pub follow_selection: bool,
pub composer: TextInput,
pub error: Option<String>,
@@ -42,6 +48,7 @@ impl Default for StructuredState {
selected: None,
expanded: HashSet::new(),
scroll: 0,
line_rows: Vec::new(),
follow_selection: true,
composer: TextInput::default(),
error: None,
+68 -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 set on the spawn request), then
the daemon's `PATH`, 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.
@@ -162,9 +169,24 @@ choices, not daemon ones.
**A client decides:**
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
diff view for `ToolDiff`, a tool card for `ToolCall`, a checklist for
`Plan`, and a floor presentation for `Notice`.
- How `responding` vs. `thinking` are distinguished visually, if at all.
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; 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.
@@ -195,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
@@ -245,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
@@ -287,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`,
@@ -313,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
+120
View File
@@ -0,0 +1,120 @@
{
"_description": "the parity fixture for #199's command resolver — every case here is asserted identically by crates/maestro-protocol/src/display.rs (command_program/command_tool) and web/src/toolIcon.ts (splitCommand). `program` is the resolved, basenamed program; `tool` is the icon id it maps to, or null when unrecognised/ambiguous.",
"cases": [
{ "command": "claude", "program": "claude", "tool": "claude" },
{ "command": "claude --resume", "program": "claude", "tool": "claude" },
{ "command": "CLAUDE", "program": "CLAUDE", "tool": "claude" },
{ "command": "/usr/local/bin/claude -p hi", "program": "claude", "tool": "claude" },
{ "command": "./bin/claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=1 BAR=2 claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=\"bar baz\" claude", "program": "claude", "tool": "claude" },
{ "command": "FOO='bar baz' claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=bar\\ baz claude", "program": "claude", "tool": "claude" },
{ "command": "'/usr/local/bin/claude'", "program": "claude", "tool": "claude" },
{ "command": "\"/opt/my agent/claude\" --resume", "program": "claude", "tool": "claude" },
{ "command": "env -i claude", "program": "claude", "tool": "claude" },
{ "command": "env --ignore-environment claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -u root claude", "program": "claude", "tool": "claude" },
{ "command": "sudo --user=root claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -- claude --resume", "program": "claude", "tool": "claude" },
{ "command": "nohup claude", "program": "claude", "tool": "claude" },
{ "command": "time claude", "program": "claude", "tool": "claude" },
{ "command": "time -p claude", "program": "claude", "tool": "claude" },
{ "command": "time -- claude", "program": "claude", "tool": "claude" },
{ "command": "time -p -- claude", "program": "claude", "tool": "claude" },
{ "command": "time -pp claude", "program": "time", "tool": null },
{ "command": "time -p -p claude", "program": "time", "tool": null },
{ "command": "time -o /tmp/t.log claude", "program": "time", "tool": null },
{ "command": "command claude", "program": "claude", "tool": "claude" },
{ "command": "command -v claude", "program": "command", "tool": null },
{ "command": "command -V claude", "program": "command", "tool": null },
{ "command": "npx --yes claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "npx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm -C ./pkg dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx -s claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --silent claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx -q claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --quiet claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "uvx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --bun claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --bun claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x -p @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --package @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --package=@anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --no-install claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --verbose claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --silent claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun --bun x claude-code", "program": "bun", "tool": null },
{ "command": "pnpm dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --package= claude-code", "program": "pnpm", "tool": null },
{ "command": "pnpm dlx --package", "program": "pnpm", "tool": null },
{ "command": "pnpm dlx -c \"claude-code\"", "program": "pnpm", "tool": null },
{ "command": "pnpm --package=typescript dlx tsc", "program": "tsc", "tool": null },
{ "command": "pnpm dlx --unknown-flag claude-code", "program": "pnpm", "tool": null },
{ "command": "yarn dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx -p foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --package= claude-code", "program": "yarn", "tool": null },
{ "command": "yarn dlx -p", "program": "yarn", "tool": null },
{ "command": "yarn dlx -p --quiet claude-code", "program": "yarn", "tool": null },
{ "command": "bunx --package= claude-code", "program": "bunx", "tool": null },
{ "command": "bunx --package", "program": "bunx", "tool": null },
{ "command": "bun x -p --silent claude-code", "program": "bun", "tool": null },
{ "command": "sudo env -i claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=1 sudo BAR=2 claude", "program": "claude", "tool": "claude" },
{ "command": "env -iv claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -nE claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -uroot claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -u", "program": "sudo", "tool": null },
{ "command": "env --unset= claude", "program": "env", "tool": null },
{ "command": "sudo --user= claude", "program": "sudo", "tool": null },
{ "command": "sudo --user \"\" claude", "program": "sudo", "tool": null },
{ "command": "sudo -nu claude", "program": "sudo", "tool": null },
{ "command": "env -S 'python3 -u' script.py", "program": "env", "tool": null },
{ "command": "npx -c \"claude --version\"", "program": "npx", "tool": null },
{ "command": "npx --call \"claude --version\"", "program": "npx", "tool": null },
{ "command": "command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "nohup FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "npx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "pnpm dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "yarn dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "bun x FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "bunx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "uvx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "sudo time FOO=1 claude", "program": "claude", "tool": "claude" },
{ "command": "nohup env FOO=1 claude", "program": "claude", "tool": "claude" },
{ "command": "sudo command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "command nohup FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "time command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "\"FOO=1\" claude", "program": "FOO=1", "tool": null },
{ "command": "FOO\\=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\"O\"O=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\\OO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "\"FOO\"=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\"\"OO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "claude\nclaude-code", "program": "claude", "tool": null },
{ "command": "clau\\\nde --resume", "program": "claude", "tool": "claude" },
{ "command": "npx opencode", "program": "opencode", "tool": "opencode" },
{ "command": "pnpm dlx opencode run", "program": "opencode", "tool": "opencode" },
{ "command": "pnpm claude", "program": "pnpm", "tool": null },
{ "command": "yarn claude", "program": "yarn", "tool": null },
{ "command": "claude-code-acp", "program": "claude-code-acp", "tool": null },
{ "command": "hx src/main.rs", "program": "hx", "tool": null },
{ "command": "bash", "program": "bash", "tool": "shell" },
{ "command": "opencode", "program": "opencode", "tool": "opencode" },
{ "command": "sudo", "program": "sudo", "tool": null },
{ "command": "", "program": "", "tool": null },
{ "command": "npx --unknown-flag claude-code", "program": "npx", "tool": null },
{ "command": "claude | tee log.txt", "program": "claude", "tool": null },
{ "command": "claude; echo done", "program": "claude;", "tool": null },
{ "command": "claude && echo done", "program": "claude", "tool": null },
{ "command": "echo hi $(claude)", "program": "echo", "tool": null },
{ "command": "claude \"unterminated", "program": "claude", "tool": null }
]
}
+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 {
+17
View File
@@ -6,6 +6,7 @@ const base: SpawnFormValues = {
command: "claude",
cwd: "/home/user/project",
name: "amber-fox",
structuredView: false,
worktreeEnabled: false,
worktreeBranch: "",
worktreeBranchMode: "new",
@@ -25,6 +26,22 @@ 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",
},
});
expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({
req: { cwd: base.cwd, command: "opencode", metadata: { name: "amber-fox" } },
});
});
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
const res = buildSpawnRequest(
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
+28 -3
View File
@@ -11,6 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
@@ -40,6 +41,7 @@ export interface SpawnFormValues {
command: string;
cwd: string;
name: string;
structuredView: boolean;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeBranchEdited: boolean;
@@ -66,6 +68,10 @@ export function buildSpawnRequest(
metadata,
};
if (values.structuredView && splitCommand(values.command).tool === "claude") {
req.view = "Structured";
}
if (values.worktreeEnabled) {
const mode = values.worktreeBranchMode ?? "new";
const branch = values.worktreeBranch.trim();
@@ -153,6 +159,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<span class="wt-summary"></span>
</button>
<div class="wt-collapsible" hidden>
<label class="row-inline structured-view" hidden>
<input type="checkbox" name="structured_view" />
Structured view
</label>
<div class="field wt-step" hidden>
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
@@ -203,6 +213,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const structuredWrap = form.querySelector(".structured-view") as HTMLElement;
const structuredView = form.querySelector("input[name=structured_view]") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
@@ -248,6 +260,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command,
cwd,
name: nameInput.value,
structuredView: structuredView.checked,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
@@ -317,6 +330,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
} else {
command = preset!.command;
}
updateStructuredAvailability();
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
@@ -354,10 +368,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// operator has already touched (#76).
let commandTouched = false;
function updateStructuredAvailability(): void {
const supported = splitCommand(command).tool === "claude";
structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false;
}
structuredView.addEventListener("change", emit);
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
commandTouched = true;
command = cmdRaw.value.trim();
updateStructuredAvailability();
emit();
}
});
@@ -844,10 +867,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (e.key !== "Enter" || e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (target === wtEnabled) {
if (target === wtEnabled || target === structuredView) {
e.preventDefault();
wtEnabled.checked = !wtEnabled.checked;
wtEnabled.dispatchEvent(new Event("change"));
const checkbox = target as HTMLInputElement;
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event("change"));
return;
}
@@ -885,6 +909,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command,
cwd,
name: nameInput.value,
structuredView: structuredView.checked,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
+108
View File
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
function stubApi(): RequestInit[] {
const sessionRequests: RequestInit[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/sessions") && init?.method === "POST") {
sessionRequests.push(init);
return new Response(JSON.stringify({ id: "s1", command: "claude", cwd: "/srv/work" }), {
status: 200,
});
}
if (url.includes("/api/info")) {
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user" }),
{ status: 200 },
);
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
return new Response(JSON.stringify([]), { status: 200 });
}),
);
return sessionRequests;
}
describe("spawn form structured view", () => {
let handle: SpawnFormHandle | null = null;
async function drain(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
afterEach(async () => {
handle?.destroy();
handle = null;
await drain();
vi.unstubAllGlobals();
});
it("is default-off and shown only for the resolved Claude Code command", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
const wrap = handle.el.querySelector<HTMLElement>(".structured-view")!;
const checkbox = wrap.querySelector<HTMLInputElement>("input")!;
const disclosure = handle.el.querySelector<HTMLElement>(".wt-collapsible")!;
expect(disclosure.hidden).toBe(true);
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(disclosure.hidden).toBe(false);
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();
expect(wrap.hidden).toBe(false);
});
it("clears a checked value when the command changes away from Claude Code", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
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"));
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
});
it("emits Structured in the submitted request when selected", async () => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
onSuccess() {},
});
document.body.appendChild(handle.el);
await drain();
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",
view: "Structured",
});
});
});
+753 -34
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). */
@@ -44,6 +50,22 @@ function row(id: string, kind: TranscriptRow["kind"], data: unknown): Transcript
return { id, kind, data };
}
function toolRow(id: string, text: string): TranscriptRow {
return row(id, "tool_call", {
title: `Read(${id})`,
status: "completed",
content: [{ type: "content", content: { type: "text", text } }],
});
}
function diffRow(id: string, newText: string): TranscriptRow {
return row(id, "tool_diff", {
title: `Edit(${id})`,
status: "completed",
content: [{ type: "diff", path: "file.ts", oldText: "before", newText }],
});
}
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
const allApprovalOptions = [
@@ -93,6 +115,169 @@ describe("structured connection: data flow", () => {
conn.dispose();
});
it("keeps state-only plan rows out of the transcript", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 3,
rows: [
row("user:1", "user", { content: { type: "text", text: "before plan" } }),
row("plan", "plan", { entries: [{ content: "ship it", status: "pending" }] }),
row("agent:1", "agent_text", { content: { type: "text", text: "after plan" } }),
],
},
});
ws.emit({
type: "reduced_state",
state: {
...idleState,
plan: { entries: [{ content: "ship it", priority: "high", status: "pending" }] },
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
expect(transcript.querySelectorAll(".st-row")).toHaveLength(2);
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
expect(transcript.textContent).toContain("before plan");
expect(transcript.textContent).toContain("after plan");
expect(transcript.textContent).not.toContain("[plan]");
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
it("renders advertised session settings and sends changed values", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "reduced_state",
state: {
...idleState,
config_options: {
configOptions: [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "opus",
options: [
{ value: "opus", name: "Opus" },
{ value: "sonnet", name: "Sonnet" },
],
},
{
id: "effort",
name: "Effort",
category: "thought_level",
type: "select",
currentValue: "high",
options: [{ value: "high", name: "High" }],
},
],
},
},
});
expect(conn.el.querySelector(".structured-config-trigger")?.getAttribute("aria-label")).toBe(
"Session settings. Model: Opus. Effort: High",
);
(conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click();
const selects = conn.el.querySelectorAll<HTMLSelectElement>(".structured-config-select");
expect(selects).toHaveLength(2);
selects[0].value = "sonnet";
selects[0].dispatchEvent(new Event("change"));
expect(setStructuredConfig).toHaveBeenCalledWith("s1", {
config_id: "model",
value: "sonnet",
});
conn.dispose();
});
it("applies plan append, patch, and remove without disturbing surrounding rows", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [row("user:1", "user", { content: { type: "text", text: "keep me" } })],
},
});
ws.emit({ type: "reduced_state", state: idleState });
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("plan", "plan", { entries: [{ content: "first", status: "pending" }] }),
},
state: {
...idleState,
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
},
});
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("agent:1", "agent_text", { content: { type: "text", text: "still here" } }),
},
state: {
...idleState,
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
},
});
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("plan", "plan", { entries: [{ content: "updated", status: "completed" }] }),
},
state: {
...idleState,
plan: { entries: [{ content: "updated", priority: "high", status: "completed" }] },
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"user:1",
"agent:1",
]);
expect(transcript.textContent).not.toContain("updated");
ws.emit({
type: "projection_update",
transcript: { type: "remove", id: "plan" },
state: idleState,
});
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("agent:1", "agent_text", { content: { type: "text", text: "patched neighbor" } }),
},
state: idleState,
});
ws.emit({
type: "projection_update",
transcript: { type: "remove", id: "user:1" },
state: idleState,
});
expect(conn.el.querySelector(".structured-rail")).toBeNull();
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"agent:1",
]);
expect(transcript.textContent).toContain("patched neighbor");
conn.dispose();
});
it("appends and patches rows via projection_update without duplicating them", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -116,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] });
@@ -227,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] });
@@ -237,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();
});
@@ -535,6 +918,110 @@ describe("structured connection: reconnection/error/loading", () => {
conn.dispose();
});
it("does not mount plan rows from a reconnect snapshot", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
first.open();
first.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [row("agent:old", "agent_text", { content: { type: "text", text: "old" } })],
},
});
first.drop();
vi.advanceTimersByTime(1000);
second.open();
second.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 4,
rows: [
row("user:new", "user", { content: { type: "text", text: "new" } }),
row("plan", "plan", { entries: [{ content: "reconnected", status: "in_progress" }] }),
],
},
});
second.emit({
type: "reduced_state",
state: {
...idleState,
plan: { entries: [{ content: "reconnected", priority: "high", status: "in_progress" }] },
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
expect(transcript.textContent).toBe("new");
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
it("keeps surviving expanded rows open across a reconnect snapshot and leaves fresh rows collapsed", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
first.open();
first.emit({
type: "transcript_snapshot",
snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
first.drop();
vi.advanceTimersByTime(1000);
second.open();
second.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 3,
rows: [toolRow("tool:1", "after"), diffRow("diff:1", "second"), toolRow("tool:2", "new")],
},
});
expect(
conn.el.querySelector('[data-row-id="tool:1"] .st-tool-head')?.getAttribute("aria-expanded"),
).toBe("true");
expect(
conn.el.querySelector('[data-row-id="diff:1"] .st-tool-head')?.getAttribute("aria-expanded"),
).toBe("true");
expect(
conn.el.querySelector('[data-row-id="tool:2"] .st-tool-head')?.getAttribute("aria-expanded"),
).toBe("false");
expect(conn.el.querySelector('[data-row-id="tool:1"] .st-tool-out')?.textContent).toContain("after");
conn.dispose();
});
it("prunes expanded IDs removed or made non-expandable by a snapshot", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 3,
rows: [row("tool:plain", "agent_text", { content: { type: "text", text: "not a tool" } })],
},
});
ws.emit({
type: "transcript_snapshot",
snapshot: { cursor: 5, rows: [toolRow("tool:gone", "returned"), toolRow("tool:plain", "returned")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) {
expect(head.getAttribute("aria-expanded")).toBe("false");
}
conn.dispose();
});
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
const first = new FakeSocket();
@@ -697,9 +1184,270 @@ describe("structured connection: reconnection/error/loading", () => {
});
});
describe("structured connection: transcript scrolling", () => {
let frames: Map<number, FrameRequestCallback>;
let nextFrame: number;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
frames = new Map();
nextFrame = 1;
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
const id = nextFrame++;
frames.set(id, cb);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
frames.delete(id);
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
function metrics(conn: ReturnType<typeof mount>, height: number, clientHeight: number, top = 0) {
const scroll = conn.el.querySelector<HTMLElement>(".structured-scroll")!;
const values = { height, clientHeight, top };
Object.defineProperties(scroll, {
scrollHeight: { configurable: true, get: () => values.height },
clientHeight: { configurable: true, get: () => values.clientHeight },
scrollTop: {
configurable: true,
get: () => values.top,
set: (value: number) => { values.top = value; },
},
});
return { scroll, values };
}
function runFrames(): void {
const pending = [...frames.entries()];
frames.clear();
for (const [, cb] of pending) cb(0);
}
it("shows the bottom after the initial transcript snapshot is laid out", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { values } = metrics(conn, 900, 300, 40);
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
row("agent:1", "agent_text", { content: { type: "text", text: "latest" } }),
],
},
});
expect(values.top).toBe(40);
runFrames();
expect(values.top).toBe(600);
conn.dispose();
});
it("keeps pinned appends and patches at the bottom after layout", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { values } = metrics(conn, 600, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.height = 720;
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }),
},
state: idleState,
});
runFrames();
expect(values.top).toBe(520);
values.height = 780;
ws.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("agent:1", "agent_text", {
content: { type: "text", text: "a longer completion" },
}),
},
state: idleState,
});
runFrames();
expect(values.top).toBe(580);
conn.dispose();
});
it.each([
{ position: "pinned", top: 800, expected: 880, manuallyScrolled: false },
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
])("preserves the $position position when the composer grows", ({ top, expected, manuallyScrolled }) => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 1000, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.top = top;
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.value = "one\ntwo\nthree";
input.dispatchEvent(new Event("input"));
values.height = 1080;
runFrames();
expect(input.style.height).toBe("120px");
expect(values.top).toBe(expected);
conn.dispose();
});
it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 1000, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [toolRow("tool:1", "output")] } });
runFrames();
values.top = 180;
scroll.dispatchEvent(new Event("scroll"));
values.height = 1120;
ws.emit({
type: "projection_update",
transcript: { type: "append", row: row("agent:2", "agent_text", { content: { type: "text", text: "new" } }) },
state: idleState,
});
runFrames();
expect(values.top).toBe(180);
values.height = 1180;
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions }),
},
state: {
...idleState,
pending_approval: {
nonce: "n1",
request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions },
},
},
});
runFrames();
expect(values.top).toBe(180);
conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!.click();
values.height = 1260;
runFrames();
expect(values.top).toBe(180);
conn.dispose();
});
it("resumes following when the user scrolls back near the bottom", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 1000, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.top = 750;
scroll.dispatchEvent(new Event("scroll"));
values.height = 1100;
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("agent:1", "agent_text", { content: { type: "text", text: "follow" } }),
},
state: idleState,
});
runFrames();
expect(values.top).toBe(900);
conn.dispose();
});
it("preserves a user-scrolled position across a reconnect snapshot", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
const { scroll, values } = metrics(conn, 1000, 200);
first.open();
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.top = 160;
scroll.dispatchEvent(new Event("scroll"));
first.drop();
vi.advanceTimersByTime(1000);
second.open();
values.height = 1200;
second.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 1,
rows: [
row("agent:1", "agent_text", {
content: { type: "text", text: "reconnected" },
}),
],
},
});
runFrames();
expect(values.top).toBe(160);
conn.dispose();
});
it("cancels a pending transcript frame when disposed or parked", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { values } = metrics(conn, 900, 300, 25);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
expect(frames.size).toBe(1);
conn.onUnmount();
expect(frames.size).toBe(0);
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.dispatchEvent(new Event("input"));
expect(frames.size).toBe(0);
conn.onMount({ focus: false });
expect(frames.size).toBe(2);
conn.dispose();
expect(frames.size).toBe(0);
runFrames();
expect(values.top).toBe(25);
});
});
describe("structured connection: pane lifecycle", () => {
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();
@@ -737,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", () => {
+572 -103
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,30 +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;
@@ -106,15 +146,84 @@ export function createStructuredConnection(
let rows: TranscriptRow[] = [];
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;
let followingBottom = true;
let receivedSnapshot = false;
let scrollWorkActive = true;
let parkedScrollTop: number | null = null;
let scrollFrame: number | null = null;
let pendingScroll: { top: number; follow: boolean } | null = null;
function cancelScrollFrame(): void {
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
scrollFrame = null;
pendingScroll = null;
}
function beginTranscriptLayout(forceBottom = false): void {
if (!scrollWorkActive || disposed) return;
if (pendingScroll) {
if (forceBottom) pendingScroll.follow = true;
return;
}
pendingScroll = {
top: scrollEl.scrollTop,
follow: forceBottom || followingBottom,
};
}
function finishTranscriptLayout(): void {
if (!scrollWorkActive || disposed || !pendingScroll || scrollFrame !== null) return;
scrollFrame = window.requestAnimationFrame(() => {
scrollFrame = null;
const pending = pendingScroll;
pendingScroll = null;
if (!pending || disposed || !scrollWorkActive) return;
scrollEl.scrollTop = pending.follow
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
: pending.top;
});
}
function updateTranscriptLayout(update: () => void, forceBottom = false): void {
beginTranscriptLayout(forceBottom);
update();
finishTranscriptLayout();
}
function onTranscriptScroll(): void {
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
cancelScrollFrame();
}
function onTranscriptClick(event: Event): void {
if ((event.target as Element | null)?.closest(".st-tool-head, .st-tool-group-head")) {
beginTranscriptLayout();
}
}
scrollEl.addEventListener("scroll", onTranscriptScroll);
scrollEl.addEventListener("click", onTranscriptClick, true);
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
@@ -151,87 +260,265 @@ export function createStructuredConnection(
}
}
function mountRow(row: TranscriptRow): void {
const el = buildTranscriptRow(row, approvalStateFor(row));
rowEls.set(row.id, el);
rowsEl.appendChild(el);
function buildRowElement(row: TranscriptRow): HTMLElement | null {
const el = buildTranscriptRow(
row,
approvalStateFor(row),
expandedRowIds.has(row.id),
(expanded) => {
if (expanded) expandedRowIds.add(row.id);
else expandedRowIds.delete(row.id);
finishTranscriptLayout();
},
);
if (!el) return null;
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
expandedRowIds.delete(row.id);
}
return el;
}
function remountRow(row: TranscriptRow): void {
const old = rowEls.get(row.id);
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
const wasExpanded = oldToolHead?.getAttribute("aria-expanded") === "true";
const restoreFocus = document.activeElement === oldToolHead;
const el = buildTranscriptRow(row, approvalStateFor(row), wasExpanded);
rowEls.set(row.id, el);
if (old) old.replaceWith(el);
else rowsEl.appendChild(el);
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
if (restoreFocus) newToolHead?.focus();
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);
}
return groups;
}
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);
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);
}
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();
}
}
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
rows = [...snapshot.rows];
rowIndex.clear();
rows.forEach((row, i) => rowIndex.set(row.id, i));
rowEls.clear();
rowsEl.replaceChildren();
for (const row of rows) mountRow(row);
const forceBottom = !receivedSnapshot;
updateTranscriptLayout(() => {
rows = [...snapshot.rows];
const expandableIds = new Set(
rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id),
);
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
rowIndex.clear();
rows.forEach((row, i) => rowIndex.set(row.id, i));
rebuildTranscript();
syncAvailableCommands();
}, forceBottom);
receivedSnapshot = true;
}
function applyDelta(delta: TranscriptDelta | null | undefined): void {
if (!delta) return;
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) {
updateTranscriptLayout(() => {
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);
} else {
rows[i] = delta.row;
}
} else {
rows[i] = delta.row;
remountRow(delta.row);
const i = rowIndex.get(delta.id);
if (i === undefined) return;
rows.splice(i, 1);
expandedRowIds.delete(delta.id);
rowIndex.clear();
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
}
} else {
const i = rowIndex.get(delta.id);
if (i === undefined) return;
rows.splice(i, 1);
rowIndex.clear();
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
rowEls.get(delta.id)?.remove();
rowEls.delete(delta.id);
rebuildTranscript();
syncAvailableCommands();
});
}
function sessionConfigOptions(): AcpSessionConfigOption[] {
return state.config_options?.configOptions ?? [];
}
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
return (option.options ?? []).flatMap((choice) =>
"options" in choice ? choice.options : [choice],
);
}
function currentConfigLabel(option: AcpSessionConfigOption): string {
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
?? option.currentValue;
}
async function changeConfig(option: AcpSessionConfigOption, value: string | boolean): Promise<void> {
configBusy = true;
renderConfig();
clearError();
try {
await setStructuredConfig(sessionId, { config_id: option.id, value });
} catch (error) {
showError(error instanceof Error ? error.message : "Could not change the session setting");
} finally {
configBusy = false;
renderConfig();
}
}
function toolRowCount(): number {
return rows.reduce((n, r) => n + (r.kind === "tool_call" || r.kind === "tool_diff" ? 1 : 0), 0);
}
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 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 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 {
@@ -252,16 +539,37 @@ export function createStructuredConnection(
reconcileAwaitingBegin();
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
if (newPendingId !== pendingApprovalRowId) {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId;
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
updateTranscriptLayout(() => {
const prev = pendingApprovalRowId;
pendingApprovalRowId = 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 {
@@ -278,17 +586,166 @@ export function createStructuredConnection(
}
// ---- composer ----
function autoResize(): void {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
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 [];
}
input.addEventListener("input", autoResize);
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();
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;
@@ -296,6 +753,7 @@ export function createStructuredConnection(
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now();
acceptedSubmission = null;
input.value = "";
autoResize();
@@ -333,21 +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) => {
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT);
});
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;
@@ -452,6 +895,12 @@ export function createStructuredConnection(
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
// it is no longer the most recent one.
let mountToken = 0;
let focusFrame: number | null = null;
function cancelFocusFrame(): void {
if (focusFrame !== null) window.cancelAnimationFrame(focusFrame);
focusFrame = null;
}
return {
sessionId,
@@ -459,13 +908,26 @@ export function createStructuredConnection(
onMount(mountOpts?: { focus?: boolean }) {
const focusOnMount = mountOpts?.focus !== false;
const token = ++mountToken;
requestAnimationFrame(() => {
scrollWorkActive = true;
pendingScroll = {
top: parkedScrollTop ?? scrollEl.scrollTop,
follow: followingBottom,
};
parkedScrollTop = null;
finishTranscriptLayout();
cancelFocusFrame();
focusFrame = window.requestAnimationFrame(() => {
focusFrame = null;
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
if (focusOnMount && !input.disabled) input.focus();
});
},
onUnmount() {
mountToken++;
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
scrollWorkActive = false;
cancelScrollFrame();
cancelFocusFrame();
},
focusTerminal() {
if (!input.disabled) input.focus();
@@ -477,11 +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();
},
};
+118 -105
View File
@@ -2,14 +2,18 @@
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(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow {
function row<K extends TranscriptRow["kind"]>(
kind: K,
data: unknown,
id = "r1",
): TranscriptRow & { kind: K } {
return { id, kind, data };
}
@@ -20,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();
});
@@ -51,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", () => {
@@ -63,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(
@@ -124,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", () => {
@@ -155,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", () => {
@@ -275,105 +351,42 @@ describe("buildTranscriptRow: approval", () => {
});
describe("buildTranscriptRow: notice / unknown kinds", () => {
it("renders a plan or notice row without crashing", () => {
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow();
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow();
});
});
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
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]",
);
const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow;
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();
});
});
+267 -155
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 {
@@ -187,7 +289,11 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
return null;
}
function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
function buildToolRow(
row: TranscriptRow,
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
): HTMLElement {
const tool = (row.data ?? {}) as AcpToolCallLike;
const content = tool.content ?? [];
const hasDiff = content.some((c) => c.type === "diff");
@@ -230,13 +336,20 @@ function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
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) {
@@ -259,20 +372,117 @@ function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
};
head.addEventListener("click", () => {
setExpanded(head.getAttribute("aria-expanded") !== "true");
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
});
setExpanded(expanded);
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
@@ -362,13 +572,39 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
return wrap;
}
/** Build the DOM for one transcript row. `approval` is consulted only for an
* `approval` row; pass it whenever the row might be that kind. */
/** 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" },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): null;
export function buildTranscriptRow(
row: TranscriptRow & { kind: "notice" },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null;
export function buildTranscriptRow(
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan" | "notice"> },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement;
export function buildTranscriptRow(
row: TranscriptRow,
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null;
export function buildTranscriptRow(
row: TranscriptRow,
approval?: ApprovalRowState,
toolExpanded = false,
): HTMLElement {
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null {
let built: HTMLElement;
switch (row.kind) {
case "user":
@@ -382,11 +618,17 @@ export function buildTranscriptRow(
break;
case "tool_call":
case "tool_diff":
built = buildToolRow(row, toolExpanded);
built = buildToolRow(row, toolExpanded, onToolExpandedChange);
break;
case "approval":
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
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;
@@ -394,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 ---------- */
+15
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import commandResolutionFixture from "../../fixtures/command-resolution.json";
interface CommandResolutionCase {
command: string;
program: string;
tool: string | null;
}
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
it("recognises the agents and shells sessions are spawned with", () => {
@@ -46,6 +53,14 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
expect(splitCommand("").tool).toBeNull();
});
it("resolves every case in the shared command-resolution fixture (#199)", () => {
const cases = commandResolutionFixture.cases as CommandResolutionCase[];
expect(cases.length).toBeGreaterThan(0);
for (const { command, program, tool } of cases) {
expect(splitCommand(command), command).toEqual({ tool, program });
}
});
it("gives every recognised tool a labelled mark", () => {
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
const svg = toolIconSvg(tool);
+428 -23
View File
@@ -9,10 +9,11 @@
* as text, so the fragment never renders a mark that stands for the wrong tool.
*/
/** Command wrappers that run another program: the tool is what follows. */
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]);
/** Program name (already basenamed and lowercased) → icon id. */
/** Program name (already basenamed and lowercased) → icon id.
*
* `claude-code-acp` — the daemon's actual structured-session child — is
* deliberately absent: the daemon launches it directly and ignores the
* session's command, so it is never itself a valid user-facing launch form. */
const BY_PROGRAM: Record<string, string> = {
claude: "claude",
"claude-code": "claude",
@@ -53,39 +54,443 @@ export interface CommandParts {
program: string;
}
/** Split a command line into the tool its icon stands for and the program name.
/** Split a command line into the tool its icon stands for and the program
* name — basenamed and stripped of its arguments. Leading `FOO=bar`
* assignments and wrapper forms (`sudo`, `env -i`, `npx`, `pnpm dlx`, …) are
* looked through, and quoting/escaping is honoured (never evaluated — no
* `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a shell would need real
* grammar for (pipelines, `&&`/`;`, redirections, substitution, an
* unterminated quote) falls back to the naive first word rather than
* guessing through it (#199). Mirrors the Rust `command_program`/
* `command_tool` in `crates/maestro-protocol/src/display.rs`.
*
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0;
// Leading `FOO=bar` assignments and wrappers stand in front of the real
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped.
while (i < words.length) {
const word = words[i];
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
i += 1;
continue;
}
const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(wrapper)) {
i += 1;
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue;
}
break;
const words = tokenize(command);
if (words === null) {
const program = legacyFirstWord(command);
return { tool: null, program };
}
const i = programIndex(words);
if (i >= words.length) return { tool: null, program: command.trim() };
const program = basename(words[i]);
const program = basename(words[i].text);
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
}
/** The naive "first whitespace word, basenamed" reading used only when
* {@link tokenize} rejects the line outright — still something sane to
* show, never a wrapper/tool match to act on. */
function legacyFirstWord(command: string): string {
const first = command.trim().split(/\s+/).filter(Boolean)[0];
return first === undefined ? command.trim() : basename(first);
}
/** One token from {@link tokenize}: its dequoted text, and — when both the
* name and `=` were entirely unquoted/unescaped in the source — the index of
* the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
* makes the word an executable named `FOO=1`; quoting/escaping only the value
* preserves the assignment, matching Bash's assignment-word grammar (#199). */
interface Word {
text: string;
plainEq: number | null;
}
/** `FOO=bar`, the shell's own prefix for one command's environment —
* recognised only when the `=` came from plain, unquoted/unescaped source
* text (#199). */
function isEnvAssignment(word: Word): boolean {
if (word.plainEq === null) return false;
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(word.text.slice(0, word.plainEq));
}
/** Walk `words` past leading `FOO=bar` assignments and known wrapper forms
* (interleaved in any order/count) to the index of the actual program. An
* assignment run is only consumed at the very start of the line or right
* after a wrapper whose own semantics accept one there ({@link Wrapper.acceptsAssignment}) —
* `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
* skipping to `claude`, since `command`/`nohup` just exec their next argument
* as the program, they don't setenv from an assignment-shaped one (#199). */
function programIndex(words: Word[]): number {
const texts = words.map((w) => w.text);
let i = 0;
let assignmentAllowed = true;
for (;;) {
if (assignmentAllowed) {
while (i < words.length && isEnvAssignment(words[i])) {
i += 1;
}
}
if (i >= words.length) break;
const wrapper = basename(texts[i]).toLowerCase();
const w = WRAPPER_RESOLVERS[wrapper];
if (!w) break;
const next = w.resolve(texts, i);
// Not a form of the wrapper we recognise (an unknown option, or — for
// `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it): the wrapper
// word itself is the program, not a launcher.
if (next === null || next <= i) break;
i = next;
assignmentAllowed = w.acceptsAssignment;
}
return i;
}
function basename(word: string): string {
const cut = word.lastIndexOf("/");
return cut >= 0 ? word.slice(cut + 1) : word;
}
/** Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
* `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
* `null` the moment it needs real shell grammar this resolver doesn't
* attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
* substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
* separator, like `;` — #199), or an unterminated quote/escape. Never
* expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
* backslash immediately before a newline, quoted or not, is a line
* continuation: removed entirely rather than embedding the newline or
* ending the word (#199). */
function tokenize(command: string): Word[] | null {
const words: Word[] = [];
let current = "";
let hasCurrent = false;
let plainEq: number | null = null;
let assignmentNameIsPlain = true;
let i = 0;
const n = command.length;
while (i < n) {
const c = command[i];
if (c === " " || c === "\t" || c === "\r") {
if (hasCurrent) {
words.push({ text: current, plainEq });
current = "";
hasCurrent = false;
plainEq = null;
assignmentNameIsPlain = true;
}
i += 1;
} else if (c === "\n") {
return null;
} else if ("|&;<>()`".includes(c)) {
return null;
} else if (c === "$" && command[i + 1] === "(") {
return null;
} else if (c === "'") {
hasCurrent = true;
if (plainEq === null) assignmentNameIsPlain = false;
i += 1;
const close = command.indexOf("'", i);
if (close === -1) return null; // unterminated single quote
current += command.slice(i, close);
i = close + 1;
} else if (c === '"') {
hasCurrent = true;
if (plainEq === null) assignmentNameIsPlain = false;
i += 1;
for (;;) {
if (i >= n) return null; // unterminated double quote
const c2 = command[i];
if (c2 === '"') {
i += 1;
break;
}
if (c2 === "\\") {
const escaped = command[i + 1];
if (escaped === undefined) return null; // trailing backslash
if (escaped === "\n") {
i += 2; // line continuation, removed entirely
continue;
}
current += '"\\$`'.includes(escaped) ? escaped : "\\" + escaped;
i += 2;
} else {
current += c2;
i += 1;
}
}
} else if (c === "\\") {
const escaped = command[i + 1];
if (escaped === undefined) return null; // trailing backslash
if (escaped === "\n") {
i += 2; // line continuation, removed entirely
continue;
}
hasCurrent = true;
if (plainEq === null) assignmentNameIsPlain = false;
current += escaped;
i += 2;
} else {
hasCurrent = true;
if (c === "=" && plainEq === null && assignmentNameIsPlain) {
plainEq = current.length;
}
current += c;
i += 1;
}
}
if (hasCurrent) words.push({ text: current, plainEq });
return words;
}
/** A wrapper's known `-`/`--` options: which take no value, and which
* consume either a following word or a `--flag=value` as theirs. A
* `-`-prefixed word this table doesn't know halts resolution rather than
* guessing past it (#199) — callers read that as {@link consumeOptions}
* returning `null`. */
interface OptionTable {
flags: Set<string>;
valued: Set<string>;
/** Whether this wrapper's short options may be combined (`-iv`) or take
* an attached value (`-uroot`), the way real getopt-based tools do
* (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/`dlx`)
* don't get this — guessing at bundling they don't actually support
* would resolve through options they'd reject outright. */
bundlesShortOptions: boolean;
}
/** `-iv` (bundled boolean short flags) or `-uroot` (a valued short option
* with its value attached) — getopt's traditional short-option forms.
* `false` the moment a character isn't a short option this table knows, or
* a valued one ends the word with no attached value left to be its operand
* (#199: rejected rather than guessed to come from the next word instead). */
function matchesShortCluster(opts: OptionTable, word: string): boolean {
let idx = 1;
while (idx < word.length) {
const short = "-" + word[idx];
if (opts.flags.has(short)) {
idx += 1;
continue;
}
if (opts.valued.has(short)) {
return idx + 1 < word.length;
}
return false;
}
return true;
}
/** Consume `opts`'s options starting at `words[i]`, returning the index just
* past them (which may equal `i` if there are none) — or `null` on an
* option this table doesn't recognise, or a valued option missing its
* operand. */
function consumeOptions(opts: OptionTable, words: string[], i: number): number | null {
for (;;) {
const word = words[i];
if (word === undefined) return i;
if (word === "--") return i + 1;
if (word === "-" || !word.startsWith("-")) return i;
const eq = word.indexOf("=");
if (eq >= 0) {
if (opts.valued.has(word.slice(0, eq)) && word.slice(eq + 1).length > 0) {
i += 1;
continue;
}
return null;
}
if (opts.flags.has(word)) {
i += 1;
continue;
}
if (opts.valued.has(word)) {
const value = words[i + 1];
if (value === undefined || value.length === 0 || value.startsWith("-")) return null;
i += 2;
continue;
}
if (
opts.bundlesShortOptions &&
!word.startsWith("--") &&
word.length > 2 &&
matchesShortCluster(opts, word)
) {
i += 1;
continue;
}
return null;
}
}
const NO_OPTIONS: OptionTable = { flags: new Set(), valued: new Set(), bundlesShortOptions: false };
const ENV_OPTS: OptionTable = {
flags: new Set(["-i", "--ignore-environment", "-0", "--null", "-v", "--debug"]),
valued: new Set(["-u", "--unset", "-C", "--chdir"]),
bundlesShortOptions: true,
};
const SUDO_OPTS: OptionTable = {
flags: new Set([
"-i",
"--login",
"-n",
"--non-interactive",
"-E",
"--preserve-env",
"-H",
"--set-home",
"-A",
"--askpass",
"-b",
"--background",
"-k",
"--reset-timestamp",
"-K",
"--remove-timestamp",
"-S",
"--stdin",
]),
valued: new Set(["-u", "--user", "-g", "--group"]),
bundlesShortOptions: true,
};
/** `-v`/`-V` only print what would run — they never execute it — so
* resolving through them would claim a program runs that `command` never
* launches (#199). Only `-p`, which still executes normally, is safe to
* look through. */
const COMMAND_BUILTIN_OPTS: OptionTable = {
flags: new Set(["-p"]),
valued: new Set(),
bundlesShortOptions: true,
};
/** The bash *keyword* `time` only ever parses `-p` (POSIX output format);
* `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
* `/usr/bin/time`, a different program a bare `time` word doesn't
* necessarily name. Assuming them here would be guessing which `time`
* resolves (#199). */
function resolveTime(words: string[], i: number): number | null {
let next = i + 1;
if (words[next] === "-p") next += 1;
if (words[next] === "--") next += 1;
const command = words[next];
return command?.startsWith("-") ? null : next;
}
/** `-c`/`--call` embeds a whole shell command string rather than naming the
* program positionally — resolving past it would mean guessing at a
* program buried inside that string, so it's left unrecognised and
* resolution stops at `npx` (#199). */
const NPX_OPTS: OptionTable = {
flags: new Set(["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"]),
valued: new Set(["-p", "--package", "--shell"]),
bundlesShortOptions: false,
};
const PNPM_OPTS: OptionTable = {
flags: new Set(["--silent", "-s", "--stream", "--aggregate-output", "--no-progress"]),
// `--package`/`--package=value` is documented in this pre-`dlx` position
// (`pnpm --package=x dlx y`), alongside the identical flag `dlx` itself
// also accepts (`PNPM_DLX_OPTS`).
valued: new Set(["-C", "--dir", "--filter", "--package"]),
bundlesShortOptions: false,
};
const YARN_OPTS: OptionTable = {
flags: new Set(["--silent", "-s", "--no-progress", "--non-interactive"]),
valued: new Set(["--cwd"]),
bundlesShortOptions: false,
};
const PNPM_DLX_OPTS: OptionTable = {
flags: new Set(["-s", "--silent"]),
valued: new Set(["--package"]),
bundlesShortOptions: false,
};
const YARN_DLX_OPTS: OptionTable = {
flags: new Set(["-q", "--quiet"]),
valued: new Set(["-p", "--package"]),
bundlesShortOptions: false,
};
const BUN_X_OPTS: OptionTable = {
flags: new Set(["--bun", "--no-install", "--verbose", "--silent"]),
valued: new Set(["-p", "--package"]),
bundlesShortOptions: false,
};
/** `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
* — without it, the next word is a `package.json` script name, not a
* package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
* itself takes launcher-specific options too, consumed before the executable. */
function resolveDlx(
opts: OptionTable,
dlxOpts: OptionTable,
words: string[],
i: number,
): number | null {
const j = consumeOptions(opts, words, i + 1);
if (j === null) return null;
if (words[j] !== "dlx") return null;
return consumeOptions(dlxOpts, words, j + 1);
}
/** `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
* (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
* subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
* the executable. */
function resolveBun(words: string[], i: number): number | null {
if (words[i + 1] !== "x") return null;
return consumeOptions(BUN_X_OPTS, words, i + 2);
}
/** A resolver for one wrapper form: given the tokenized command and the
* index of the wrapper word itself, the index of the word after it (past
* its options and any subcommand it requires), or `null` if this occurrence
* isn't a form we resolve through. */
type WrapperResolver = (words: string[], i: number) => number | null;
/** One recognised wrapper form: how to walk past it, and whether an
* assignment word is a valid follow-on once it's been walked past. */
interface Wrapper {
resolve: WrapperResolver;
/** `true` only for wrappers that themselves interpret a following
* `FOO=bar` as an environment assignment for the command they launch
* (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
* grammar for a brand new simple command (`time`). Every other wrapper here
* is a plain executable that execs its next argument as the program
* outright — an assignment-shaped one names a program literally called
* `FOO=1`, not an env var (#199). */
acceptsAssignment: boolean;
}
const WRAPPER_RESOLVERS: Record<string, Wrapper> = {
env: { resolve: (words, i) => consumeOptions(ENV_OPTS, words, i + 1), acceptsAssignment: true },
sudo: {
resolve: (words, i) => consumeOptions(SUDO_OPTS, words, i + 1),
acceptsAssignment: true,
},
command: {
resolve: (words, i) => consumeOptions(COMMAND_BUILTIN_OPTS, words, i + 1),
acceptsAssignment: false,
},
nohup: {
resolve: (words, i) => consumeOptions(NO_OPTIONS, words, i + 1),
acceptsAssignment: false,
},
time: { resolve: resolveTime, acceptsAssignment: true },
npx: {
resolve: (words, i) => consumeOptions(NPX_OPTS, words, i + 1),
acceptsAssignment: false,
},
pnpm: {
resolve: (words, i) => resolveDlx(PNPM_OPTS, PNPM_DLX_OPTS, words, i),
acceptsAssignment: false,
},
yarn: {
resolve: (words, i) => resolveDlx(YARN_OPTS, YARN_DLX_OPTS, words, i),
acceptsAssignment: false,
},
bun: { resolve: resolveBun, acceptsAssignment: false },
bunx: {
resolve: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
acceptsAssignment: false,
},
uvx: { resolve: (_words, i) => i + 1, acceptsAssignment: false },
};
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
function burst(): string {
const rays = Array.from({ length: 8 }, (_, k) => {
+42
View File
@@ -276,6 +276,7 @@ export interface CreateSessionRequest {
env?: Record<string, string>;
metadata?: Record<string, unknown>;
worktree?: WorktreeSpec;
view?: SessionView;
}
export type CatalogEvent =
@@ -421,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`/
@@ -435,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`. */
@@ -448,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";
@@ -542,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;