Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25653abcf5 | ||
|
|
0dc10ffe76 | ||
|
|
0543f66469 | ||
|
|
4a2f7e98b4 | ||
|
|
8c151a5fb2 | ||
|
|
8d0886f9b7 | ||
|
|
9f82400f0a | ||
|
|
059316cbac | ||
|
|
0b1c9e15cf | ||
|
|
e30084ea24 | ||
|
|
bb898a75f3 | ||
|
|
c8e90af91c | ||
|
|
d0df60ac87 | ||
|
|
25244803a9 | ||
|
|
92b41a8742 | ||
|
|
508262bbac | ||
|
|
2e7c5f7f58 | ||
|
|
3529947497 | ||
|
|
4851ad9101 | ||
|
|
3cb4bebd1e | ||
|
|
0e20baf7c2 | ||
|
|
af8acbfbf2 | ||
|
|
1ad7845f51 |
Generated
+1
@@ -1467,6 +1467,7 @@ dependencies = [
|
||||
"maestro-cli",
|
||||
"maestro-daemon",
|
||||
"maestro-tui",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "ACP client role: one claude-code-acp child per structured session"
|
||||
description = "ACP client role: one provider adapter child per structured session"
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! ACP client role for the daemon (#134): one `claude-code-acp` child per
|
||||
//! structured session.
|
||||
//! ACP client role for the daemon: one provider adapter child per structured
|
||||
//! session.
|
||||
//!
|
||||
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the
|
||||
//! adapter as a child process with stdio pipes, in the session's `cwd` with
|
||||
@@ -9,10 +9,10 @@
|
||||
//!
|
||||
//! **The handshake declares almost nothing.** `initialize` reports
|
||||
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
|
||||
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the
|
||||
//! daemon to act on its behalf — exactly what already happens in the
|
||||
//! terminal view. `session/request_permission` is the one client method that
|
||||
//! gets a real implementation, in #139; here it is a stub that declines.
|
||||
//! adapter uses the provider's own file and command tools instead of asking
|
||||
//! the daemon to act on its behalf — exactly what already happens in the
|
||||
//! terminal view. `session/request_permission` is the one client method with
|
||||
//! a real implementation.
|
||||
//!
|
||||
//! Every `session/update` notification the adapter sends lands on an
|
||||
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
|
||||
@@ -96,8 +96,8 @@ pub const INSTALL_COMMAND: &str =
|
||||
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
|
||||
|
||||
/// How long `initialize` + `session/new` may take before the spawn is
|
||||
/// declared failed. The real adapter boots a node process and the Claude
|
||||
/// Code SDK behind it; a slow machine takes seconds, never a minute.
|
||||
/// declared failed. The real adapters boot a node process and their provider
|
||||
/// client behind it; a slow machine takes seconds, never a minute.
|
||||
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
|
||||
|
||||
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
pub existing_branch: bool,
|
||||
/// Run a Claude Code or Codex session in a plain terminal instead of the
|
||||
/// structured (ACP) view it otherwise gets by default.
|
||||
#[arg(long, conflicts_with = "structured")]
|
||||
pub terminal: bool,
|
||||
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
|
||||
/// Codex commands have an adapter; the daemon refuses anything else.
|
||||
#[arg(long)]
|
||||
pub structured: bool,
|
||||
/// After spawning, attach immediately.
|
||||
#[arg(long, short = 'a')]
|
||||
pub attach: bool,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||
@@ -51,7 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
};
|
||||
|
||||
let body = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
view: spawn_view(&args),
|
||||
cwd: args.cwd.clone(),
|
||||
command: args.command.clone(),
|
||||
env,
|
||||
@@ -61,6 +61,22 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
client.create_session(&body).await
|
||||
}
|
||||
|
||||
/// The view a `maestro spawn` asks for: structured whenever the command
|
||||
/// resolves to an agent with an ACP adapter, since that is the richer session
|
||||
/// and the one both other clients now default to. `--terminal` opts out;
|
||||
/// `--structured` forces the request through even for a command this side
|
||||
/// does not recognise, leaving the refusal to the daemon.
|
||||
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
||||
if args.terminal {
|
||||
return SessionView::Terminal;
|
||||
}
|
||||
if args.structured || supports_structured_view(&args.command) {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
}
|
||||
}
|
||||
|
||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||
/// the work went.
|
||||
@@ -239,6 +255,62 @@ mod tests {
|
||||
|
||||
use super::DaemonInfo;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::cli::{Cli, Command};
|
||||
|
||||
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
||||
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
||||
argv.extend_from_slice(extra);
|
||||
let cli = Cli::try_parse_from(argv).expect("parses");
|
||||
let Command::Spawn(args) = cli.command else {
|
||||
panic!("expected spawn")
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
/// The CLI follows the two interactive clients: a command with an ACP
|
||||
/// adapter spawns structured unless `--terminal` says otherwise, and
|
||||
/// `--structured` still forces the request for a command this side does
|
||||
/// not recognise.
|
||||
#[test]
|
||||
fn spawn_defaults_to_structured_for_supported_agents() {
|
||||
use super::spawn_view;
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["claude"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["npx codex exec"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
||||
SessionView::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_refuses_both_view_flags_at_once() {
|
||||
let err = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"claude",
|
||||
"--structured",
|
||||
"--terminal",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("--terminal"), "{err}");
|
||||
}
|
||||
|
||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||
Session {
|
||||
view: Default::default(),
|
||||
|
||||
@@ -405,15 +405,17 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 49ab7e9: a new-branch add onto a name that already exists suffixes the
|
||||
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md.
|
||||
/// `git worktree add -b` alone fails outright when the branch already exists,
|
||||
/// even with the target directory free — a random handle commonly collides
|
||||
/// with one an earlier, already-removed worktree left behind. `add_new`
|
||||
/// sidesteps it on the branch name itself instead of erroring (#77).
|
||||
#[test]
|
||||
fn add_new_suffixes_when_branch_already_exists() {
|
||||
fn add_new_suffixes_branch_name_on_branch_collision() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// pre-create the branch
|
||||
// pre-create the branch, unused by any worktree.
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
@@ -425,9 +427,17 @@ fn add_new_suffixes_when_branch_already_exists() {
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
let path = mgr.add_new(&repo, "feat/dup").unwrap();
|
||||
let path = mgr
|
||||
.add_new(&repo, "feat/dup")
|
||||
.expect("sidesteps the branch collision");
|
||||
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
|
||||
assert!(path.is_dir());
|
||||
assert!(path.exists());
|
||||
|
||||
// The pre-existing branch is untouched; the new worktree checked out the
|
||||
// suffixed one instead of erroring or adopting `feat/dup`.
|
||||
let branches = worktree_branches(&repo);
|
||||
assert!(branches.contains(&"feat/dup-2".to_string()), "{branches:?}");
|
||||
assert!(!branches.contains(&"feat/dup".to_string()), "{branches:?}");
|
||||
}
|
||||
|
||||
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
|
||||
|
||||
@@ -383,13 +383,11 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a second spawn onto an *existing* branch that already has a worktree
|
||||
/// is rejected with a structured worktree error; the first worktree stays
|
||||
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9
|
||||
/// suffixes the new branch name instead, see
|
||||
/// `colliding_branch_name_gets_suffixed_too`.)
|
||||
/// #66/#77: a second spawn onto the same branch used to be rejected outright;
|
||||
/// since the branch-collision suffixing landed, it succeeds instead — the
|
||||
/// second worktree lands on `<branch>-2` and the first stays untouched.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn second_spawn_on_same_branch_is_rejected() {
|
||||
async fn second_spawn_on_same_branch_gets_suffixed() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
@@ -399,28 +397,33 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await
|
||||
.expect("first spawn");
|
||||
let dup = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", false))
|
||||
.await;
|
||||
match dup {
|
||||
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
|
||||
assert_eq!(
|
||||
info.code, "worktree_failed",
|
||||
"expected worktree error: {info:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected rejection for duplicate branch, got {other:?}"),
|
||||
}
|
||||
let second = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await
|
||||
.expect("second spawn suffixes instead of rejecting");
|
||||
|
||||
// Exactly one registration, one directory, still usable.
|
||||
// Count branch records only: the worktree path now contains the branch
|
||||
// name too (`worktrees/feature/dup`), so a substring match over every line
|
||||
// would count the same tree twice.
|
||||
let holders = git_worktree_list(base.path())
|
||||
.iter()
|
||||
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
|
||||
.count();
|
||||
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
||||
let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
|
||||
assert_eq!(
|
||||
second.cwd, suffixed,
|
||||
"second worktree must land on the suffixed path"
|
||||
);
|
||||
assert!(suffixed.is_dir());
|
||||
|
||||
// Both registrations exist: the original branch, untouched, and the
|
||||
// suffixed one the second spawn created.
|
||||
let registrations = git_worktree_list(base.path());
|
||||
assert!(
|
||||
registrations
|
||||
.iter()
|
||||
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
|
||||
"original branch must stay registered: {registrations:?}"
|
||||
);
|
||||
assert!(
|
||||
registrations
|
||||
.iter()
|
||||
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
|
||||
"suffixed branch must be registered too: {registrations:?}"
|
||||
);
|
||||
assert!(
|
||||
base.path()
|
||||
.join("worktrees")
|
||||
@@ -431,7 +434,8 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(first.id).await.expect("kill");
|
||||
client.kill_session(first.id).await.expect("kill first");
|
||||
client.kill_session(second.id).await.expect("kill second");
|
||||
}
|
||||
|
||||
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||
|
||||
@@ -226,6 +226,15 @@ pub fn command_tool(command: &str) -> Option<&'static str> {
|
||||
.map(|(_, tool)| *tool)
|
||||
}
|
||||
|
||||
/// Whether a command line resolves to an agent Maestro can run as a
|
||||
/// structured (ACP) session — Claude Code or Codex, the two adapters
|
||||
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
|
||||
/// control on this, and the daemon rejects a `Structured` request for
|
||||
/// anything else, so the answer has to come from one place.
|
||||
pub fn supports_structured_view(command: &str) -> bool {
|
||||
matches!(command_tool(command), Some("claude" | "codex"))
|
||||
}
|
||||
|
||||
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
||||
/// of the word that is the actual program (past every assignment/wrapper it
|
||||
/// resolved through).
|
||||
@@ -1064,4 +1073,14 @@ mod tests {
|
||||
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
||||
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_support_follows_the_resolved_program() {
|
||||
assert!(supports_structured_view("claude --resume"));
|
||||
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
|
||||
assert!(supports_structured_view("npx claude-code"));
|
||||
assert!(!supports_structured_view("opencode"));
|
||||
assert!(!supports_structured_view("bash -l"));
|
||||
assert!(!supports_structured_view("claude-code-acp"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ pub struct Session {
|
||||
/// daemon that predates the field keeps working untouched.
|
||||
#[serde(default)]
|
||||
pub view: SessionView,
|
||||
/// The ACP session id the `claude-code-acp` adapter handed back from
|
||||
/// The ACP session id the selected provider adapter handed back from
|
||||
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
|
||||
/// one, and only once its handshake succeeded; a later respawn passes it
|
||||
/// to `session/load` so the conversation continues (#135). Distinct from
|
||||
|
||||
@@ -3053,17 +3053,19 @@ mod tests {
|
||||
let request = |view| CreateSessionRequest {
|
||||
view,
|
||||
cwd: std::path::PathBuf::from("/srv/work"),
|
||||
command: "claude".into(),
|
||||
command: "codex".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
};
|
||||
|
||||
let structured_id = Uuid::from_u128(188);
|
||||
app.state.mode = Mode::Insert;
|
||||
app.submit_spawn(request(SessionView::Structured)).await;
|
||||
assert!(app.catalog.snapshot().sessions.is_empty());
|
||||
assert!(app.structured.contains_key(&structured_id));
|
||||
assert!(app.emulators.get(structured_id).is_none());
|
||||
assert_eq!(app.state.mode, Mode::Normal);
|
||||
|
||||
let terminal_id = Uuid::from_u128(187);
|
||||
app.submit_spawn(request(SessionView::Terminal)).await;
|
||||
|
||||
+132
-32
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::{abbreviate_dir, command_tool};
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||
};
|
||||
@@ -91,7 +91,7 @@ pub enum DraftField {
|
||||
/// Disclosure collapsing the optional view and worktree controls below.
|
||||
/// Closed by default; Enter / Space toggles.
|
||||
Advanced,
|
||||
/// ACP-backed transcript instead of a terminal. Claude Code only.
|
||||
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
|
||||
StructuredToggle,
|
||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||
WorktreeToggle,
|
||||
@@ -255,9 +255,14 @@ pub struct DraftPane {
|
||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||
/// take the default target.
|
||||
pub advanced_collapsed: bool,
|
||||
/// ACP-backed structured transcript. Off by default and supported only
|
||||
/// when the resolved command is Claude Code.
|
||||
/// ACP-backed structured transcript. Supported only when the resolved
|
||||
/// command is Claude Code or Codex, and on by default whenever it is —
|
||||
/// until the operator works the toggle themselves.
|
||||
pub structured_view: bool,
|
||||
/// Set once the operator toggles structured view by hand. From then on a
|
||||
/// command change never re-enables what they turned off, the same guard
|
||||
/// `name_touched` gives the name field.
|
||||
pub structured_view_touched: bool,
|
||||
/// Enclosing repository of the chosen target — from the project scan for
|
||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||
pub base_repo: Option<PathBuf>,
|
||||
@@ -298,7 +303,7 @@ impl Default for DraftPane {
|
||||
// while its data loads, and stay usable when the daemon reports none.
|
||||
let mut presets = builtin_presets();
|
||||
presets.push(custom_preset());
|
||||
Self {
|
||||
let mut draft = Self {
|
||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
presets,
|
||||
selected_preset: 0,
|
||||
@@ -319,6 +324,7 @@ impl Default for DraftPane {
|
||||
worktree_enabled: true,
|
||||
advanced_collapsed: true,
|
||||
structured_view: false,
|
||||
structured_view_touched: false,
|
||||
base_repo: None,
|
||||
probed_cwd: None,
|
||||
probe_seq: 0,
|
||||
@@ -337,7 +343,11 @@ impl Default for DraftPane {
|
||||
// straight away, and Tab from here reaches the command tiles.
|
||||
focused_field: DraftField::DirPicker,
|
||||
home_dir: String::new(),
|
||||
}
|
||||
};
|
||||
// The built-in tile the form opens on decides whether structured view
|
||||
// starts ticked, exactly as a later preset list would.
|
||||
draft.sync_structured_view();
|
||||
draft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +366,7 @@ impl DraftPane {
|
||||
.position(|p| !p.command.is_empty())
|
||||
.unwrap_or(0);
|
||||
self.apply_seed_command();
|
||||
self.clear_unsupported_structured_view();
|
||||
self.sync_structured_view();
|
||||
}
|
||||
|
||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||
@@ -673,18 +683,24 @@ impl DraftPane {
|
||||
}
|
||||
|
||||
fn supports_structured_view(&self) -> bool {
|
||||
command_tool(self.command()) == Some("claude")
|
||||
supports_structured_view(self.command())
|
||||
}
|
||||
|
||||
fn clear_unsupported_structured_view(&mut self) {
|
||||
/// Re-resolve the toggle after the command changed: an unsupported
|
||||
/// command forces it off, a supported one turns it back on unless the
|
||||
/// operator has already made the choice themselves.
|
||||
fn sync_structured_view(&mut self) {
|
||||
if !self.supports_structured_view() {
|
||||
self.structured_view = false;
|
||||
} else if !self.structured_view_touched {
|
||||
self.structured_view = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_structured_view(&mut self) {
|
||||
if self.supports_structured_view() {
|
||||
self.structured_view = !self.structured_view;
|
||||
self.structured_view_touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,7 +843,7 @@ impl DraftPane {
|
||||
}
|
||||
self.selected_preset =
|
||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||
self.clear_unsupported_structured_view();
|
||||
self.sync_structured_view();
|
||||
}
|
||||
|
||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||
@@ -1130,7 +1146,7 @@ impl DraftPane {
|
||||
DraftField::Preset | DraftField::Submit => {}
|
||||
DraftField::CustomCommand => {
|
||||
self.custom_command.handle_key(other);
|
||||
self.clear_unsupported_structured_view();
|
||||
self.sync_structured_view();
|
||||
}
|
||||
// Typing filters the list; the highlight resets to the top
|
||||
// so a narrowed search always starts at its best match.
|
||||
@@ -1888,18 +1904,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
|
||||
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
||||
let mut draft = DraftPane {
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
assert!(!draft.structured_view);
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "OpenCode".into(),
|
||||
command: "opencode".into(),
|
||||
},
|
||||
]);
|
||||
assert!(draft.structured_view, "on by default for a supported agent");
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(rendered.contains("[ ] structured view"), "{rendered}");
|
||||
assert!(rendered.contains("[x] structured view"), "{rendered}");
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(draft.structured_view);
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "codex");
|
||||
assert!(
|
||||
draft.structured_view,
|
||||
"Codex keeps the structured selection"
|
||||
);
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert!(
|
||||
@@ -1928,9 +1965,9 @@ mod tests {
|
||||
|
||||
/// A custom command typed into the free-text field, run through the same
|
||||
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
|
||||
/// env prefix, or a wrapper still resolves to Claude, so the toggle stays
|
||||
/// offered and the request carries `Structured`; an unrelated program
|
||||
/// does not.
|
||||
/// env prefix, or a wrapper still resolves to Claude Code or Codex, so
|
||||
/// the toggle stays offered and the request carries `Structured`; an
|
||||
/// unrelated program does not.
|
||||
#[test]
|
||||
fn structured_view_follows_command_resolution_for_custom_commands() {
|
||||
let cases: &[(&str, bool)] = &[
|
||||
@@ -1952,6 +1989,16 @@ mod tests {
|
||||
("yarn --silent dlx claude-code", true),
|
||||
("bun x claude-code", true),
|
||||
("\"/opt/my agent/claude\" --resume", true),
|
||||
("codex", true),
|
||||
("codex --full-auto", true),
|
||||
("CODEX=1 codex", true),
|
||||
("/usr/local/bin/codex exec", true),
|
||||
("npx codex", true),
|
||||
("sudo -u root codex", true),
|
||||
("env -i codex", true),
|
||||
("command codex", true),
|
||||
("pnpm --silent dlx codex", true),
|
||||
("\"/opt/my agent/codex\" exec", true),
|
||||
("bash", false),
|
||||
("opencode", false),
|
||||
("/usr/local/bin/bash -l", false),
|
||||
@@ -1973,16 +2020,16 @@ mod tests {
|
||||
};
|
||||
draft.selected_preset = draft.presets.len() - 1;
|
||||
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||
draft.sync_structured_view();
|
||||
|
||||
assert_eq!(
|
||||
draft.field_order().contains(&DraftField::StructuredToggle),
|
||||
supported,
|
||||
"toggle visibility for {command:?}"
|
||||
);
|
||||
draft.toggle_structured_view();
|
||||
assert_eq!(
|
||||
draft.structured_view, supported,
|
||||
"toggle takes effect for {command:?}"
|
||||
"default selection for {command:?}"
|
||||
);
|
||||
let view = draft.build_request().unwrap().view;
|
||||
assert_eq!(
|
||||
@@ -1994,12 +2041,18 @@ mod tests {
|
||||
},
|
||||
"request view for {command:?}"
|
||||
);
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"the toggle turns the default off for {command:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A configured preset (the spawn-form "alias" for a command) is gated
|
||||
/// the same way as free text: a preset whose command resolves to Claude
|
||||
/// through a wrapper still offers structured view.
|
||||
/// Code or Codex through a wrapper still offers structured view.
|
||||
#[test]
|
||||
fn structured_view_follows_command_resolution_for_presets() {
|
||||
let mut draft = DraftPane {
|
||||
@@ -2014,6 +2067,11 @@ mod tests {
|
||||
label: "Claude (npx)".into(),
|
||||
command: "npx claude-code --resume".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex-wrapped".into(),
|
||||
label: "Codex (npx)".into(),
|
||||
command: "npx codex exec".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
@@ -2023,11 +2081,16 @@ mod tests {
|
||||
|
||||
assert_eq!(draft.command(), "npx claude-code --resume");
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
draft.toggle_structured_view();
|
||||
assert!(draft.structured_view);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
// Switching to a preset that does not resolve to Claude clears the
|
||||
// Switching between supported presets preserves the selection.
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "npx codex exec");
|
||||
assert!(draft.structured_view);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
// Switching to a preset that does not resolve to an ACP agent clears the
|
||||
// stale selection instead of letting an invalid request through.
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
@@ -2036,11 +2099,48 @@ mod tests {
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
/// Editing the custom-command text away from Claude clears an armed
|
||||
/// The default only applies until the operator has an opinion: once they
|
||||
/// untick the box, moving between supported commands leaves it unticked
|
||||
/// instead of turning it back on under them.
|
||||
#[test]
|
||||
fn an_unticked_structured_view_survives_a_command_change() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
},
|
||||
]);
|
||||
assert!(draft.structured_view);
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(!draft.structured_view);
|
||||
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "codex");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"the operator's choice outranks the default"
|
||||
);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
/// Editing the custom-command text away from a supported agent clears an armed
|
||||
/// selection immediately, matching the preset-switch case above — an
|
||||
/// invalid `Structured` request can never reach `build_request`.
|
||||
#[test]
|
||||
fn structured_view_clears_when_custom_command_edited_away_from_claude() {
|
||||
fn structured_view_clears_when_custom_command_edited_away_from_supported_agent() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
@@ -2049,22 +2149,22 @@ mod tests {
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.selected_preset = draft.presets.len() - 1;
|
||||
draft.custom_command = crate::text_input::TextInput::with_value("claude");
|
||||
draft.toggle_structured_view();
|
||||
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
||||
draft.sync_structured_view();
|
||||
assert!(draft.structured_view);
|
||||
|
||||
typed(&mut draft, " -p");
|
||||
assert_eq!(draft.command(), "claude -p");
|
||||
assert!(draft.structured_view, "still Claude, selection survives");
|
||||
assert_eq!(draft.command(), "codex -p");
|
||||
assert!(draft.structured_view, "still Codex, selection survives");
|
||||
|
||||
for _ in 0.."claude -p".chars().count() {
|
||||
for _ in 0.."codex -p".chars().count() {
|
||||
draft.on_input(&InputKey::Backspace);
|
||||
}
|
||||
typed(&mut draft, "bash");
|
||||
assert_eq!(draft.command(), "bash");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"editing the program away from claude clears the stale selection"
|
||||
"editing the program away from Codex clears the stale selection"
|
||||
);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
@@ -29,3 +29,6 @@ tracing-subscriber.workspace = true
|
||||
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
|
||||
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
|
||||
maestro-tui = { path = "../maestro-tui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Process-level routing for the umbrella `maestro` binary (#10): each
|
||||
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
|
||||
//! not just parse successfully.
|
||||
//!
|
||||
//! A malformed `--config` file makes every crate's dispatch fail identically
|
||||
//! at config load, before any subcommand touches tmux, the network, or a
|
||||
//! terminal. That lets the assertions tell "clap parsed it but the handler
|
||||
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
|
||||
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
|
||||
//! exit 1).
|
||||
//!
|
||||
//! The absence tests below only compile when the matching feature is off, so
|
||||
//! they run under CI's per-feature-subset matrix (`--no-default-features
|
||||
//! --features cli|daemon|tui`) rather than the default all-features build.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn bin() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
|
||||
}
|
||||
|
||||
fn bad_config() -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
|
||||
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
|
||||
f
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(bin())
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
|
||||
}
|
||||
|
||||
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
|
||||
/// config crate's error surfacing through — never clap's own usage-error
|
||||
/// page, which would mean the subcommand never reached its handler.
|
||||
fn assert_dispatched(out: &Output) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
|
||||
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
|
||||
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
|
||||
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
|
||||
}
|
||||
|
||||
/// Never reached a handler: clap rejected the subcommand outright because the
|
||||
/// feature that defines it is off. Only used by the `not(feature = "...")`
|
||||
/// tests below, which themselves only exist in a single-feature build.
|
||||
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
|
||||
fn assert_unrecognized(out: &Output, name: &str) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
|
||||
assert!(
|
||||
stderr.contains("unrecognized subcommand"),
|
||||
"stderr:\n{stderr}"
|
||||
);
|
||||
assert!(stderr.contains(name), "stderr:\n{stderr}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
#[test]
|
||||
fn list_dispatches_into_maestro_cli() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cli"))]
|
||||
#[test]
|
||||
fn list_is_unrecognized_without_cli_feature() {
|
||||
assert_unrecognized(&run(&["list"]), "list");
|
||||
}
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
#[test]
|
||||
fn daemon_dispatches_into_maestro_daemon() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "daemon"))]
|
||||
#[test]
|
||||
fn daemon_is_unrecognized_without_daemon_feature() {
|
||||
assert_unrecognized(&run(&["daemon"]), "daemon");
|
||||
}
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
#[test]
|
||||
fn tui_dispatches_into_maestro_tui() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
#[test]
|
||||
fn tui_is_unrecognized_without_tui_feature() {
|
||||
assert_unrecognized(&run(&["tui"]), "tui");
|
||||
}
|
||||
+12
-9
@@ -195,25 +195,28 @@ An unknown name is rejected as an `[tui].theme` configuration error.
|
||||
The full action vocabulary is the `Action` enum in
|
||||
`crates/maestro-tui/src/action.rs`.
|
||||
|
||||
## Structured sessions: `claude-code-acp`
|
||||
## Structured sessions: ACP adapters
|
||||
|
||||
A session spawned with `view: Structured` (#134) runs Claude Code as a child
|
||||
A session spawned with `view: Structured` runs Claude Code or Codex as a child
|
||||
of the daemon over the Agent Client Protocol instead of in a tmux pane. The
|
||||
daemon resolves the adapter binary, `claude-code-acp`, on its `PATH` at spawn
|
||||
time. Install it once per host:
|
||||
resolved session command selects the adapter binary, which must be on the
|
||||
session's `PATH` when the spawn request sets one, or the daemon's `PATH`
|
||||
otherwise. Install the adapter for each provider you use:
|
||||
|
||||
```sh
|
||||
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
|
||||
npm install -g --prefix ~/.local @agentclientprotocol/codex-acp
|
||||
```
|
||||
|
||||
That puts `claude-code-acp` in `~/.local/bin`, which must be on the daemon's
|
||||
`PATH` (a systemd user unit does not read your shell profile; set
|
||||
That puts `claude-code-acp` and `codex-acp` in `~/.local/bin`, which must be on
|
||||
the applicable `PATH` (a systemd user unit does not read your shell profile; set
|
||||
`Environment=PATH=…` in the unit or install somewhere already on it). The
|
||||
adapter needs a logged-in Claude Code (`claude /login`).
|
||||
Claude adapter needs a logged-in Claude Code (`claude /login`); Codex uses the
|
||||
Codex CLI's or adapter's configured authentication.
|
||||
|
||||
When the adapter is missing the spawn still creates the session, marked dead
|
||||
with the install command above as its activity detail, so the sidebar says
|
||||
what to run. The adapter is a child of the daemon: it ends with a `DELETE
|
||||
with the provider-specific install command as its activity detail, so clients
|
||||
say what to run. The adapter is a child of the daemon: it ends with a `DELETE
|
||||
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
|
||||
itself. A daemon restart ends an in-flight structured turn, but preserves its
|
||||
worktree, session record, and event-log transcript. The replacement daemon
|
||||
|
||||
+18
-2
@@ -35,11 +35,27 @@ session's worktree holds unsaved work — applies to a structured session
|
||||
exactly as it does to a terminal one; only what gets terminated differs (an
|
||||
ACP handle instead of a tmux pane).
|
||||
|
||||
## Choosing the view
|
||||
|
||||
`view` is fixed at spawn and never changes. Every client picks it the same
|
||||
way, from the resolved command alone
|
||||
(`maestro_protocol::display::supports_structured_view`): a command that
|
||||
resolves to Claude Code or Codex spawns **Structured** by default, anything
|
||||
else spawns **Terminal**. The operator can override the default — the web and
|
||||
TUI spawn forms show the checkbox ticked and let it be unticked, and
|
||||
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
|
||||
even for a command the CLI does not recognise and lets the daemon refuse it).
|
||||
Once the operator works the toggle, a later command change never re-ticks it
|
||||
under them; it can still be forced off when the new command has no adapter.
|
||||
|
||||
The daemon is the last word: a `Structured` request for a command with no
|
||||
adapter is refused there, whichever client sent it.
|
||||
|
||||
## The adapter
|
||||
|
||||
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.
|
||||
resolved by walking the session's `PATH` when the spawn request sets one, or
|
||||
the daemon's `PATH` otherwise, and taking the first executable with that name.
|
||||
|
||||
| Command | Adapter binary | Install command | Provider detail |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
|
||||
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b tsconfig.build.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"test": "vitest run",
|
||||
|
||||
+21
-2
@@ -64,6 +64,7 @@ export function createAttachConnection(
|
||||
<div class="attach-body"></div>
|
||||
`;
|
||||
|
||||
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
|
||||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
@@ -323,6 +324,9 @@ export function createAttachConnection(
|
||||
let ws: WebSocket;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
|
||||
// Follow it once per socket; later scrolling remains under the operator's control.
|
||||
let awaitingReplay = true;
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
@@ -342,6 +346,7 @@ export function createAttachConnection(
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
awaitingReplay = true;
|
||||
ws = new WebSocket(attachWsUrl(sessionId));
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
@@ -362,10 +367,20 @@ export function createAttachConnection(
|
||||
return;
|
||||
}
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(ev.data));
|
||||
const replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(ev.data), () => {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
} else if (ev.data instanceof Blob) {
|
||||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
||||
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
|
||||
void ev.data.arrayBuffer().then((b) => {
|
||||
const replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(b), () => {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -463,6 +478,10 @@ export function createAttachConnection(
|
||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||
// bar already take.
|
||||
sendInput,
|
||||
updateInfo(next: AttachInfo) {
|
||||
titleEl.innerHTML = paneTitleHtml(sessionId, next);
|
||||
titleEl.title = paneTitleAttr(sessionId, next);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -27,6 +27,7 @@ function fakeConn(kind: string, id: string) {
|
||||
sendInput(data: string) {
|
||||
sent.push({ id, data });
|
||||
},
|
||||
updateInfo() {},
|
||||
dispose() {
|
||||
disposed.push(`${kind}:${id}`);
|
||||
},
|
||||
|
||||
@@ -110,6 +110,7 @@ export class ConnectionPool {
|
||||
} else {
|
||||
this.cache.delete(id);
|
||||
this.cache.set(id, conn); // LRU refresh
|
||||
if (info) conn.updateInfo(info);
|
||||
}
|
||||
} else {
|
||||
conn = this.create(id, info);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// transcript (structured.ts).
|
||||
|
||||
import type { Uuid } from "./types.js";
|
||||
import type { AttachInfo } from "./paneTitle.js";
|
||||
|
||||
export interface PaneConnection {
|
||||
readonly sessionId: Uuid;
|
||||
@@ -21,6 +22,8 @@ export interface PaneConnection {
|
||||
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
|
||||
* has no PTY, so this is a no-op there. */
|
||||
sendInput(data: string): void;
|
||||
/** Refresh the pane header after the catalog renames a live session. */
|
||||
updateInfo(info: AttachInfo): void;
|
||||
/** Fully disconnect and remove the element. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -26,15 +26,21 @@ describe("buildSpawnRequest", () => {
|
||||
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
||||
});
|
||||
|
||||
it("requests structured view only for Claude Code", () => {
|
||||
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({
|
||||
req: {
|
||||
cwd: base.cwd,
|
||||
command: "claude",
|
||||
metadata: { name: "amber-fox" },
|
||||
view: "Structured",
|
||||
},
|
||||
});
|
||||
it.each(["claude", "FOO=1 /usr/local/bin/codex", "env -i codex"])(
|
||||
"requests structured view for resolved %s commands",
|
||||
(command) => {
|
||||
expect(buildSpawnRequest({ ...base, command, structuredView: true }, null)).toEqual({
|
||||
req: {
|
||||
cwd: base.cwd,
|
||||
command,
|
||||
metadata: { name: "amber-fox" },
|
||||
view: "Structured",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not request structured view for unsupported commands", () => {
|
||||
expect(
|
||||
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
|
||||
).toEqual({
|
||||
|
||||
+20
-3
@@ -15,6 +15,13 @@ import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
function supportsStructuredView(command: string): boolean {
|
||||
const tool = splitCommand(command).tool;
|
||||
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
|
||||
}
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
* from the repository's existing branches (#61). */
|
||||
export type WorktreeBranchMode = "new" | "existing";
|
||||
@@ -68,7 +75,7 @@ export function buildSpawnRequest(
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (values.structuredView && splitCommand(values.command).tool === "claude") {
|
||||
if (values.structuredView && supportsStructuredView(values.command)) {
|
||||
req.view = "Structured";
|
||||
}
|
||||
|
||||
@@ -368,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// operator has already touched (#76).
|
||||
let commandTouched = false;
|
||||
|
||||
// Set once the operator works the checkbox themselves. Until then every
|
||||
// command change re-derives the default (on for Claude Code and Codex, off
|
||||
// for everything else); afterwards their choice outranks it, the same guard
|
||||
// `commandTouched` gives the command field.
|
||||
let structuredTouched = false;
|
||||
|
||||
function updateStructuredAvailability(): void {
|
||||
const supported = splitCommand(command).tool === "claude";
|
||||
const supported = supportsStructuredView(command);
|
||||
structuredWrap.hidden = !supported;
|
||||
if (!supported) structuredView.checked = false;
|
||||
else if (!structuredTouched) structuredView.checked = true;
|
||||
}
|
||||
|
||||
structuredView.addEventListener("change", emit);
|
||||
structuredView.addEventListener("change", () => {
|
||||
structuredTouched = true;
|
||||
emit();
|
||||
});
|
||||
|
||||
cmdRaw.addEventListener("input", () => {
|
||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("is default-off and shown only for the resolved Claude Code command", async () => {
|
||||
it("is default-on and shown for resolved Claude Code and Codex commands", async () => {
|
||||
stubApi();
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
@@ -57,28 +57,55 @@ describe("spawn form structured view", () => {
|
||||
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||
expect(disclosure.hidden).toBe(false);
|
||||
expect(wrap.hidden).toBe(false);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click();
|
||||
expect(wrap.hidden).toBe(true);
|
||||
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click();
|
||||
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||
custom.click();
|
||||
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
command.value = "FOO=1 /usr/local/bin/codex";
|
||||
command.dispatchEvent(new Event("input"));
|
||||
expect(wrap.hidden).toBe(false);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
command.value = "opencode";
|
||||
command.dispatchEvent(new Event("input"));
|
||||
expect(wrap.hidden).toBe(true);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a checked value when the command changes away from Claude Code", async () => {
|
||||
it("keeps an unchecked box unchecked across a command change", async () => {
|
||||
stubApi();
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
checkbox.click();
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
|
||||
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
command.value = "codex";
|
||||
command.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the checked default when Codex changes to an unsupported command", async () => {
|
||||
stubApi();
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
|
||||
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
command.value = "env -i codex";
|
||||
command.dispatchEvent(new Event("input"));
|
||||
|
||||
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||
custom.click();
|
||||
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
command.value = "opencode";
|
||||
command.dispatchEvent(new Event("input"));
|
||||
|
||||
@@ -86,7 +113,33 @@ describe("spawn form structured view", () => {
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("emits Structured in the submitted request when selected", async () => {
|
||||
it.each(["claude", "codex"])("emits Structured for a %s command by default", async (tool) => {
|
||||
const requests = stubApi();
|
||||
handle = createSpawnForm({
|
||||
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
||||
onSuccess() {},
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
if (tool === "codex") {
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
|
||||
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
command.value = "codex";
|
||||
command.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
handle.el.requestSubmit();
|
||||
await drain();
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(JSON.parse(String(requests[0].body))).toMatchObject({
|
||||
command: tool,
|
||||
view: "Structured",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits no view once the operator unticks the box", async () => {
|
||||
const requests = stubApi();
|
||||
handle = createSpawnForm({
|
||||
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
||||
@@ -100,9 +153,6 @@ describe("spawn form structured view", () => {
|
||||
await drain();
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(JSON.parse(String(requests[0].body))).toMatchObject({
|
||||
command: "claude",
|
||||
view: "Structured",
|
||||
});
|
||||
expect(JSON.parse(String(requests[0].body)).view).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,42 @@ describe("structured connection: data flow", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("shows turn details only while that footer is the conversation's final item", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const turnMeta = (id: string, elapsedMs: number) =>
|
||||
row(id, "notice", {
|
||||
maestroTurnMeta: { elapsedMs, contextTokens: 12_000, outcome: "completed", model: "codex" },
|
||||
});
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 4,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { type: "text", text: "first" } }),
|
||||
turnMeta("turn:1", 1_000),
|
||||
row("user:2", "user", { content: { type: "text", text: "second" } }),
|
||||
turnMeta("turn:2", 2_000),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(conn.el.querySelectorAll(".st-turn-meta")).toHaveLength(1);
|
||||
expect(conn.el.querySelector(".st-turn-meta")?.textContent).toContain("2s");
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("user:3", "user", { content: { type: "text", text: "third" } }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
expect(conn.el.querySelector(".st-turn-meta")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps state-only plan rows out of the transcript", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -289,6 +325,7 @@ describe("structured connection: data flow", () => {
|
||||
transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
|
||||
state: idleState,
|
||||
});
|
||||
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.classList).toContain("st-row-enter");
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) },
|
||||
@@ -296,6 +333,7 @@ describe("structured connection: data flow", () => {
|
||||
});
|
||||
const rows = conn.el.querySelectorAll(".st-row-agent");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].classList).not.toContain("st-row-enter");
|
||||
expect(conn.el.textContent).toContain("partial done");
|
||||
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
||||
conn.dispose();
|
||||
@@ -321,7 +359,7 @@ describe("structured connection: data flow", () => {
|
||||
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
|
||||
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.hidden).toBe(true);
|
||||
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
@@ -1253,7 +1291,7 @@ describe("structured connection: transcript scrolling", () => {
|
||||
it("keeps pinned appends and patches at the bottom after layout", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { values } = metrics(conn, 600, 200);
|
||||
const { scroll, values } = metrics(conn, 600, 200);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
@@ -1269,6 +1307,7 @@ describe("structured connection: transcript scrolling", () => {
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(520);
|
||||
expect(scroll.style.scrollBehavior).toBe("smooth");
|
||||
|
||||
values.height = 780;
|
||||
ws.emit({
|
||||
@@ -1283,6 +1322,32 @@ describe("structured connection: transcript scrolling", () => {
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(580);
|
||||
expect(scroll.style.scrollBehavior).toBe("smooth");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps follow-to-bottom immediate when reduced motion is requested", () => {
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { scroll, values } = metrics(conn, 600, 200);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
|
||||
values.height = 720;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("agent:1", "agent_text", { content: { type: "text", text: "quietly" } }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
|
||||
expect(values.top).toBe(520);
|
||||
expect(scroll.style.scrollBehavior).toBe("");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
|
||||
+68
-11
@@ -114,6 +114,7 @@ export function createStructuredConnection(
|
||||
`;
|
||||
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
|
||||
const configEl = el.querySelector(".structured-config") as HTMLDivElement;
|
||||
const configTrigger = el.querySelector(".structured-config-trigger") as HTMLButtonElement;
|
||||
const configMenu = el.querySelector(".structured-config-menu") as HTMLDivElement;
|
||||
@@ -148,6 +149,7 @@ export function createStructuredConnection(
|
||||
const rowEls = new Map<string, HTMLElement>();
|
||||
const expandedRowIds = new Set<string>();
|
||||
const expandedGroupIds = new Set<string>();
|
||||
const enteringRowIds = new Set<string>();
|
||||
const groupEls = new Map<string, HTMLElement>();
|
||||
let state: ReducedState = { turn: idleTurn() };
|
||||
let pendingApprovalRowId: string | null = null;
|
||||
@@ -171,7 +173,20 @@ export function createStructuredConnection(
|
||||
let scrollWorkActive = true;
|
||||
let parkedScrollTop: number | null = null;
|
||||
let scrollFrame: number | null = null;
|
||||
let pendingScroll: { top: number; follow: boolean } | null = null;
|
||||
let pendingScroll: { top: number; follow: boolean; smooth: boolean } | null = null;
|
||||
let smoothScrollTimer: number | null = null;
|
||||
let smoothFollowing = false;
|
||||
|
||||
function reduceMotion(): boolean {
|
||||
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
||||
}
|
||||
|
||||
function stopSmoothFollow(): void {
|
||||
if (smoothScrollTimer !== null) window.clearTimeout(smoothScrollTimer);
|
||||
smoothScrollTimer = null;
|
||||
smoothFollowing = false;
|
||||
scrollEl.style.removeProperty("scroll-behavior");
|
||||
}
|
||||
|
||||
function cancelScrollFrame(): void {
|
||||
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
|
||||
@@ -179,15 +194,17 @@ export function createStructuredConnection(
|
||||
pendingScroll = null;
|
||||
}
|
||||
|
||||
function beginTranscriptLayout(forceBottom = false): void {
|
||||
function beginTranscriptLayout(forceBottom = false, smooth = false): void {
|
||||
if (!scrollWorkActive || disposed) return;
|
||||
if (pendingScroll) {
|
||||
if (forceBottom) pendingScroll.follow = true;
|
||||
if (smooth) pendingScroll.smooth = true;
|
||||
return;
|
||||
}
|
||||
pendingScroll = {
|
||||
top: scrollEl.scrollTop,
|
||||
follow: forceBottom || followingBottom,
|
||||
smooth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,19 +215,30 @@ export function createStructuredConnection(
|
||||
const pending = pendingScroll;
|
||||
pendingScroll = null;
|
||||
if (!pending || disposed || !scrollWorkActive) return;
|
||||
scrollEl.scrollTop = pending.follow
|
||||
const target = pending.follow
|
||||
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
|
||||
: pending.top;
|
||||
if (pending.follow && pending.smooth && !reduceMotion()) {
|
||||
smoothFollowing = true;
|
||||
scrollEl.style.scrollBehavior = "smooth";
|
||||
scrollEl.scrollTop = target;
|
||||
if (smoothScrollTimer !== null) window.clearTimeout(smoothScrollTimer);
|
||||
smoothScrollTimer = window.setTimeout(() => stopSmoothFollow(), 220);
|
||||
} else {
|
||||
stopSmoothFollow();
|
||||
scrollEl.scrollTop = target;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateTranscriptLayout(update: () => void, forceBottom = false): void {
|
||||
beginTranscriptLayout(forceBottom);
|
||||
function updateTranscriptLayout(update: () => void, forceBottom = false, smooth = false): void {
|
||||
beginTranscriptLayout(forceBottom, smooth);
|
||||
update();
|
||||
finishTranscriptLayout();
|
||||
}
|
||||
|
||||
function onTranscriptScroll(): void {
|
||||
if (smoothFollowing) return;
|
||||
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
|
||||
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
|
||||
cancelScrollFrame();
|
||||
@@ -218,12 +246,19 @@ export function createStructuredConnection(
|
||||
|
||||
function onTranscriptClick(event: Event): void {
|
||||
if ((event.target as Element | null)?.closest(".st-tool-head, .st-tool-group-head")) {
|
||||
beginTranscriptLayout();
|
||||
beginTranscriptLayout(false, true);
|
||||
}
|
||||
}
|
||||
|
||||
function settleDisclosureLayout(): void {
|
||||
finishTranscriptLayout();
|
||||
window.setTimeout(() => updateTranscriptLayout(() => {}, false, true), 190);
|
||||
}
|
||||
|
||||
scrollEl.addEventListener("scroll", onTranscriptScroll);
|
||||
scrollEl.addEventListener("click", onTranscriptClick, true);
|
||||
scrollEl.addEventListener("wheel", stopSmoothFollow, { passive: true });
|
||||
scrollEl.addEventListener("touchstart", stopSmoothFollow, { passive: true });
|
||||
|
||||
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
@@ -268,10 +303,11 @@ export function createStructuredConnection(
|
||||
(expanded) => {
|
||||
if (expanded) expandedRowIds.add(row.id);
|
||||
else expandedRowIds.delete(row.id);
|
||||
finishTranscriptLayout();
|
||||
settleDisclosureLayout();
|
||||
},
|
||||
);
|
||||
if (!el) return null;
|
||||
if (enteringRowIds.has(row.id)) el.classList.add("st-row-enter");
|
||||
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||
expandedRowIds.delete(row.id);
|
||||
}
|
||||
@@ -328,9 +364,12 @@ export function createStructuredConnection(
|
||||
(expanded) => {
|
||||
if (expanded) expandedGroupIds.add(groupId);
|
||||
else expandedGroupIds.delete(groupId);
|
||||
finishTranscriptLayout();
|
||||
settleDisclosureLayout();
|
||||
},
|
||||
);
|
||||
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
|
||||
groupEl.classList.add("st-row-enter");
|
||||
}
|
||||
groupEls.set(groupId, groupEl);
|
||||
children.push(groupEl);
|
||||
continue;
|
||||
@@ -340,7 +379,15 @@ export function createStructuredConnection(
|
||||
rowEls.set(row.id, rowEl);
|
||||
children.push(rowEl);
|
||||
}
|
||||
rowsEl.replaceChildren(...children);
|
||||
// A completed turn's footer is useful as the conversation's current
|
||||
// tail, but repeating duration/model/token summaries throughout history
|
||||
// makes them read like transcript content. Keep only a footer that is the
|
||||
// final visible item; appending the next turn naturally retires it.
|
||||
const visibleChildren = children.filter(
|
||||
(child, index) => !child.classList.contains("st-turn-meta") || index === children.length - 1,
|
||||
);
|
||||
rowsEl.replaceChildren(...visibleChildren);
|
||||
enteringRowIds.clear();
|
||||
if (restoreToolFocus && focusedRowId) {
|
||||
rowEls.get(focusedRowId)?.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
|
||||
} else if (restoreGroupFocus && focusedGroupId) {
|
||||
@@ -368,6 +415,7 @@ export function createStructuredConnection(
|
||||
if (!delta) return;
|
||||
updateTranscriptLayout(() => {
|
||||
if (delta.type === "append") {
|
||||
if (receivedSnapshot) enteringRowIds.add(delta.row.id);
|
||||
rowIndex.set(delta.row.id, rows.length);
|
||||
rows.push(delta.row);
|
||||
} else if (delta.type === "patch") {
|
||||
@@ -388,7 +436,7 @@ export function createStructuredConnection(
|
||||
}
|
||||
rebuildTranscript();
|
||||
syncAvailableCommands();
|
||||
});
|
||||
}, false, true);
|
||||
}
|
||||
|
||||
function sessionConfigOptions(): AcpSessionConfigOption[] {
|
||||
@@ -703,7 +751,7 @@ export function createStructuredConnection(
|
||||
input.style.overflowY = "hidden";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
if (input.scrollHeight > 160) input.style.overflowY = "auto";
|
||||
});
|
||||
}, false, true);
|
||||
}
|
||||
input.addEventListener("input", () => {
|
||||
autoResize();
|
||||
@@ -912,6 +960,7 @@ export function createStructuredConnection(
|
||||
pendingScroll = {
|
||||
top: parkedScrollTop ?? scrollEl.scrollTop,
|
||||
follow: followingBottom,
|
||||
smooth: false,
|
||||
};
|
||||
parkedScrollTop = null;
|
||||
finishTranscriptLayout();
|
||||
@@ -927,6 +976,7 @@ export function createStructuredConnection(
|
||||
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
|
||||
scrollWorkActive = false;
|
||||
cancelScrollFrame();
|
||||
stopSmoothFollow();
|
||||
cancelFocusFrame();
|
||||
},
|
||||
focusTerminal() {
|
||||
@@ -936,6 +986,10 @@ export function createStructuredConnection(
|
||||
// No PTY behind a structured session — the leader's literal-byte
|
||||
// escape hatch has nothing to do here.
|
||||
},
|
||||
updateInfo(next: AttachInfo) {
|
||||
titleEl.innerHTML = paneTitleHtml(sessionId, next);
|
||||
titleEl.title = paneTitleAttr(sessionId, next);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
@@ -943,6 +997,7 @@ export function createStructuredConnection(
|
||||
mountToken++;
|
||||
scrollWorkActive = false;
|
||||
cancelScrollFrame();
|
||||
stopSmoothFollow();
|
||||
cancelFocusFrame();
|
||||
cancelReconnect();
|
||||
closeCurrentSocket();
|
||||
@@ -950,6 +1005,8 @@ export function createStructuredConnection(
|
||||
expandedGroupIds.clear();
|
||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
||||
scrollEl.removeEventListener("touchstart", stopSmoothFollow);
|
||||
document.removeEventListener("pointerdown", closeConfigOnOutsidePress);
|
||||
el.remove();
|
||||
},
|
||||
|
||||
@@ -40,10 +40,15 @@ describe("buildTranscriptRow: user/agent/thought", () => {
|
||||
expect(ps[0].innerHTML).toContain("<code>three</code>");
|
||||
});
|
||||
|
||||
it("renders a thought row as plain text", () => {
|
||||
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } }));
|
||||
it("trims ACP framing whitespace and renders thought-row inline markdown", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("agent_thought", {
|
||||
content: { type: "text", text: "\n\n**Planning comply tool commentary update**\n" },
|
||||
}),
|
||||
);
|
||||
expect(el.className).toContain("st-row-thought");
|
||||
expect(el.textContent).toBe("hmm");
|
||||
expect(el.textContent).toBe("Planning comply tool commentary update");
|
||||
expect(el.querySelector("strong")?.textContent).toBe("Planning comply tool commentary update");
|
||||
});
|
||||
|
||||
it("falls back to a labelled placeholder for non-text content, never crashing", () => {
|
||||
@@ -153,7 +158,10 @@ describe("buildTranscriptRow: tool calls", () => {
|
||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(out.textContent).toContain("match found");
|
||||
head.click();
|
||||
expect(out.hidden).toBe(true);
|
||||
expect(out.hidden).toBe(false);
|
||||
expect(out.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(out.inert).toBe(true);
|
||||
expect(out.classList.contains("is-expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not expand when there is nothing to show", () => {
|
||||
@@ -163,19 +171,107 @@ describe("buildTranscriptRow: tool calls", () => {
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows raw command, input, and output as separately copyable details", () => {
|
||||
it("renders command fields and keeps the exact JSON available", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", {
|
||||
title: "Bash(test)",
|
||||
rawInput: { command: "printf ok", timeout_ms: 1000 },
|
||||
rawOutput: { stdout: "ok", exit_code: 0 },
|
||||
rawInput: { command: "printf ok", cwd: "/workspace", timeout_ms: 1000 },
|
||||
rawOutput: { formatted_output: "ok", exit_code: 0 },
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
const labels = [...el.querySelectorAll(".st-tool-detail-label")].map((node) => node.textContent);
|
||||
expect(labels).toEqual(["Command", "Input", "Output"]);
|
||||
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
|
||||
expect(el.querySelectorAll(".st-tool-detail pre[role=button]")).toHaveLength(3);
|
||||
expect(el.querySelector(".st-tool-fact")!.textContent).toBe("Exit code0");
|
||||
const raw = el.querySelector(".st-tool-raw") as HTMLDetailsElement;
|
||||
expect(raw.open).toBe(false);
|
||||
expect(raw.textContent).toContain('"timeout_ms": 1000');
|
||||
expect(raw.textContent).toContain('"exit_code": 0');
|
||||
});
|
||||
|
||||
it("renders MCP arguments and results without hiding the source payload", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", {
|
||||
title: "mcp.github.search",
|
||||
rawInput: { server: "github", tool: "search", arguments: { query: "ACP" } },
|
||||
rawOutput: { result: { count: 2 }, error: null },
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
const labels = [
|
||||
...el.querySelectorAll(
|
||||
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
|
||||
),
|
||||
].map((node) => node.querySelector(".st-tool-detail-label")?.textContent);
|
||||
expect(labels).toEqual(["Arguments", "Result"]);
|
||||
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"server": "github"');
|
||||
});
|
||||
|
||||
it("renders a recognized web search query", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", {
|
||||
title: "Search the web",
|
||||
rawInput: { type: "webSearch", query: "Agent Client Protocol", action: { type: "search" } },
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
expect(el.querySelector(".st-tool-detail-label")!.textContent).toBe("Query");
|
||||
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("Agent Client Protocol");
|
||||
});
|
||||
|
||||
it("falls back to raw JSON for an unknown provider shape", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", { title: "Custom", rawInput: { peculiar: [1, 2] }, rawOutput: "done" }),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
expect(
|
||||
el.querySelectorAll(
|
||||
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"peculiar"');
|
||||
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"done"');
|
||||
});
|
||||
|
||||
it("renders standardized content before provider-specific and raw data", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", {
|
||||
title: "Bash(test)",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "typed result" } },
|
||||
{ type: "content", content: { type: "resource_link", name: "report", uri: "file:///tmp/report" } },
|
||||
{ type: "terminal", terminalId: "term-1" },
|
||||
],
|
||||
rawInput: { command: "test" },
|
||||
rawOutput: { formatted_output: "typed result", exit_code: 0 },
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
const inner = el.querySelector(".st-tool-out > .st-disclosure-clip > .st-disclosure-inner")!;
|
||||
expect([...inner.children].map((node) => node.className)).toEqual([
|
||||
"st-tool-text st-copyable",
|
||||
"st-tool-resource",
|
||||
"st-tool-terminal",
|
||||
"st-tool-detail",
|
||||
"st-tool-fact",
|
||||
"st-tool-raw",
|
||||
]);
|
||||
expect(el.querySelector(".st-tool-resource")!.textContent).toContain("file:///tmp/report");
|
||||
expect(el.querySelector(".st-tool-terminal")!.textContent).toBe("Terminal · term-1");
|
||||
});
|
||||
|
||||
it("renders safe typed image and audio blocks as media controls", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", {
|
||||
title: "Media",
|
||||
content: [
|
||||
{ type: "content", content: { type: "image", mimeType: "image/png", data: "aW1hZ2U=" } },
|
||||
{ type: "content", content: { type: "audio", mimeType: "audio/mpeg", data: "YXVkaW8=" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
expect((el.querySelector(".st-tool-image") as HTMLImageElement).src).toBe("data:image/png;base64,aW1hZ2U=");
|
||||
expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe("data:audio/mpeg;base64,YXVkaW8=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,9 +287,11 @@ describe("buildToolGroup", () => {
|
||||
const head = group.querySelector<HTMLButtonElement>(".st-tool-group-head")!;
|
||||
const body = group.querySelector<HTMLElement>(".st-tool-group-body")!;
|
||||
expect(head.textContent).toContain("2 tool calls");
|
||||
expect(body.hidden).toBe(true);
|
||||
expect(body.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(body.inert).toBe(true);
|
||||
head.click();
|
||||
expect(body.hidden).toBe(false);
|
||||
expect(body.getAttribute("aria-hidden")).toBe("false");
|
||||
expect(body.classList.contains("is-expanded")).toBe(true);
|
||||
expect(onExpanded).toHaveBeenCalledWith(true);
|
||||
const toolHeads = group.querySelectorAll<HTMLButtonElement>(".st-tool-head");
|
||||
toolHeads[1].click();
|
||||
|
||||
+247
-47
@@ -196,20 +196,21 @@ function makeCopyable(node: HTMLElement, text: string, label: string): void {
|
||||
function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement {
|
||||
const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined;
|
||||
const text = contentBlockText(data?.content);
|
||||
const displayText = variant === "thought" ? text.trim() : text;
|
||||
const wrap = el("div", `st-row st-row-${variant}`);
|
||||
const body = el("div", "st-body");
|
||||
if (variant === "user") {
|
||||
const line = el("div", "st-user-text");
|
||||
line.textContent = text;
|
||||
line.textContent = displayText;
|
||||
body.appendChild(line);
|
||||
} else if (variant === "agent") {
|
||||
renderProse(body, text);
|
||||
renderProse(body, displayText);
|
||||
} else {
|
||||
body.textContent = text;
|
||||
body.innerHTML = renderInlineMarkdown(displayText);
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
|
||||
makeCopyable(wrap, text, label);
|
||||
makeCopyable(wrap, displayText, label);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -245,16 +246,18 @@ function printable(value: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function toolInput(tool: AcpToolCallLike): { command: string | null; input: string | null } {
|
||||
const raw = tool.rawInput;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { command: null, input: printable(raw) };
|
||||
function jsonPrintable(value: unknown): string | null {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? null;
|
||||
} catch {
|
||||
return printable(value);
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const command = typeof record.command === "string" ? record.command : null;
|
||||
if (!command) return { command: null, input: printable(raw) };
|
||||
const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== "command"));
|
||||
return { command, input: Object.keys(rest).length > 0 ? printable(rest) : null };
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function appendToolDetail(container: HTMLElement, label: string, text: string): void {
|
||||
@@ -268,6 +271,213 @@ function appendToolDetail(container: HTMLElement, label: string, text: string):
|
||||
container.appendChild(detail);
|
||||
}
|
||||
|
||||
function appendToolValue(container: HTMLElement, label: string, value: unknown, duplicates?: Set<string>): void {
|
||||
const text = printable(value);
|
||||
if (text !== null && text !== "" && !duplicates?.has(text)) appendToolDetail(container, label, text);
|
||||
}
|
||||
|
||||
function appendExitCode(container: HTMLElement, value: unknown): void {
|
||||
if (typeof value !== "number") return;
|
||||
const line = el("div", "st-tool-fact");
|
||||
const label = el("span", "st-tool-fact-label");
|
||||
label.textContent = "Exit code";
|
||||
const code = el("code", value === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed");
|
||||
code.textContent = String(value);
|
||||
line.append(label, code);
|
||||
container.appendChild(line);
|
||||
}
|
||||
|
||||
function appendCommandPayload(
|
||||
container: HTMLElement,
|
||||
input: Record<string, unknown>,
|
||||
output: unknown,
|
||||
contentText: Set<string>,
|
||||
): boolean {
|
||||
if (typeof input.command !== "string") return false;
|
||||
appendToolDetail(container, "Command", input.command);
|
||||
appendToolValue(container, "Working directory", input.cwd);
|
||||
|
||||
const result = record(output);
|
||||
if (result) {
|
||||
appendToolValue(container, "Output", result.formatted_output ?? result.stdout ?? result.output, contentText);
|
||||
appendExitCode(container, result.exit_code);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function appendMcpPayload(
|
||||
container: HTMLElement,
|
||||
input: Record<string, unknown>,
|
||||
output: unknown,
|
||||
contentText: Set<string>,
|
||||
): boolean {
|
||||
if (typeof input.server !== "string" || typeof input.tool !== "string" || !("arguments" in input)) return false;
|
||||
appendToolValue(container, "Arguments", input.arguments);
|
||||
const result = record(output);
|
||||
if (result) {
|
||||
appendToolValue(container, "Result", result.result, contentText);
|
||||
appendToolValue(container, "Error", result.error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function appendSearchPayload(
|
||||
container: HTMLElement,
|
||||
input: Record<string, unknown>,
|
||||
tool: AcpToolCallLike,
|
||||
): boolean {
|
||||
if (typeof input.query !== "string" || (tool.kind !== "search" && input.type !== "webSearch")) return false;
|
||||
appendToolDetail(container, "Query", input.query);
|
||||
appendToolValue(container, "Action", input.action);
|
||||
return true;
|
||||
}
|
||||
|
||||
function appendFunctionPayload(
|
||||
container: HTMLElement,
|
||||
input: Record<string, unknown>,
|
||||
output: unknown,
|
||||
contentText: Set<string>,
|
||||
): boolean {
|
||||
if (!("arguments" in input)) return false;
|
||||
appendToolValue(container, "Arguments", input.arguments);
|
||||
const result = record(output);
|
||||
if (result) appendToolValue(container, "Result", result.output, contentText);
|
||||
return true;
|
||||
}
|
||||
|
||||
function appendKnownToolPayload(
|
||||
container: HTMLElement,
|
||||
tool: AcpToolCallLike,
|
||||
content: AcpToolCallContent[],
|
||||
): void {
|
||||
const input = record(tool.rawInput);
|
||||
if (!input) return;
|
||||
const contentText = new Set(
|
||||
content
|
||||
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
|
||||
.filter((block) => block.content.type === "text")
|
||||
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
|
||||
);
|
||||
if (appendCommandPayload(container, input, tool.rawOutput, contentText)) return;
|
||||
if (appendMcpPayload(container, input, tool.rawOutput, contentText)) return;
|
||||
if (appendSearchPayload(container, input, tool)) return;
|
||||
appendFunctionPayload(container, input, tool.rawOutput, contentText);
|
||||
}
|
||||
|
||||
function safeMediaSource(
|
||||
mimeType: string | undefined,
|
||||
data: string | undefined,
|
||||
family: "image" | "audio",
|
||||
): string | null {
|
||||
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
|
||||
return `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
|
||||
function appendContentBlock(container: HTMLElement, block: AcpContentBlock): void {
|
||||
if (block.type === "text") {
|
||||
if (!block.text) return;
|
||||
const text = el("div", "st-tool-text");
|
||||
text.textContent = block.text;
|
||||
makeCopyable(text, block.text, "output");
|
||||
container.appendChild(text);
|
||||
return;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
const source = safeMediaSource(block.mimeType, block.data, "image");
|
||||
if (source) {
|
||||
const image = document.createElement("img");
|
||||
image.className = "st-tool-image";
|
||||
image.src = source;
|
||||
image.alt = "Tool output";
|
||||
image.loading = "lazy";
|
||||
container.appendChild(image);
|
||||
return;
|
||||
}
|
||||
appendToolValue(container, "Image", block.uri ?? block.mimeType ?? "Unavailable");
|
||||
return;
|
||||
}
|
||||
if (block.type === "audio") {
|
||||
const source = safeMediaSource(block.mimeType, block.data, "audio");
|
||||
if (source) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.className = "st-tool-audio";
|
||||
audio.controls = true;
|
||||
audio.src = source;
|
||||
container.appendChild(audio);
|
||||
return;
|
||||
}
|
||||
appendToolValue(container, "Audio", block.mimeType ?? "Unavailable");
|
||||
return;
|
||||
}
|
||||
if (block.type === "resource_link") {
|
||||
const resource = el("div", "st-tool-resource");
|
||||
const name = el("span", "st-tool-resource-name");
|
||||
name.textContent = block.title ?? block.name ?? "Resource";
|
||||
resource.appendChild(name);
|
||||
if (block.uri) {
|
||||
const uri = el("code", "st-tool-resource-uri");
|
||||
uri.textContent = block.uri;
|
||||
makeCopyable(uri, block.uri, "resource URI");
|
||||
resource.appendChild(uri);
|
||||
}
|
||||
if (block.description) {
|
||||
const description = el("span", "st-tool-resource-description");
|
||||
description.textContent = block.description;
|
||||
resource.appendChild(description);
|
||||
}
|
||||
container.appendChild(resource);
|
||||
return;
|
||||
}
|
||||
const resource = block.resource;
|
||||
if (typeof resource?.text === "string") {
|
||||
appendToolDetail(container, resource.uri ? `Resource · ${resource.uri}` : "Resource", resource.text);
|
||||
} else {
|
||||
appendToolValue(container, "Resource", resource);
|
||||
}
|
||||
}
|
||||
|
||||
function appendStandardContent(container: HTMLElement, content: AcpToolCallContent[]): void {
|
||||
for (const block of content) {
|
||||
if (block.type === "content") appendContentBlock(container, block.content);
|
||||
if (block.type === "diff") container.appendChild(buildDiffBlock(block));
|
||||
if (block.type === "terminal") {
|
||||
const note = el("div", "st-tool-terminal");
|
||||
note.textContent = block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal";
|
||||
container.appendChild(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendRawPayload(container: HTMLElement, tool: AcpToolCallLike): void {
|
||||
const input = jsonPrintable(tool.rawInput);
|
||||
const output = jsonPrintable(tool.rawOutput);
|
||||
if (input === null && output === null) return;
|
||||
|
||||
const disclosure = document.createElement("details");
|
||||
disclosure.className = "st-tool-raw";
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "Raw JSON";
|
||||
disclosure.appendChild(summary);
|
||||
const body = el("div", "st-tool-raw-body");
|
||||
if (input !== null) appendToolDetail(body, "Input", input);
|
||||
if (output !== null) appendToolDetail(body, "Output", output);
|
||||
disclosure.appendChild(body);
|
||||
container.appendChild(disclosure);
|
||||
}
|
||||
|
||||
function setDisclosure(element: HTMLElement, expanded: boolean, animate = false): void {
|
||||
element.hidden = false;
|
||||
if (animate && expanded && !element.classList.contains("is-expanded")) {
|
||||
// Establish the collapsed grid track before opening it in the same event
|
||||
// turn. Without this read, a freshly lazy-built tool body has no painted
|
||||
// starting state and the browser quite reasonably skips the transition.
|
||||
void element.offsetHeight;
|
||||
}
|
||||
element.classList.toggle("is-expanded", expanded);
|
||||
element.setAttribute("aria-hidden", String(!expanded));
|
||||
element.inert = !expanded;
|
||||
}
|
||||
|
||||
/** `+added -removed` across every diff block in a tool call's content —
|
||||
* real derived data, never a fabricated duration. */
|
||||
function toolDiffMeta(content: AcpToolCallContent[]): string {
|
||||
@@ -330,48 +540,34 @@ function buildToolRow(
|
||||
}
|
||||
card.appendChild(head);
|
||||
|
||||
const out = el("div", "st-tool-out");
|
||||
const out = el("div", "st-tool-out st-disclosure");
|
||||
const outClip = el("div", "st-disclosure-clip");
|
||||
const outInner = el("div", "st-disclosure-inner");
|
||||
outClip.appendChild(outInner);
|
||||
out.appendChild(outClip);
|
||||
out.hidden = true;
|
||||
let outBuilt = false;
|
||||
const buildOut = () => {
|
||||
if (outBuilt) return;
|
||||
outBuilt = true;
|
||||
const { command, input } = toolInput(tool);
|
||||
if (command) appendToolDetail(out, "Command", command);
|
||||
if (input !== null) appendToolDetail(out, "Input", input);
|
||||
const rawOutput = printable(tool.rawOutput);
|
||||
if (rawOutput !== null) appendToolDetail(out, "Output", rawOutput);
|
||||
const texts = content
|
||||
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
|
||||
.map((c) => contentBlockText(c.content))
|
||||
.filter(Boolean);
|
||||
const contentText = texts.join("\n");
|
||||
if (contentText && contentText !== rawOutput) {
|
||||
const textPanel = el("div", "st-tool-text");
|
||||
textPanel.textContent = contentText;
|
||||
makeCopyable(textPanel, contentText, "output");
|
||||
out.appendChild(textPanel);
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block.type === "diff") out.appendChild(buildDiffBlock(block));
|
||||
if (block.type === "terminal") {
|
||||
const note = el("div", "st-tool-text");
|
||||
note.textContent = "[embedded terminal]";
|
||||
out.appendChild(note);
|
||||
}
|
||||
}
|
||||
if (out.children.length === 0) out.hidden = true;
|
||||
appendStandardContent(outInner, content);
|
||||
appendKnownToolPayload(outInner, tool, content);
|
||||
appendRawPayload(outInner, tool);
|
||||
};
|
||||
card.appendChild(out);
|
||||
|
||||
const setExpanded = (nextExpanded: boolean) => {
|
||||
const setExpanded = (nextExpanded: boolean, animate = false) => {
|
||||
if (nextExpanded) buildOut();
|
||||
if (out.children.length === 0) nextExpanded = false;
|
||||
if (outInner.children.length === 0) nextExpanded = false;
|
||||
head.setAttribute("aria-expanded", String(nextExpanded));
|
||||
out.hidden = !nextExpanded;
|
||||
if (outInner.children.length === 0) {
|
||||
out.hidden = true;
|
||||
} else {
|
||||
setDisclosure(out, nextExpanded, animate);
|
||||
}
|
||||
};
|
||||
head.addEventListener("click", () => {
|
||||
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
||||
setExpanded(head.getAttribute("aria-expanded") !== "true", true);
|
||||
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
|
||||
});
|
||||
setExpanded(expanded);
|
||||
@@ -407,13 +603,17 @@ export function buildToolGroup(
|
||||
.join(" · ");
|
||||
head.append(glyph, title, names);
|
||||
|
||||
const body = el("div", "st-tool-group-body");
|
||||
body.hidden = !expanded;
|
||||
body.append(...children);
|
||||
const body = el("div", "st-tool-group-body st-disclosure");
|
||||
const bodyClip = el("div", "st-disclosure-clip");
|
||||
const bodyInner = el("div", "st-disclosure-inner");
|
||||
bodyInner.append(...children);
|
||||
bodyClip.appendChild(bodyInner);
|
||||
body.appendChild(bodyClip);
|
||||
setDisclosure(body, expanded);
|
||||
head.addEventListener("click", () => {
|
||||
expanded = !expanded;
|
||||
head.setAttribute("aria-expanded", String(expanded));
|
||||
body.hidden = !expanded;
|
||||
setDisclosure(body, expanded, true);
|
||||
onExpandedChange?.(expanded);
|
||||
});
|
||||
wrap.append(head, body);
|
||||
|
||||
+189
-26
@@ -2533,7 +2533,7 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
.st-copyable::after {
|
||||
content: "copy";
|
||||
content: none;
|
||||
position: absolute;
|
||||
top: var(--sp-1);
|
||||
right: var(--sp-1);
|
||||
@@ -2549,27 +2549,30 @@ input.leaf-rename:focus {
|
||||
transition: opacity var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-copyable:hover,
|
||||
.st-copyable:focus-visible {
|
||||
background: var(--surface-2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.st-copyable:hover::after,
|
||||
.st-copyable:focus-visible::after,
|
||||
.st-copyable[data-copy-state="copied"]::after,
|
||||
.st-copyable[data-copy-state="error"]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.st-copyable:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.st-copyable[data-copy-state="copied"]::after {
|
||||
content: "copied";
|
||||
color: var(--live);
|
||||
}
|
||||
.st-copyable:hover::after {
|
||||
content: "copy";
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.st-copyable[data-copy-state="error"]::after {
|
||||
content: "copy failed";
|
||||
color: var(--danger);
|
||||
.st-copyable[data-copy-state="copied"]:hover::after {
|
||||
content: "copied";
|
||||
color: var(--live);
|
||||
}
|
||||
|
||||
.st-copyable[data-copy-state="error"]:hover::after {
|
||||
content: "copy failed";
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
/* tool rows: bordered, compact, deliberately not lifted */
|
||||
@@ -2593,6 +2596,7 @@ input.leaf-rename:focus {
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
min-width: 0;
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-tool-head:hover {
|
||||
@@ -2642,11 +2646,38 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
.st-tool-out {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
.st-disclosure {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows var(--dur) var(--ease-out),
|
||||
opacity var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-disclosure.is-expanded {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.st-disclosure-clip {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.st-tool-out.is-expanded {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
|
||||
.st-tool-out .st-disclosure-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
/* `display: flex` above outranks the UA sheet's `[hidden]` (same pitfall as
|
||||
@@ -2689,6 +2720,109 @@ input.leaf-rename:focus {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.st-tool-fact {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-11);
|
||||
}
|
||||
|
||||
.st-tool-fact code {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.st-tool-exit-ok {
|
||||
color: var(--live);
|
||||
}
|
||||
|
||||
.st-tool-exit-failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.st-tool-image {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: min(100%, 720px);
|
||||
max-height: 480px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.st-tool-audio {
|
||||
display: block;
|
||||
width: min(100%, 480px);
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
|
||||
.st-tool-resource {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: var(--sp-1) var(--sp-2);
|
||||
min-width: 0;
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
background: var(--bg);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.st-tool-resource-name {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.st-tool-resource-uri {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.st-tool-resource-description {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.st-tool-terminal {
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.st-tool-raw {
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-11);
|
||||
}
|
||||
|
||||
.st-tool-raw summary {
|
||||
width: fit-content;
|
||||
padding-block: var(--sp-1);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-tool-raw summary:hover,
|
||||
.st-tool-raw summary:focus-visible {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.st-tool-raw summary:focus-visible {
|
||||
border-radius: var(--r-1);
|
||||
outline: 2px solid var(--accent-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.st-tool-raw-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding-top: var(--sp-1);
|
||||
}
|
||||
|
||||
.st-tool-group {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
@@ -2715,6 +2849,7 @@ input.leaf-rename:focus {
|
||||
gap: var(--sp-2);
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
transition: color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-tool-group-head:hover {
|
||||
@@ -2745,12 +2880,16 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
.st-tool-group-body {
|
||||
padding-top: var(--sp-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.st-tool-group-body > .st-row-tool {
|
||||
.st-tool-group-body .st-disclosure-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
|
||||
@@ -2758,6 +2897,21 @@ input.leaf-rename:focus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Live transcript rows resolve into the stream through a short occlusion,
|
||||
matching data becoming concrete without replaying on token patches. */
|
||||
.st-row-enter {
|
||||
animation: structured-row-enter var(--dur) var(--ease-out) both;
|
||||
}
|
||||
|
||||
@keyframes structured-row-enter {
|
||||
from {
|
||||
opacity: 0.2;
|
||||
transform: translateY(3px);
|
||||
clip-path: inset(0 0 18% 0);
|
||||
filter: blur(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.st-diff-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3015,6 +3169,15 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.structured-composer {
|
||||
margin: var(--sp-1) 0 0;
|
||||
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
border-radius: var(--r-2) var(--r-2) 0 0;
|
||||
}
|
||||
|
||||
.structured-config-menu {
|
||||
right: calc(-1 * (var(--control-h) + var(--sp-2)));
|
||||
}
|
||||
@@ -3117,18 +3280,18 @@ input.leaf-rename:focus {
|
||||
.act,
|
||||
.st-copyable,
|
||||
.st-copyable::after,
|
||||
.st-disclosure,
|
||||
.st-tool-head,
|
||||
.st-tool-group-glyph {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
|
||||
.structured-activity-pulse {
|
||||
.st-row-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.st-copyable::after {
|
||||
opacity: 0.7;
|
||||
.structured-activity-pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-7
@@ -56,7 +56,7 @@ export interface Session {
|
||||
* how the command bar's empty query sorts a never-focused session after every
|
||||
* session that has one (`docs/COMMAND-BAR.md`). */
|
||||
last_focused_at?: IsoTimestamp | null;
|
||||
/** The ACP session id the `claude-code-acp` adapter returned from
|
||||
/** The ACP session id the selected provider adapter returned from
|
||||
* `session/new` (#134). Only a `Structured` session whose handshake
|
||||
* succeeded has one; a respawn passes it to `session/load` (#135). */
|
||||
acp_session_id?: string | null;
|
||||
@@ -485,14 +485,28 @@ export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "d
|
||||
|
||||
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
||||
|
||||
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a
|
||||
* generic placeholder rather than being dropped silently. */
|
||||
export interface AcpEmbeddedResource {
|
||||
uri?: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
}
|
||||
|
||||
/** `ContentBlock`, including the fields needed to render typed tool output. */
|
||||
export type AcpContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; mimeType?: string }
|
||||
| { type: "audio"; mimeType?: string }
|
||||
| { type: "resource_link"; uri?: string; name?: string }
|
||||
| { type: "resource"; resource?: unknown };
|
||||
| { type: "image"; data?: string; mimeType?: string; uri?: string | null }
|
||||
| { type: "audio"; data?: string; mimeType?: string }
|
||||
| {
|
||||
type: "resource_link";
|
||||
uri?: string;
|
||||
name?: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
mimeType?: string | null;
|
||||
size?: number | null;
|
||||
}
|
||||
| { type: "resource"; resource?: AcpEmbeddedResource };
|
||||
|
||||
/** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */
|
||||
export interface AcpContentChunk {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
// The nix derivation's src is `./web`, so the shared fixtures/ directory at
|
||||
// the repo root does not exist there and `*.test.ts` cannot resolve its
|
||||
// imports. Tests are not part of the shipped bundle; `pnpm typecheck` still
|
||||
// checks them from a full checkout.
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user