Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19087ec613 | ||
|
|
88afd0df3f | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 | ||
|
|
0543f66469 | ||
|
|
4a2f7e98b4 | ||
|
|
8c151a5fb2 | ||
|
|
8d0886f9b7 | ||
|
|
9f82400f0a | ||
|
|
059316cbac | ||
|
|
0b1c9e15cf | ||
|
|
e30084ea24 | ||
|
|
bb898a75f3 | ||
|
|
c8e90af91c | ||
|
|
d0df60ac87 | ||
|
|
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,16 +51,33 @@ 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,
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve: false,
|
||||
};
|
||||
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 +256,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(),
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "opencode".into(),
|
||||
command: "opencode".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "zsh".into(),
|
||||
label: "Shell".into(),
|
||||
command: "zsh".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
||||
id: CUSTOM_PRESET_ID.into(),
|
||||
label: "Custom…".into(),
|
||||
command: String::new(),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +519,7 @@ auto_install = false
|
||||
id: "fish".into(),
|
||||
label: "fish".into(),
|
||||
command: "fish".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -551,6 +556,7 @@ auto_install = false
|
||||
id: " ".into(),
|
||||
label: "x".into(),
|
||||
command: "x".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -561,6 +567,7 @@ auto_install = false
|
||||
id: "x".into(),
|
||||
label: "x".into(),
|
||||
command: "".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -578,11 +585,13 @@ auto_install = false
|
||||
id: "a".into(),
|
||||
label: "a".into(),
|
||||
command: "a".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "a".into(),
|
||||
label: "a2".into(),
|
||||
command: "a2".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
@@ -597,6 +606,7 @@ auto_install = false
|
||||
id: "custom".into(),
|
||||
label: "Custom".into(),
|
||||
command: "whatever".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{any, delete, get, post};
|
||||
@@ -70,6 +71,10 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
||||
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_ATTACHMENT,
|
||||
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
|
||||
)
|
||||
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
||||
.route(
|
||||
@@ -432,6 +437,46 @@ async fn structured_replay(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AttachmentQuery {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn structured_attachment(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(query): Query<AttachmentQuery>,
|
||||
bytes: Bytes,
|
||||
) -> Response {
|
||||
let name = query.name;
|
||||
if name.is_empty()
|
||||
|| name.len() > 180
|
||||
|| name == "."
|
||||
|| name == ".."
|
||||
|| name
|
||||
.chars()
|
||||
.any(|c| c.is_control() || c == '/' || c == '\\')
|
||||
{
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_filename",
|
||||
"use a filename without directory separators, up to 180 bytes",
|
||||
);
|
||||
}
|
||||
match s.manager.store_acp_attachment(id, &name, &bytes).await {
|
||||
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
|
||||
Ok(None) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"not_structured",
|
||||
"session is not a live structured session",
|
||||
),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_prompt(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
||||
@@ -122,6 +122,9 @@ pub struct SessionRecord {
|
||||
/// Completion signal for the task draining ACP notifications into the
|
||||
/// event log. Shutdown waits for it before returning.
|
||||
acp_event_pump: Option<watch::Receiver<bool>>,
|
||||
/// When `true`, the permission pump auto-responds `AllowAlways` to every
|
||||
/// ACP permission request without surfacing it to clients.
|
||||
pub auto_approve: bool,
|
||||
}
|
||||
|
||||
impl SessionRecord {
|
||||
@@ -604,6 +607,36 @@ impl SessionManager {
|
||||
Ok(Some(replay))
|
||||
}
|
||||
|
||||
/// Store attachment bytes for a live structured session.
|
||||
pub async fn store_acp_attachment(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<Option<std::path::PathBuf>, SessionError> {
|
||||
{
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if !record.is_live_structured() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let dir = self.inner.sidecar.dir().join(format!("{id}.attachments"));
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let dir = tokio::fs::canonicalize(dir).await?;
|
||||
let path = dir.join(format!("{}-{name}", Uuid::new_v4()));
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.await?;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
file.write_all(bytes).await?;
|
||||
file.sync_all().await?;
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
/// Subscribe to the two projections of a structured session's ACP log.
|
||||
///
|
||||
/// The transcript snapshot and whole reduced state are folded once by the
|
||||
@@ -1414,7 +1447,7 @@ impl SessionManager {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let event_pump = event_pump.map(|(acp, events)| {
|
||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||
start_acp_permission_pump(&self.inner, id, &acp, events);
|
||||
start_acp_permission_pump(&self.inner, id, &acp, events, req.auto_approve);
|
||||
done
|
||||
});
|
||||
sessions.insert(
|
||||
@@ -1435,6 +1468,7 @@ impl SessionManager {
|
||||
acp_events,
|
||||
structured_activity_watermark: None,
|
||||
acp_event_pump: event_pump,
|
||||
auto_approve: req.auto_approve,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1647,6 +1681,7 @@ impl SessionManager {
|
||||
acp_events: None,
|
||||
structured_activity_watermark: None,
|
||||
acp_event_pump: None,
|
||||
auto_approve: false,
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
@@ -1824,11 +1859,14 @@ impl SessionManager {
|
||||
acp_events,
|
||||
structured_activity_watermark: None,
|
||||
acp_event_pump: None,
|
||||
// Recovery cannot know the original preset's flag; the
|
||||
// operator re-enables it by restarting with the preset.
|
||||
auto_approve: false,
|
||||
},
|
||||
);
|
||||
if let Some((acp, events)) = event_pump {
|
||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||
start_acp_permission_pump(&self.inner, id, &acp, events);
|
||||
start_acp_permission_pump(&self.inner, id, &acp, events, false);
|
||||
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||
record.acp_event_pump = Some(done);
|
||||
}
|
||||
@@ -2327,6 +2365,7 @@ fn start_acp_permission_pump(
|
||||
id: Uuid,
|
||||
acp: &Arc<AcpSession>,
|
||||
events: Arc<Mutex<AcpEventStream>>,
|
||||
auto_approve: bool,
|
||||
) {
|
||||
let Some(mut requests) = acp.take_permission_requests() else {
|
||||
return;
|
||||
@@ -2340,6 +2379,12 @@ fn start_acp_permission_pump(
|
||||
response,
|
||||
}) = requests.recv().await
|
||||
{
|
||||
if auto_approve {
|
||||
let outcome =
|
||||
permission_outcome(ApprovalDecision::AllowForSession, &request.options);
|
||||
let _ = response.send(outcome);
|
||||
continue;
|
||||
}
|
||||
let mut events = events.lock().await;
|
||||
if events.projection.active_turn() != Some(turn) {
|
||||
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
||||
@@ -3060,6 +3105,7 @@ mod tests {
|
||||
env: std::collections::HashMap::new(),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ fn structured_command(
|
||||
env,
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +706,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1187,6 +1189,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
||||
branch: "cancelled-create".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let create = {
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
|
||||
env,
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -234,6 +235,60 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_attachments_preserve_bytes_and_reject_paths() {
|
||||
let h = spawn("attachments").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
let client = reqwest::Client::new();
|
||||
let endpoint = url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/attachments"),
|
||||
);
|
||||
let bytes = vec![0, 255, 10, 13];
|
||||
let response = client
|
||||
.post(&endpoint)
|
||||
.query(&[("name", "sample.bin")])
|
||||
.body(bytes.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
let body: serde_json::Value = response.json().await.unwrap();
|
||||
let path = body["path"].as_str().unwrap();
|
||||
assert!(Path::new(path).is_absolute());
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
for name in ["../escape", "a/b", "a\\b", "..", ""] {
|
||||
let response = client
|
||||
.post(&endpoint)
|
||||
.query(&[("name", name)])
|
||||
.body("text")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 400, "{name}");
|
||||
}
|
||||
let response = client
|
||||
.post(&endpoint)
|
||||
.query(&[("name", "large.bin")])
|
||||
.body(vec![0; 20 * 1024 * 1024 + 1])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 413);
|
||||
let response = client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
|
||||
))
|
||||
.query(&[("name", "file.txt")])
|
||||
.body("text")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -493,6 +548,7 @@ async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -626,6 +682,7 @@ async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -768,6 +825,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
};
|
||||
let created: Session = reqwest::Client::new()
|
||||
.post(url(h.addr, "/api/sessions"))
|
||||
@@ -985,6 +1043,7 @@ async fn patch_session_renames_it() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1028,6 +1087,7 @@ async fn post_focus_stamps_last_focused_at() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1062,6 +1122,7 @@ async fn delete_owned_session_kills_it() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1084,6 +1145,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -1198,6 +1260,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1251,6 +1314,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1281,6 +1345,7 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1383,6 +1448,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1495,6 +1561,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -70,6 +70,7 @@ impl Fx {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
branch: "feat/indicator".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
branch: "feat/keep".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
branch: "feat/pushed".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
||||
branch: "feat/forced".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
branch: "feat/clear".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
branch: "feat/rusty".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
||||
branch: "feat/exited".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
branch: "feat/clean".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
branch: "feat/one".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create first");
|
||||
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
branch: "feat/two".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create second");
|
||||
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +112,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
@@ -260,6 +263,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
@@ -319,6 +323,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client
|
||||
@@ -383,13 +388,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 +402,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 +439,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
|
||||
@@ -823,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("spawn dirty");
|
||||
@@ -872,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
|
||||
env: fake.env.clone(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("spawn structured session");
|
||||
|
||||
@@ -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
|
||||
@@ -383,6 +383,10 @@ pub struct CreateSessionRequest {
|
||||
/// untouched.
|
||||
#[serde(default)]
|
||||
pub view: SessionView,
|
||||
/// When `true`, the daemon auto-approves every ACP permission request
|
||||
/// for this structured session.
|
||||
#[serde(default)]
|
||||
pub auto_approve: bool,
|
||||
}
|
||||
|
||||
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
||||
@@ -522,6 +526,11 @@ pub struct SpawnPreset {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
/// When `true`, the daemon auto-approves every ACP permission request
|
||||
/// for structured sessions spawned from this preset instead of surfacing
|
||||
/// them to the client.
|
||||
#[serde(default)]
|
||||
pub auto_approve: bool,
|
||||
}
|
||||
|
||||
/// Build provenance of the running daemon: the commit it was compiled
|
||||
@@ -1251,6 +1260,7 @@ mod tests {
|
||||
create_branch: true,
|
||||
}),
|
||||
view: SessionView::Terminal,
|
||||
auto_approve: false,
|
||||
};
|
||||
assert_eq!(req, round_trip(&req));
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ pub fn structured_prompt(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/prompt")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
|
||||
pub fn structured_attachment(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/attachments")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
|
||||
pub fn structured_cancel(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/cancel")
|
||||
@@ -113,6 +118,7 @@ pub mod patterns {
|
||||
pub const STRUCTURED: &str = "/api/structured/:id";
|
||||
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
||||
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
||||
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
|
||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! `dispatch` / the overlay routing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -54,6 +55,36 @@ enum OverlayDecision {
|
||||
Pass,
|
||||
}
|
||||
|
||||
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
|
||||
let encoded = base64_encode(text.as_bytes());
|
||||
let mut output = std::io::stdout();
|
||||
write!(output, "\x1b]52;c;{encoded}\x07")?;
|
||||
output.flush()
|
||||
}
|
||||
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let bits = (u32::from(chunk[0]) << 16)
|
||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
|
||||
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
|
||||
output.push(if chunk.len() > 1 {
|
||||
TABLE[((bits >> 6) & 0x3f) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
output.push(if chunk.len() > 2 {
|
||||
TABLE[(bits & 0x3f) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
cfg: TuiConfig,
|
||||
home_dir: String,
|
||||
@@ -340,6 +371,10 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if self.handle_structured_mouse(&key) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(effects) = self.handle_structured_key(&key) {
|
||||
self.perform(effects).await;
|
||||
return Ok(false);
|
||||
@@ -423,6 +458,61 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
/// Transcript blocks are pointer targets: click selects the whole block,
|
||||
/// gives it the selection surface, and copies its complete text.
|
||||
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
|
||||
let InputKey::Mouse {
|
||||
kind,
|
||||
region: Region::Pane,
|
||||
row,
|
||||
..
|
||||
} = key
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
|
||||
return false;
|
||||
}
|
||||
let Some(sid) = self.state.active_session() else {
|
||||
return false;
|
||||
};
|
||||
let Some(handle) = self.structured.get(&sid) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pane) = self.region.pane else {
|
||||
return false;
|
||||
};
|
||||
let offset = usize::from(row.saturating_sub(pane.y));
|
||||
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
|
||||
if offset >= usize::from(transcript_height) {
|
||||
return true;
|
||||
}
|
||||
let mut state = handle.state.lock().expect("structured state mutex");
|
||||
let rendered_line = state.scroll.saturating_add(offset);
|
||||
if !state.select_rendered_line(rendered_line) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = state.selected.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
|
||||
return true;
|
||||
};
|
||||
if *kind == MouseKind::Hover {
|
||||
return true;
|
||||
}
|
||||
let text = crate::structured_pane::copy_text(transcript);
|
||||
drop(state);
|
||||
if text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match copy_to_terminal_clipboard(&text) {
|
||||
Ok(()) => self.state.toast("copied transcript block"),
|
||||
Err(error) => self.state.toast(format!("copy failed: {error}")),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||
/// the daemon.
|
||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipboard_payload_uses_standard_base64() {
|
||||
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
||||
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
|
||||
}
|
||||
use crate::action::Action;
|
||||
use crate::draft::DraftField;
|
||||
use crate::keymap::Mode;
|
||||
@@ -3009,6 +3105,7 @@ mod tests {
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
};
|
||||
app.submit_spawn(req).await;
|
||||
|
||||
@@ -3053,17 +3150,20 @@ 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,
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
+149
-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +734,10 @@ impl DraftPane {
|
||||
);
|
||||
}
|
||||
let worktree = self.build_worktree()?;
|
||||
let auto_approve = self
|
||||
.presets
|
||||
.get(self.selected_preset)
|
||||
.is_some_and(|p| p.auto_approve);
|
||||
Ok(CreateSessionRequest {
|
||||
view: if self.structured_view && self.supports_structured_view() {
|
||||
SessionView::Structured
|
||||
@@ -729,6 +749,7 @@ impl DraftPane {
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -827,7 +848,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 +1151,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.
|
||||
@@ -1776,6 +1797,7 @@ mod tests {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
}]);
|
||||
assert_eq!(
|
||||
draft.command(),
|
||||
@@ -1798,11 +1820,13 @@ mod tests {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
@@ -1822,6 +1846,7 @@ mod tests {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
}]);
|
||||
assert!(draft.is_custom());
|
||||
assert_eq!(draft.command(), "bash -l");
|
||||
@@ -1888,18 +1913,42 @@ 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(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "OpenCode".into(),
|
||||
command: "opencode".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
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 +1977,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 +2001,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 +2032,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 +2053,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 {
|
||||
@@ -2013,21 +2078,34 @@ mod tests {
|
||||
id: "claude-wrapped".into(),
|
||||
label: "Claude (npx)".into(),
|
||||
command: "npx claude-code --resume".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex-wrapped".into(),
|
||||
label: "Codex (npx)".into(),
|
||||
command: "npx codex exec".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
|
||||
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 +2114,50 @@ 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(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
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 +2166,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);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
|
||||
branch: handle.to_string(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -184,6 +185,7 @@ mod tests {
|
||||
id: id.into(),
|
||||
label: id.into(),
|
||||
command: command.into(),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ pub enum MouseKind {
|
||||
Click,
|
||||
DblClick,
|
||||
Drag,
|
||||
Hover,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
}
|
||||
@@ -148,6 +149,7 @@ impl InputKey {
|
||||
MouseKind::Click => "click",
|
||||
MouseKind::DblClick => "dblclick",
|
||||
MouseKind::Drag => "drag",
|
||||
MouseKind::Hover => "hover",
|
||||
MouseKind::ScrollUp => "scrollup",
|
||||
MouseKind::ScrollDown => "scrolldown",
|
||||
};
|
||||
@@ -178,6 +180,7 @@ impl InputKey {
|
||||
"click" => MouseKind::Click,
|
||||
"dblclick" => MouseKind::DblClick,
|
||||
"drag" => MouseKind::Drag,
|
||||
"hover" => MouseKind::Hover,
|
||||
"scrollup" => MouseKind::ScrollUp,
|
||||
"scrolldown" => MouseKind::ScrollDown,
|
||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||
@@ -297,6 +300,7 @@ where
|
||||
let mk = match kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||
MouseEventKind::Moved => MouseKind::Hover,
|
||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||
_ => return None,
|
||||
@@ -681,6 +685,9 @@ mod tests {
|
||||
let s = "mouse:dblclick(sidebar-row)";
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s);
|
||||
let s = "mouse:hover(pane)";
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -308,11 +308,12 @@ fn fallback_after_removal(
|
||||
|
||||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
let mut out = Vec::new();
|
||||
for row in &state.rows {
|
||||
for (index, row) in state.rows.iter().enumerate() {
|
||||
if row.kind == TranscriptRowKind::Plan {
|
||||
continue;
|
||||
}
|
||||
let row_width = width.saturating_sub(1).max(1);
|
||||
let start = out.len();
|
||||
match row.kind {
|
||||
TranscriptRowKind::User => push_wrapped(
|
||||
&mut out,
|
||||
@@ -356,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
),
|
||||
TranscriptRowKind::Plan => {}
|
||||
}
|
||||
if out.len() > start
|
||||
&& state.rows[index + 1..]
|
||||
.iter()
|
||||
.any(|next| next.kind != TranscriptRowKind::Plan)
|
||||
{
|
||||
// A blank surface line is the vertical padding that keeps adjacent
|
||||
// transcript blocks readable at a glance.
|
||||
out.push(VisualLine {
|
||||
text: String::new(),
|
||||
row_id: None,
|
||||
rail: false,
|
||||
tone: Tone::Normal,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text supplied to the terminal clipboard when a transcript block is
|
||||
/// clicked. It preserves the full tool detail rather than its compact preview.
|
||||
pub fn copy_text(row: &TranscriptRow) -> String {
|
||||
match row.kind {
|
||||
TranscriptRowKind::User
|
||||
| TranscriptRowKind::AgentText
|
||||
| TranscriptRowKind::AgentThought => content_text(row),
|
||||
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
|
||||
let (name, argument) = tool_title(row);
|
||||
let mut lines = vec![if argument.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{name} {argument}")
|
||||
}];
|
||||
lines.extend(tool_content_lines(row, None));
|
||||
lines.join("\n")
|
||||
}
|
||||
TranscriptRowKind::Approval => row
|
||||
.data
|
||||
.get("toolCall")
|
||||
.and_then(|tool| tool.get("title"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("permission requested")
|
||||
.into(),
|
||||
TranscriptRowKind::Notice => "[notice]".into(),
|
||||
TranscriptRowKind::Plan => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_wrapped(
|
||||
out: &mut Vec<VisualLine>,
|
||||
row: &TranscriptRow,
|
||||
@@ -1000,6 +1044,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_blocks_have_a_padding_line_between_them() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows = vec![
|
||||
row(
|
||||
"one",
|
||||
TranscriptRowKind::User,
|
||||
json!({"content":{"type":"text","text":"first"}}),
|
||||
),
|
||||
row(
|
||||
"two",
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"second"}}),
|
||||
),
|
||||
];
|
||||
|
||||
let lines = build_lines(&state, 80);
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[1].text, "");
|
||||
assert!(lines[1].row_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_tool_keeps_its_full_detail() {
|
||||
let tool = row(
|
||||
"t",
|
||||
TranscriptRowKind::ToolCall,
|
||||
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
|
||||
);
|
||||
|
||||
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_is_compact_until_selected_row_expands() {
|
||||
let mut state = StructuredState::default();
|
||||
@@ -1420,7 +1497,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
@@ -1439,7 +1516,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
@@ -1459,7 +1536,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
@@ -1478,7 +1555,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
@@ -1497,7 +1574,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||||
|
||||
@@ -199,6 +199,18 @@ impl StructuredState {
|
||||
self.follow_selection = true;
|
||||
}
|
||||
|
||||
/// Select the transcript block under a rendered transcript line. Pointer
|
||||
/// input uses the same row identity map as scroll reconciliation, so a
|
||||
/// wrapped block behaves as one target.
|
||||
pub fn select_rendered_line(&mut self, line: usize) -> bool {
|
||||
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
|
||||
return false;
|
||||
};
|
||||
self.selected = Some(id);
|
||||
self.follow_selection = false;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn toggle_selected(&mut self) {
|
||||
let Some(id) = self.selected.clone() else {
|
||||
return;
|
||||
@@ -657,6 +669,25 @@ mod tests {
|
||||
assert_eq!(state.composer.value(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_rendered_line_selects_its_whole_block() {
|
||||
let mut state = StructuredState {
|
||||
line_rows: vec![
|
||||
Some("one".into()),
|
||||
Some("one".into()),
|
||||
None,
|
||||
Some("two".into()),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(state.select_rendered_line(1));
|
||||
assert_eq!(state.selected.as_deref(), Some("one"));
|
||||
assert!(!state.follow_selection);
|
||||
assert!(!state.select_rendered_line(2));
|
||||
assert_eq!(state.selected.as_deref(), Some("one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
||||
let mut state = StructuredState::default();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +35,22 @@ 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
|
||||
@@ -302,6 +318,26 @@ pending decision, entirely on the daemon side:
|
||||
|
||||
## Prompt and cancel: bounded liveness
|
||||
|
||||
The web composer accepts up to ten files through its attachment picker, file
|
||||
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
|
||||
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
|
||||
be removed before sending; failed sends retain the draft and attachments.
|
||||
Pastes also stay inline when all ten attachment slots are occupied or the text
|
||||
exceeds the 20 MiB file limit.
|
||||
|
||||
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
|
||||
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
|
||||
Only live structured sessions accept uploads. Filenames are limited to 180
|
||||
bytes and cannot contain directory separators or control characters. Files get unique names and private
|
||||
file permissions in `<id>.attachments` beside the session sidecar. They persist
|
||||
across daemon restarts; removing a composer chip does not delete uploaded data.
|
||||
There is currently no automatic attachment cleanup.
|
||||
|
||||
The prompt includes each filename and host path so the adapter can read it
|
||||
through its own file tools, including images when the provider supports them.
|
||||
These references are persisted in the ordinary user transcript row. Attachment
|
||||
bytes are not embedded in ACP messages or transcript history.
|
||||
|
||||
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
||||
against each other through one per-session `operation` lock
|
||||
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -184,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
|
||||
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
body: file,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cancel a structured session's active ACP turn (#138). */
|
||||
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
|
||||
await jsonOrError(
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ export function structuredPrompt(id: Uuid): string {
|
||||
return `${session(id)}/structured/prompt`;
|
||||
}
|
||||
|
||||
export function structuredAttachment(id: Uuid): string {
|
||||
return `${session(id)}/structured/attachments`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
||||
export function structuredCancel(id: Uuid): string {
|
||||
return `${session(id)}/structured/cancel`;
|
||||
|
||||
@@ -7,6 +7,7 @@ const base: SpawnFormValues = {
|
||||
cwd: "/home/user/project",
|
||||
name: "amber-fox",
|
||||
structuredView: false,
|
||||
autoApprove: false,
|
||||
worktreeEnabled: false,
|
||||
worktreeBranch: "",
|
||||
worktreeBranchMode: "new",
|
||||
|
||||
+54
-33
@@ -49,6 +49,7 @@ export interface SpawnFormValues {
|
||||
cwd: string;
|
||||
name: string;
|
||||
structuredView: boolean;
|
||||
autoApprove: boolean;
|
||||
worktreeEnabled: boolean;
|
||||
worktreeBranch: string;
|
||||
worktreeBranchEdited: boolean;
|
||||
@@ -79,6 +80,10 @@ export function buildSpawnRequest(
|
||||
req.view = "Structured";
|
||||
}
|
||||
|
||||
if (values.autoApprove) {
|
||||
req.auto_approve = true;
|
||||
}
|
||||
|
||||
if (values.worktreeEnabled) {
|
||||
const mode = values.worktreeBranchMode ?? "new";
|
||||
const branch = values.worktreeBranch.trim();
|
||||
@@ -152,20 +157,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<div class="name-row">
|
||||
<input name="name" type="text" autocomplete="off"
|
||||
placeholder="named after your first prompt" />
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="worktree">
|
||||
<div class="advanced">
|
||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||
<span class="chevron">▾</span>
|
||||
<span class="folder-name">Advanced options</span>
|
||||
<span class="folder-name">Advanced</span>
|
||||
<span class="wt-summary"></span>
|
||||
</button>
|
||||
<div class="wt-collapsible" hidden>
|
||||
<div class="field">
|
||||
<span class="field-label">Session name</span>
|
||||
<div class="name-row">
|
||||
<input name="name" type="text" autocomplete="off"
|
||||
placeholder="named after your first prompt" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="row-inline structured-view" hidden>
|
||||
<input type="checkbox" name="structured_view" />
|
||||
Structured view
|
||||
@@ -174,34 +179,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
<span class="field-label">Worktree</span>
|
||||
<div class="picker wt-choices"></div>
|
||||
</div>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_enabled" />
|
||||
Create git worktree
|
||||
</label>
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
<div class="adv-group">
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_enabled" />
|
||||
Create git worktree
|
||||
</label>
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
New branch name
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Pick a branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
New branch name
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Pick a branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Spawn</button>
|
||||
@@ -261,6 +268,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
let ready = false;
|
||||
let destroyed = false;
|
||||
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
||||
const selectedAutoApprove = () =>
|
||||
presets.find((p) => p.id === selectedPresetId)?.auto_approve ?? false;
|
||||
const emit = () => {
|
||||
if (!ready || destroyed) return;
|
||||
opts.onChange?.({
|
||||
@@ -268,6 +277,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
structuredView: structuredView.checked,
|
||||
autoApprove: selectedAutoApprove(),
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
@@ -375,13 +385,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 = 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 === "") {
|
||||
@@ -917,6 +937,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
structuredView: structuredView.checked,
|
||||
autoApprove: selectedAutoApprove(),
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("is default-off and shown for resolved Claude Code and Codex commands", 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,7 +57,7 @@ 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);
|
||||
|
||||
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||
custom.click();
|
||||
@@ -65,13 +65,34 @@ describe("spawn form structured view", () => {
|
||||
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 Codex changes to an unsupported command", 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);
|
||||
@@ -83,7 +104,6 @@ describe("spawn form structured view", () => {
|
||||
command.dispatchEvent(new Event("input"));
|
||||
|
||||
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||
checkbox.click();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
command.value = "opencode";
|
||||
@@ -93,7 +113,7 @@ describe("spawn form structured view", () => {
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["claude", "codex"])("emits Structured for a selected %s command", async (tool) => {
|
||||
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 },
|
||||
@@ -109,7 +129,6 @@ describe("spawn form structured view", () => {
|
||||
command.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
|
||||
handle.el.requestSubmit();
|
||||
await drain();
|
||||
|
||||
@@ -119,4 +138,21 @@ describe("spawn form structured view", () => {
|
||||
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 },
|
||||
onSuccess() {},
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
|
||||
handle.el.requestSubmit();
|
||||
await drain();
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(JSON.parse(String(requests[0].body)).view).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+116
-2
@@ -6,6 +6,7 @@ vi.mock("./api.js", async () => {
|
||||
return {
|
||||
...actual,
|
||||
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
|
||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
uploadStructuredFile,
|
||||
setStructuredConfig,
|
||||
} from "./api.js";
|
||||
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
||||
@@ -95,6 +97,53 @@ function mount(opts: {
|
||||
describe("structured connection: data flow", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("converts long pasted text into a removable file and sends it without reply text", async () => {
|
||||
const conn = mount({ sockets: [new FakeSocket()] });
|
||||
const input = conn.el.querySelector("textarea")!;
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: {
|
||||
files: [], getData: () => "a".repeat(4000),
|
||||
} });
|
||||
input.dispatchEvent(paste);
|
||||
expect(paste.defaultPrevented).toBe(true);
|
||||
expect(input.value).toBe("");
|
||||
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
|
||||
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
|
||||
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("leaves short pastes alone", () => {
|
||||
const conn = mount({ sockets: [new FakeSocket()] });
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
|
||||
conn.el.querySelector("textarea")!.dispatchEvent(paste);
|
||||
expect(paste.defaultPrevented).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("retains failed uploads for retry and lets the operator remove them", async () => {
|
||||
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
|
||||
const conn = mount({ sockets: [new FakeSocket()] });
|
||||
const input = conn.el.querySelector("textarea")!;
|
||||
input.value = "Read this";
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: {
|
||||
files: [new File(["content"], "notes.txt")], getData: () => "",
|
||||
} });
|
||||
input.dispatchEvent(paste);
|
||||
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||
await vi.waitFor(() => expect(input.disabled).toBe(false));
|
||||
expect(input.value).toBe("Read this");
|
||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
||||
expect(remove).not.toBeNull();
|
||||
remove.click();
|
||||
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("renders every row from the initial snapshot", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -115,6 +164,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 +374,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 +382,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 +408,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 +1340,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 +1356,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 +1371,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();
|
||||
});
|
||||
|
||||
|
||||
+174
-19
@@ -10,6 +10,7 @@ import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
uploadStructuredFile,
|
||||
setStructuredConfig,
|
||||
structuredWsUrl,
|
||||
} from "./api.js";
|
||||
@@ -90,7 +91,12 @@ export function createStructuredConnection(
|
||||
<span class="structured-activity-text">Thinking</span>
|
||||
</div>
|
||||
<form class="structured-composer">
|
||||
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
|
||||
</button>
|
||||
<input class="structured-file-input" type="file" multiple hidden>
|
||||
<div class="structured-input-wrap">
|
||||
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
|
||||
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
|
||||
role="listbox" hidden></div>
|
||||
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
|
||||
@@ -114,6 +120,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;
|
||||
@@ -127,6 +134,79 @@ export function createStructuredConnection(
|
||||
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||
type Attachment = { file: File; path?: string };
|
||||
let attachments: Attachment[] = [];
|
||||
let uploading = false;
|
||||
let pasteCount = 0;
|
||||
|
||||
function renderAttachments(): void {
|
||||
attachmentEl.replaceChildren();
|
||||
attachmentEl.hidden = attachments.length === 0;
|
||||
for (const attachment of attachments) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "structured-attachment";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = attachment.file.name;
|
||||
label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`;
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
|
||||
remove.disabled = input.disabled;
|
||||
remove.addEventListener("click", () => {
|
||||
attachments = attachments.filter((item) => item !== attachment);
|
||||
renderAttachments();
|
||||
});
|
||||
chip.append(label, remove);
|
||||
attachmentEl.append(chip);
|
||||
}
|
||||
}
|
||||
|
||||
function addFiles(files: File[]): void {
|
||||
if (input.disabled || disposed) return;
|
||||
for (const file of files) {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||
} else if (attachments.length >= 10) {
|
||||
showError("Attach up to 10 files per message.");
|
||||
} else {
|
||||
attachments.push({ file });
|
||||
}
|
||||
}
|
||||
renderAttachments();
|
||||
}
|
||||
attachBtn.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", () => {
|
||||
addFiles(Array.from(fileInput.files ?? []));
|
||||
fileInput.value = "";
|
||||
});
|
||||
composerForm.addEventListener("dragover", (event) => {
|
||||
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
||||
});
|
||||
composerForm.addEventListener("drop", (event) => {
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(event.dataTransfer.files));
|
||||
});
|
||||
input.addEventListener("paste", (event) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard || input.disabled) return;
|
||||
if (clipboard.files.length) {
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(clipboard.files));
|
||||
return;
|
||||
}
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (text.length < 4000 && text.split("\n").length < 40) return;
|
||||
if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return;
|
||||
event.preventDefault();
|
||||
addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]);
|
||||
input.setRangeText("", input.selectionStart, input.selectionEnd, "end");
|
||||
autoResize();
|
||||
});
|
||||
|
||||
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
|
||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
|
||||
@@ -148,6 +228,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 +252,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 +273,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 +294,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 +325,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 +382,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 +443,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 +458,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 +494,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 +515,7 @@ export function createStructuredConnection(
|
||||
}
|
||||
rebuildTranscript();
|
||||
syncAvailableCommands();
|
||||
});
|
||||
}, false, true);
|
||||
}
|
||||
|
||||
function sessionConfigOptions(): AcpSessionConfigOption[] {
|
||||
@@ -479,9 +606,12 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function renderComposerState(): void {
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
const projectedActive = state.turn.responding || state.turn.thinking;
|
||||
input.disabled = active;
|
||||
attachBtn.disabled = active;
|
||||
fileInput.disabled = active;
|
||||
renderAttachments();
|
||||
sendBtn.disabled = active;
|
||||
sendBtn.hidden = projectedActive;
|
||||
cancelBtn.hidden = !projectedActive;
|
||||
@@ -703,7 +833,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();
|
||||
@@ -749,7 +879,9 @@ export function createStructuredConnection(
|
||||
composerForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value;
|
||||
if (!text.trim() || input.disabled) return;
|
||||
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
|
||||
const submittedAttachments = attachments;
|
||||
uploading = submittedAttachments.length > 0;
|
||||
const generation = ++submissionGeneration;
|
||||
const submissionSocketGeneration = socketGeneration;
|
||||
awaitingBeginGeneration = generation;
|
||||
@@ -759,13 +891,26 @@ export function createStructuredConnection(
|
||||
autoResize();
|
||||
clearError();
|
||||
renderComposerState();
|
||||
sendStructuredPrompt(sessionId, text).then(
|
||||
const send = async () => {
|
||||
for (const attachment of submittedAttachments) {
|
||||
if (disposed) return;
|
||||
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
|
||||
}
|
||||
if (disposed) return;
|
||||
const references = submittedAttachments.map(({ file, path }) =>
|
||||
`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
|
||||
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
|
||||
};
|
||||
void send().then(
|
||||
() => {
|
||||
if (
|
||||
disposed
|
||||
|| generation !== submissionGeneration
|
||||
|| awaitingBeginGeneration !== generation
|
||||
) return;
|
||||
if (disposed || generation !== submissionGeneration) return;
|
||||
uploading = false;
|
||||
if (attachments === submittedAttachments) {
|
||||
attachments = [];
|
||||
renderAttachments();
|
||||
}
|
||||
renderComposerState();
|
||||
if (awaitingBeginGeneration !== generation) return;
|
||||
const acceptanceSocketGeneration = socketGeneration;
|
||||
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
|
||||
// A replacement socket may have captured idle before the POST claimed
|
||||
@@ -773,6 +918,7 @@ export function createStructuredConnection(
|
||||
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
||||
},
|
||||
(e: unknown) => {
|
||||
uploading = false;
|
||||
if (disposed || generation !== submissionGeneration) return;
|
||||
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
||||
if (input.value === "") {
|
||||
@@ -912,6 +1058,7 @@ export function createStructuredConnection(
|
||||
pendingScroll = {
|
||||
top: parkedScrollTop ?? scrollEl.scrollTop,
|
||||
follow: followingBottom,
|
||||
smooth: false,
|
||||
};
|
||||
parkedScrollTop = null;
|
||||
finishTranscriptLayout();
|
||||
@@ -927,6 +1074,7 @@ export function createStructuredConnection(
|
||||
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
|
||||
scrollWorkActive = false;
|
||||
cancelScrollFrame();
|
||||
stopSmoothFollow();
|
||||
cancelFocusFrame();
|
||||
},
|
||||
focusTerminal() {
|
||||
@@ -936,6 +1084,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 +1095,7 @@ export function createStructuredConnection(
|
||||
mountToken++;
|
||||
scrollWorkActive = false;
|
||||
cancelScrollFrame();
|
||||
stopSmoothFollow();
|
||||
cancelFocusFrame();
|
||||
cancelReconnect();
|
||||
closeCurrentSocket();
|
||||
@@ -950,6 +1103,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();
|
||||
|
||||
+248
-55
@@ -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);
|
||||
@@ -398,22 +594,19 @@ export function buildToolGroup(
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
const title = el("span", "st-tool-group-title");
|
||||
title.textContent = `${rows.length} tool calls`;
|
||||
const names = el("span", "st-tool-group-names");
|
||||
names.textContent = rows
|
||||
.map((row) => {
|
||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||
return splitToolTitle(tool.title, tool.name, tool.kind).label;
|
||||
})
|
||||
.join(" · ");
|
||||
head.append(glyph, title, names);
|
||||
head.append(glyph, title);
|
||||
|
||||
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);
|
||||
|
||||
+292
-69
@@ -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,17 +2720,120 @@ 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);
|
||||
border-radius: var(--r-2);
|
||||
background: color-mix(in oklab, var(--surface-2) 55%, transparent);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-tool-group:hover {
|
||||
background: var(--surface-2);
|
||||
background: color-mix(in oklab, var(--surface-2) 70%, transparent);
|
||||
}
|
||||
|
||||
.st-tool-group-head {
|
||||
@@ -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 {
|
||||
@@ -2735,29 +2870,50 @@ input.leaf-rename:focus {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.st-tool-group-names {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.st-tool-group-body {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.st-tool-group-body {
|
||||
padding-top: var(--sp-2);
|
||||
.st-tool-group-body .st-disclosure-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--sp-1);
|
||||
}
|
||||
|
||||
.st-tool-group-body > .st-row-tool {
|
||||
padding: var(--sp-1) 0;
|
||||
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* inside a group, tool cards lose their border and background —
|
||||
the group container already provides the visual boundary. */
|
||||
.st-tool-group-body .st-tool {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.st-tool-group-body .st-tool-head {
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
|
||||
.st-tool-group-body[hidden] {
|
||||
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;
|
||||
@@ -2908,31 +3064,24 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
/* The composer is a sibling of the scrolling transcript, not part of it: it
|
||||
stays reachable at the pane edge without changing transcript scroll math. */
|
||||
stays reachable at the pane edge without changing transcript scroll math.
|
||||
It is a flush band at the foot of the pane rather than a floating card: its
|
||||
own `--surface` fill is what separates it from the transcript, over a
|
||||
hairline that doubles as the focus indicator. Inner metrics line the draft
|
||||
up with the transcript's own 12px gutter. */
|
||||
.structured-composer {
|
||||
flex: 0 0 auto;
|
||||
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
|
||||
padding: var(--sp-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-3);
|
||||
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
|
||||
var(--shadow-1);
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
transition: border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.structured-composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
|
||||
0 0 0 3px var(--accent-tint),
|
||||
var(--shadow-1);
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
|
||||
.structured-input-wrap {
|
||||
@@ -2941,6 +3090,45 @@ input.leaf-rename:focus {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.structured-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-1);
|
||||
}
|
||||
|
||||
.structured-attachments[hidden] { display: none; }
|
||||
|
||||
.structured-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-left: var(--sp-2);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
font-size: var(--fs-11);
|
||||
}
|
||||
|
||||
.structured-attachment > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structured-attachment button {
|
||||
flex-shrink: 0;
|
||||
min-width: var(--tap);
|
||||
min-height: var(--tap);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.structured-attachment button:hover { color: var(--ink); }
|
||||
.structured-attachment button:disabled { opacity: 0.5; }
|
||||
|
||||
.structured-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -2958,6 +3146,10 @@ input.leaf-rename:focus {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.structured-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.structured-composer .structured-send,
|
||||
.structured-composer .structured-cancel {
|
||||
height: 40px;
|
||||
@@ -3015,8 +3207,29 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
/* One row cannot hold a draft plus three controls at phone width — the
|
||||
draft gets a line of its own and the controls drop below it. */
|
||||
.structured-composer {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
padding-left: var(--sp-2);
|
||||
padding-right: var(--sp-2);
|
||||
}
|
||||
|
||||
/* `order` keeps the draft on the first line: without it the attach button,
|
||||
which precedes it in the DOM, wraps onto a line of its own. */
|
||||
.structured-input-wrap {
|
||||
flex: 1 1 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.structured-composer .structured-config {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.structured-config-menu {
|
||||
right: calc(-1 * (var(--control-h) + var(--sp-2)));
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3117,18 +3330,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3230,14 +3443,9 @@ input.leaf-rename:focus {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* Rides the bottom of the scroll box: reachable at any scroll position, and
|
||||
`margin-top: auto` keeps it at the screen's foot when the form is short. */
|
||||
.spawn-modal .form-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: auto;
|
||||
padding: var(--sp-2) 0 calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--surface);
|
||||
padding-bottom: calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
@@ -3319,20 +3527,16 @@ input.leaf-rename:focus {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.worktree {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
padding: 0 var(--sp-3);
|
||||
margin: 0;
|
||||
.advanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
||||
}
|
||||
|
||||
.worktree .wt-toggle {
|
||||
margin: 0 calc(var(--sp-3) * -1);
|
||||
padding-left: var(--sp-3);
|
||||
width: calc(100% + var(--sp-3) * 2);
|
||||
.advanced .wt-toggle {
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
padding-top: var(--sp-2);
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.wt-summary {
|
||||
@@ -3344,13 +3548,27 @@ input.leaf-rename:focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding-bottom: var(--sp-3);
|
||||
padding: var(--sp-2) 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.wt-collapsible[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Worktree sub-group: left accent border when expanded, visually nested. */
|
||||
.adv-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding-left: var(--sp-3);
|
||||
border-left: 2px solid var(--border);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
|
||||
.adv-group:has(input[name="worktree_enabled"]:checked) {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--danger);
|
||||
margin: 0;
|
||||
@@ -3668,6 +3886,11 @@ input.leaf-rename:focus {
|
||||
gap: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-2);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: var(--sp-2) 0;
|
||||
background: var(--surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.reroll {
|
||||
|
||||
+23
-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;
|
||||
@@ -91,6 +91,7 @@ export interface SpawnPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
auto_approve?: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
|
||||
@@ -277,6 +278,7 @@ export interface CreateSessionRequest {
|
||||
metadata?: Record<string, unknown>;
|
||||
worktree?: WorktreeSpec;
|
||||
view?: SessionView;
|
||||
auto_approve?: boolean;
|
||||
}
|
||||
|
||||
export type CatalogEvent =
|
||||
@@ -485,14 +487,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