Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19087ec613 | ||
|
|
88afd0df3f | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 |
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
|
|||||||
/// there.
|
/// there.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub existing_branch: bool,
|
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.
|
/// After spawning, attach immediately.
|
||||||
#[arg(long, short = 'a')]
|
#[arg(long, short = 'a')]
|
||||||
pub attach: bool,
|
pub attach: bool,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||||
@@ -51,16 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
};
|
};
|
||||||
|
|
||||||
let body = CreateSessionRequest {
|
let body = CreateSessionRequest {
|
||||||
view: Default::default(),
|
view: spawn_view(&args),
|
||||||
cwd: args.cwd.clone(),
|
cwd: args.cwd.clone(),
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
env,
|
env,
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
client.create_session(&body).await
|
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
|
/// `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
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
/// the work went.
|
/// the work went.
|
||||||
@@ -239,6 +256,62 @@ mod tests {
|
|||||||
|
|
||||||
use super::DaemonInfo;
|
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 {
|
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
view: Default::default(),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "opencode".into(),
|
label: "opencode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "zsh".into(),
|
id: "zsh".into(),
|
||||||
label: "Shell".into(),
|
label: "Shell".into(),
|
||||||
command: "zsh".into(),
|
command: "zsh".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
|||||||
id: CUSTOM_PRESET_ID.into(),
|
id: CUSTOM_PRESET_ID.into(),
|
||||||
label: "Custom…".into(),
|
label: "Custom…".into(),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +519,7 @@ auto_install = false
|
|||||||
id: "fish".into(),
|
id: "fish".into(),
|
||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -551,6 +556,7 @@ auto_install = false
|
|||||||
id: " ".into(),
|
id: " ".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -561,6 +567,7 @@ auto_install = false
|
|||||||
id: "x".into(),
|
id: "x".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -578,11 +585,13 @@ auto_install = false
|
|||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a".into(),
|
label: "a".into(),
|
||||||
command: "a".into(),
|
command: "a".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a2".into(),
|
label: "a2".into(),
|
||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -597,6 +606,7 @@ auto_install = false
|
|||||||
id: "custom".into(),
|
id: "custom".into(),
|
||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
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::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get, post};
|
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, any(structured_ws))
|
||||||
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_ATTACHMENT,
|
||||||
|
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
|
||||||
|
)
|
||||||
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||||
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
||||||
.route(
|
.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(
|
async fn structured_prompt(
|
||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ pub struct SessionRecord {
|
|||||||
/// Completion signal for the task draining ACP notifications into the
|
/// Completion signal for the task draining ACP notifications into the
|
||||||
/// event log. Shutdown waits for it before returning.
|
/// event log. Shutdown waits for it before returning.
|
||||||
acp_event_pump: Option<watch::Receiver<bool>>,
|
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 {
|
impl SessionRecord {
|
||||||
@@ -604,6 +607,36 @@ impl SessionManager {
|
|||||||
Ok(Some(replay))
|
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.
|
/// Subscribe to the two projections of a structured session's ACP log.
|
||||||
///
|
///
|
||||||
/// The transcript snapshot and whole reduced state are folded once by the
|
/// 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 mut sessions = self.inner.sessions.lock().await;
|
||||||
let event_pump = event_pump.map(|(acp, events)| {
|
let event_pump = event_pump.map(|(acp, events)| {
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
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
|
done
|
||||||
});
|
});
|
||||||
sessions.insert(
|
sessions.insert(
|
||||||
@@ -1435,6 +1468,7 @@ impl SessionManager {
|
|||||||
acp_events,
|
acp_events,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: event_pump,
|
acp_event_pump: event_pump,
|
||||||
|
auto_approve: req.auto_approve,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1647,6 +1681,7 @@ impl SessionManager {
|
|||||||
acp_events: None,
|
acp_events: None,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -1824,11 +1859,14 @@ impl SessionManager {
|
|||||||
acp_events,
|
acp_events,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: 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 {
|
if let Some((acp, events)) = event_pump {
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
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) {
|
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||||
record.acp_event_pump = Some(done);
|
record.acp_event_pump = Some(done);
|
||||||
}
|
}
|
||||||
@@ -2327,6 +2365,7 @@ fn start_acp_permission_pump(
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
acp: &Arc<AcpSession>,
|
acp: &Arc<AcpSession>,
|
||||||
events: Arc<Mutex<AcpEventStream>>,
|
events: Arc<Mutex<AcpEventStream>>,
|
||||||
|
auto_approve: bool,
|
||||||
) {
|
) {
|
||||||
let Some(mut requests) = acp.take_permission_requests() else {
|
let Some(mut requests) = acp.take_permission_requests() else {
|
||||||
return;
|
return;
|
||||||
@@ -2340,6 +2379,12 @@ fn start_acp_permission_pump(
|
|||||||
response,
|
response,
|
||||||
}) = requests.recv().await
|
}) = 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;
|
let mut events = events.lock().await;
|
||||||
if events.projection.active_turn() != Some(turn) {
|
if events.projection.active_turn() != Some(turn) {
|
||||||
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
let _ = response.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
||||||
@@ -3060,6 +3105,7 @@ mod tests {
|
|||||||
env: std::collections::HashMap::new(),
|
env: std::collections::HashMap::new(),
|
||||||
metadata: std::collections::HashMap::new(),
|
metadata: std::collections::HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ fn structured_command(
|
|||||||
env,
|
env,
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,6 +706,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
|||||||
branch: branch.into(),
|
branch: branch.into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1187,6 +1189,7 @@ async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
|||||||
branch: "cancelled-create".into(),
|
branch: "cancelled-create".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let create = {
|
let create = {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
|||||||
branch: branch.into(),
|
branch: branch.into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
|
|||||||
env,
|
env,
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.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]
|
#[tokio::test]
|
||||||
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -493,6 +548,7 @@ async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -626,6 +682,7 @@ async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -768,6 +825,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
let created: Session = reqwest::Client::new()
|
let created: Session = reqwest::Client::new()
|
||||||
.post(url(h.addr, "/api/sessions"))
|
.post(url(h.addr, "/api/sessions"))
|
||||||
@@ -985,6 +1043,7 @@ async fn patch_session_renames_it() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1028,6 +1087,7 @@ async fn post_focus_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1062,6 +1122,7 @@ async fn delete_owned_session_kills_it() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1084,6 +1145,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1198,6 +1260,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1251,6 +1314,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1281,6 +1345,7 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1383,6 +1448,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1495,6 +1561,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ impl Fx {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
branch: "feat/indicator".into(),
|
branch: "feat/indicator".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
branch: "feat/keep".into(),
|
branch: "feat/keep".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
branch: "feat/pushed".into(),
|
branch: "feat/pushed".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/forced".into(),
|
branch: "feat/forced".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
branch: "feat/clear".into(),
|
branch: "feat/clear".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
branch: "feat/rusty".into(),
|
branch: "feat/rusty".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/exited".into(),
|
branch: "feat/exited".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
branch: "feat/clean".into(),
|
branch: "feat/clean".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/one".into(),
|
branch: "feat/one".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create first");
|
.expect("create first");
|
||||||
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/two".into(),
|
branch: "feat/two".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create second");
|
.expect("create second");
|
||||||
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
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(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
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(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
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(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client
|
let s = client
|
||||||
@@ -827,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn dirty");
|
.expect("spawn dirty");
|
||||||
@@ -876,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
|
|||||||
env: fake.env.clone(),
|
env: fake.env.clone(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn structured session");
|
.expect("spawn structured session");
|
||||||
|
|||||||
@@ -226,6 +226,15 @@ pub fn command_tool(command: &str) -> Option<&'static str> {
|
|||||||
.map(|(_, tool)| *tool)
|
.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
|
/// [`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
|
/// of the word that is the actual program (past every assignment/wrapper it
|
||||||
/// resolved through).
|
/// 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);
|
||||||
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,6 +383,10 @@ pub struct CreateSessionRequest {
|
|||||||
/// untouched.
|
/// untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub view: SessionView,
|
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.
|
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
||||||
@@ -522,6 +526,11 @@ pub struct SpawnPreset {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub command: 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
|
/// Build provenance of the running daemon: the commit it was compiled
|
||||||
@@ -1251,6 +1260,7 @@ mod tests {
|
|||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
assert_eq!(req, round_trip(&req));
|
assert_eq!(req, round_trip(&req));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ pub fn structured_prompt(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/structured/prompt")
|
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.
|
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
|
||||||
pub fn structured_cancel(id: Uuid) -> String {
|
pub fn structured_cancel(id: Uuid) -> String {
|
||||||
format!("{SESSIONS}/{id}/structured/cancel")
|
format!("{SESSIONS}/{id}/structured/cancel")
|
||||||
@@ -113,6 +118,7 @@ pub mod patterns {
|
|||||||
pub const STRUCTURED: &str = "/api/structured/:id";
|
pub const STRUCTURED: &str = "/api/structured/:id";
|
||||||
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
||||||
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
||||||
|
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
|
||||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||||
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||||
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
//! `dispatch` / the overlay routing.
|
//! `dispatch` / the overlay routing.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -54,6 +55,36 @@ enum OverlayDecision {
|
|||||||
Pass,
|
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 {
|
pub struct App {
|
||||||
cfg: TuiConfig,
|
cfg: TuiConfig,
|
||||||
home_dir: String,
|
home_dir: String,
|
||||||
@@ -340,6 +371,10 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.handle_structured_mouse(&key) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(effects) = self.handle_structured_key(&key) {
|
if let Some(effects) = self.handle_structured_key(&key) {
|
||||||
self.perform(effects).await;
|
self.perform(effects).await;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -423,6 +458,61 @@ impl App {
|
|||||||
true
|
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
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||||
/// the daemon.
|
/// the daemon.
|
||||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||||
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::action::Action;
|
||||||
use crate::draft::DraftField;
|
use crate::draft::DraftField;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
@@ -3009,6 +3105,7 @@ mod tests {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
app.submit_spawn(req).await;
|
app.submit_spawn(req).await;
|
||||||
|
|
||||||
@@ -3057,6 +3154,7 @@ mod tests {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let structured_id = Uuid::from_u128(188);
|
let structured_id = Uuid::from_u128(188);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
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::{
|
use maestro_protocol::{
|
||||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||||
};
|
};
|
||||||
@@ -255,9 +255,14 @@ pub struct DraftPane {
|
|||||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||||
/// take the default target.
|
/// take the default target.
|
||||||
pub advanced_collapsed: bool,
|
pub advanced_collapsed: bool,
|
||||||
/// ACP-backed structured transcript. Off by default and supported only
|
/// ACP-backed structured transcript. Supported only when the resolved
|
||||||
/// when the resolved command is Claude Code or Codex.
|
/// command is Claude Code or Codex, and on by default whenever it is —
|
||||||
|
/// until the operator works the toggle themselves.
|
||||||
pub structured_view: bool,
|
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
|
/// Enclosing repository of the chosen target — from the project scan for
|
||||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||||
pub base_repo: Option<PathBuf>,
|
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.
|
// while its data loads, and stay usable when the daemon reports none.
|
||||||
let mut presets = builtin_presets();
|
let mut presets = builtin_presets();
|
||||||
presets.push(custom_preset());
|
presets.push(custom_preset());
|
||||||
Self {
|
let mut draft = Self {
|
||||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
@@ -319,6 +324,7 @@ impl Default for DraftPane {
|
|||||||
worktree_enabled: true,
|
worktree_enabled: true,
|
||||||
advanced_collapsed: true,
|
advanced_collapsed: true,
|
||||||
structured_view: false,
|
structured_view: false,
|
||||||
|
structured_view_touched: false,
|
||||||
base_repo: None,
|
base_repo: None,
|
||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
@@ -337,7 +343,11 @@ impl Default for DraftPane {
|
|||||||
// straight away, and Tab from here reaches the command tiles.
|
// straight away, and Tab from here reaches the command tiles.
|
||||||
focused_field: DraftField::DirPicker,
|
focused_field: DraftField::DirPicker,
|
||||||
home_dir: String::new(),
|
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())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.apply_seed_command();
|
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
|
/// 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 {
|
fn supports_structured_view(&self) -> bool {
|
||||||
matches!(command_tool(self.command()), Some("claude" | "codex"))
|
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() {
|
if !self.supports_structured_view() {
|
||||||
self.structured_view = false;
|
self.structured_view = false;
|
||||||
|
} else if !self.structured_view_touched {
|
||||||
|
self.structured_view = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_structured_view(&mut self) {
|
pub fn toggle_structured_view(&mut self) {
|
||||||
if self.supports_structured_view() {
|
if self.supports_structured_view() {
|
||||||
self.structured_view = !self.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 worktree = self.build_worktree()?;
|
||||||
|
let auto_approve = self
|
||||||
|
.presets
|
||||||
|
.get(self.selected_preset)
|
||||||
|
.is_some_and(|p| p.auto_approve);
|
||||||
Ok(CreateSessionRequest {
|
Ok(CreateSessionRequest {
|
||||||
view: if self.structured_view && self.supports_structured_view() {
|
view: if self.structured_view && self.supports_structured_view() {
|
||||||
SessionView::Structured
|
SessionView::Structured
|
||||||
@@ -729,6 +749,7 @@ impl DraftPane {
|
|||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,7 +848,7 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
self.selected_preset =
|
self.selected_preset =
|
||||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
((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
|
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||||
@@ -1130,7 +1151,7 @@ impl DraftPane {
|
|||||||
DraftField::Preset | DraftField::Submit => {}
|
DraftField::Preset | DraftField::Submit => {}
|
||||||
DraftField::CustomCommand => {
|
DraftField::CustomCommand => {
|
||||||
self.custom_command.handle_key(other);
|
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
|
// Typing filters the list; the highlight resets to the top
|
||||||
// so a narrowed search always starts at its best match.
|
// so a narrowed search always starts at its best match.
|
||||||
@@ -1776,6 +1797,7 @@ mod tests {
|
|||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]);
|
}]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.command(),
|
draft.command(),
|
||||||
@@ -1798,11 +1820,13 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
@@ -1822,6 +1846,7 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]);
|
}]);
|
||||||
assert!(draft.is_custom());
|
assert!(draft.is_custom());
|
||||||
assert_eq!(draft.command(), "bash -l");
|
assert_eq!(draft.command(), "bash -l");
|
||||||
@@ -1888,7 +1913,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structured_view_is_default_off_and_visible_for_supported_agents() {
|
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
advanced_collapsed: false,
|
advanced_collapsed: false,
|
||||||
..DraftPane::default()
|
..DraftPane::default()
|
||||||
@@ -1898,25 +1923,26 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "codex".into(),
|
id: "codex".into(),
|
||||||
label: "Codex".into(),
|
label: "Codex".into(),
|
||||||
command: "codex".into(),
|
command: "codex".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "OpenCode".into(),
|
label: "OpenCode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert!(!draft.structured_view);
|
assert!(draft.structured_view, "on by default for a supported agent");
|
||||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
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);
|
draft.cycle_preset(1);
|
||||||
assert_eq!(draft.command(), "codex");
|
assert_eq!(draft.command(), "codex");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2006,16 +2032,16 @@ mod tests {
|
|||||||
};
|
};
|
||||||
draft.selected_preset = draft.presets.len() - 1;
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||||
|
draft.sync_structured_view();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.field_order().contains(&DraftField::StructuredToggle),
|
draft.field_order().contains(&DraftField::StructuredToggle),
|
||||||
supported,
|
supported,
|
||||||
"toggle visibility for {command:?}"
|
"toggle visibility for {command:?}"
|
||||||
);
|
);
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.structured_view, supported,
|
draft.structured_view, supported,
|
||||||
"toggle takes effect for {command:?}"
|
"default selection for {command:?}"
|
||||||
);
|
);
|
||||||
let view = draft.build_request().unwrap().view;
|
let view = draft.build_request().unwrap().view;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2027,6 +2053,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"request view for {command:?}"
|
"request view for {command:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"the toggle turns the default off for {command:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2046,22 +2078,24 @@ mod tests {
|
|||||||
id: "claude-wrapped".into(),
|
id: "claude-wrapped".into(),
|
||||||
label: "Claude (npx)".into(),
|
label: "Claude (npx)".into(),
|
||||||
command: "npx claude-code --resume".into(),
|
command: "npx claude-code --resume".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "codex-wrapped".into(),
|
id: "codex-wrapped".into(),
|
||||||
label: "Codex (npx)".into(),
|
label: "Codex (npx)".into(),
|
||||||
command: "npx codex exec".into(),
|
command: "npx codex exec".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert_eq!(draft.command(), "npx claude-code --resume");
|
assert_eq!(draft.command(), "npx claude-code --resume");
|
||||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert!(draft.structured_view);
|
assert!(draft.structured_view);
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
@@ -2080,6 +2114,45 @@ mod tests {
|
|||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Editing the custom-command text away from a supported agent clears an armed
|
||||||
/// selection immediately, matching the preset-switch case above — an
|
/// selection immediately, matching the preset-switch case above — an
|
||||||
/// invalid `Structured` request can never reach `build_request`.
|
/// invalid `Structured` request can never reach `build_request`.
|
||||||
@@ -2094,7 +2167,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
draft.selected_preset = draft.presets.len() - 1;
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
||||||
draft.toggle_structured_view();
|
draft.sync_structured_view();
|
||||||
assert!(draft.structured_view);
|
assert!(draft.structured_view);
|
||||||
|
|
||||||
typed(&mut draft, " -p");
|
typed(&mut draft, " -p");
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
|
|||||||
branch: handle.to_string(),
|
branch: handle.to_string(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ mod tests {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: id.into(),
|
label: id.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ pub enum MouseKind {
|
|||||||
Click,
|
Click,
|
||||||
DblClick,
|
DblClick,
|
||||||
Drag,
|
Drag,
|
||||||
|
Hover,
|
||||||
ScrollUp,
|
ScrollUp,
|
||||||
ScrollDown,
|
ScrollDown,
|
||||||
}
|
}
|
||||||
@@ -148,6 +149,7 @@ impl InputKey {
|
|||||||
MouseKind::Click => "click",
|
MouseKind::Click => "click",
|
||||||
MouseKind::DblClick => "dblclick",
|
MouseKind::DblClick => "dblclick",
|
||||||
MouseKind::Drag => "drag",
|
MouseKind::Drag => "drag",
|
||||||
|
MouseKind::Hover => "hover",
|
||||||
MouseKind::ScrollUp => "scrollup",
|
MouseKind::ScrollUp => "scrollup",
|
||||||
MouseKind::ScrollDown => "scrolldown",
|
MouseKind::ScrollDown => "scrolldown",
|
||||||
};
|
};
|
||||||
@@ -178,6 +180,7 @@ impl InputKey {
|
|||||||
"click" => MouseKind::Click,
|
"click" => MouseKind::Click,
|
||||||
"dblclick" => MouseKind::DblClick,
|
"dblclick" => MouseKind::DblClick,
|
||||||
"drag" => MouseKind::Drag,
|
"drag" => MouseKind::Drag,
|
||||||
|
"hover" => MouseKind::Hover,
|
||||||
"scrollup" => MouseKind::ScrollUp,
|
"scrollup" => MouseKind::ScrollUp,
|
||||||
"scrolldown" => MouseKind::ScrollDown,
|
"scrolldown" => MouseKind::ScrollDown,
|
||||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||||
@@ -297,6 +300,7 @@ where
|
|||||||
let mk = match kind {
|
let mk = match kind {
|
||||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||||
|
MouseEventKind::Moved => MouseKind::Hover,
|
||||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -681,6 +685,9 @@ mod tests {
|
|||||||
let s = "mouse:dblclick(sidebar-row)";
|
let s = "mouse:dblclick(sidebar-row)";
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s);
|
assert_eq!(k.canonical(), s);
|
||||||
|
let s = "mouse:hover(pane)";
|
||||||
|
let k = InputKey::parse(s).unwrap();
|
||||||
|
assert_eq!(k.canonical(), s);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -308,11 +308,12 @@ fn fallback_after_removal(
|
|||||||
|
|
||||||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in &state.rows {
|
for (index, row) in state.rows.iter().enumerate() {
|
||||||
if row.kind == TranscriptRowKind::Plan {
|
if row.kind == TranscriptRowKind::Plan {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let row_width = width.saturating_sub(1).max(1);
|
let row_width = width.saturating_sub(1).max(1);
|
||||||
|
let start = out.len();
|
||||||
match row.kind {
|
match row.kind {
|
||||||
TranscriptRowKind::User => push_wrapped(
|
TranscriptRowKind::User => push_wrapped(
|
||||||
&mut out,
|
&mut out,
|
||||||
@@ -356,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
|||||||
),
|
),
|
||||||
TranscriptRowKind::Plan => {}
|
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
|
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(
|
fn push_wrapped(
|
||||||
out: &mut Vec<VisualLine>,
|
out: &mut Vec<VisualLine>,
|
||||||
row: &TranscriptRow,
|
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]
|
#[test]
|
||||||
fn tool_is_compact_until_selected_row_expands() {
|
fn tool_is_compact_until_selected_row_expands() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
@@ -1420,7 +1497,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1439,7 +1516,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1459,7 +1536,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1478,7 +1555,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
assert!(before[0].contains("line 10"), "{before:?}");
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
@@ -1497,7 +1574,7 @@ mod tests {
|
|||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
state.follow_selection = false;
|
state.follow_selection = false;
|
||||||
numbered_rows(&mut state, 20);
|
numbered_rows(&mut state, 20);
|
||||||
state.scroll = 10;
|
state.scroll = 20;
|
||||||
|
|
||||||
render_transcript_lines(&pane, &mut state, 30, 5);
|
render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||||||
|
|||||||
@@ -199,6 +199,18 @@ impl StructuredState {
|
|||||||
self.follow_selection = true;
|
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) {
|
pub fn toggle_selected(&mut self) {
|
||||||
let Some(id) = self.selected.clone() else {
|
let Some(id) = self.selected.clone() else {
|
||||||
return;
|
return;
|
||||||
@@ -657,6 +669,25 @@ mod tests {
|
|||||||
assert_eq!(state.composer.value(), "hi");
|
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]
|
#[test]
|
||||||
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
|
|||||||
@@ -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
|
exactly as it does to a terminal one; only what gets terminated differs (an
|
||||||
ACP handle instead of a tmux pane).
|
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 adapter
|
||||||
|
|
||||||
The resolved structured command selects a provider adapter. Its binary is
|
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
|
## 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
|
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
||||||
against each other through one per-session `operation` lock
|
against each other through one per-session `operation` lock
|
||||||
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
|
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"packageManager": "pnpm@9.15.4",
|
"packageManager": "pnpm@9.15.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b tsconfig.build.json && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"test": "vitest run",
|
"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). */
|
/** Cancel a structured session's active ACP turn (#138). */
|
||||||
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
|
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
|
||||||
await jsonOrError(
|
await jsonOrError(
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export function structuredPrompt(id: Uuid): string {
|
|||||||
return `${session(id)}/structured/prompt`;
|
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. */
|
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
||||||
export function structuredCancel(id: Uuid): string {
|
export function structuredCancel(id: Uuid): string {
|
||||||
return `${session(id)}/structured/cancel`;
|
return `${session(id)}/structured/cancel`;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const base: SpawnFormValues = {
|
|||||||
cwd: "/home/user/project",
|
cwd: "/home/user/project",
|
||||||
name: "amber-fox",
|
name: "amber-fox",
|
||||||
structuredView: false,
|
structuredView: false,
|
||||||
|
autoApprove: false,
|
||||||
worktreeEnabled: false,
|
worktreeEnabled: false,
|
||||||
worktreeBranch: "",
|
worktreeBranch: "",
|
||||||
worktreeBranchMode: "new",
|
worktreeBranchMode: "new",
|
||||||
|
|||||||
+54
-33
@@ -49,6 +49,7 @@ export interface SpawnFormValues {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
name: string;
|
name: string;
|
||||||
structuredView: boolean;
|
structuredView: boolean;
|
||||||
|
autoApprove: boolean;
|
||||||
worktreeEnabled: boolean;
|
worktreeEnabled: boolean;
|
||||||
worktreeBranch: string;
|
worktreeBranch: string;
|
||||||
worktreeBranchEdited: boolean;
|
worktreeBranchEdited: boolean;
|
||||||
@@ -79,6 +80,10 @@ export function buildSpawnRequest(
|
|||||||
req.view = "Structured";
|
req.view = "Structured";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.autoApprove) {
|
||||||
|
req.auto_approve = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (values.worktreeEnabled) {
|
if (values.worktreeEnabled) {
|
||||||
const mode = values.worktreeBranchMode ?? "new";
|
const mode = values.worktreeBranchMode ?? "new";
|
||||||
const branch = values.worktreeBranch.trim();
|
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
|
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||||
placeholder="bash -l" autocomplete="off" />
|
placeholder="bash -l" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="advanced">
|
||||||
<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">
|
|
||||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||||
<span class="chevron">▾</span>
|
<span class="chevron">▾</span>
|
||||||
<span class="folder-name">Advanced options</span>
|
<span class="folder-name">Advanced</span>
|
||||||
<span class="wt-summary"></span>
|
<span class="wt-summary"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="wt-collapsible" hidden>
|
<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>
|
<label class="row-inline structured-view" hidden>
|
||||||
<input type="checkbox" name="structured_view" />
|
<input type="checkbox" name="structured_view" />
|
||||||
Structured view
|
Structured view
|
||||||
@@ -174,34 +179,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
<span class="field-label">Worktree</span>
|
<span class="field-label">Worktree</span>
|
||||||
<div class="picker wt-choices"></div>
|
<div class="picker wt-choices"></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="row-inline">
|
<div class="adv-group">
|
||||||
<input type="checkbox" name="worktree_enabled" />
|
<label class="row-inline">
|
||||||
Create git worktree
|
<input type="checkbox" name="worktree_enabled" />
|
||||||
</label>
|
Create git worktree
|
||||||
<p class="wt-hint" hidden></p>
|
</label>
|
||||||
<div class="wt-body" hidden>
|
<p class="wt-hint" hidden></p>
|
||||||
<p class="wt-repo"></p>
|
<div class="wt-body" hidden>
|
||||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
<p class="wt-repo"></p>
|
||||||
<label class="row-inline">
|
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
<label class="row-inline">
|
||||||
New branch
|
<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>
|
||||||
<label class="row-inline">
|
<label class="wt-exist-wrap" hidden>
|
||||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
Pick a branch
|
||||||
Existing branch
|
<select class="branch-select"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Spawn</button>
|
<button type="submit" class="btn btn-primary">Spawn</button>
|
||||||
@@ -261,6 +268,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
let ready = false;
|
let ready = false;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
|
||||||
|
const selectedAutoApprove = () =>
|
||||||
|
presets.find((p) => p.id === selectedPresetId)?.auto_approve ?? false;
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
if (!ready || destroyed) return;
|
if (!ready || destroyed) return;
|
||||||
opts.onChange?.({
|
opts.onChange?.({
|
||||||
@@ -268,6 +277,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
structuredView: structuredView.checked,
|
structuredView: structuredView.checked,
|
||||||
|
autoApprove: selectedAutoApprove(),
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
@@ -375,13 +385,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// operator has already touched (#76).
|
// operator has already touched (#76).
|
||||||
let commandTouched = false;
|
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 {
|
function updateStructuredAvailability(): void {
|
||||||
const supported = supportsStructuredView(command);
|
const supported = supportsStructuredView(command);
|
||||||
structuredWrap.hidden = !supported;
|
structuredWrap.hidden = !supported;
|
||||||
if (!supported) structuredView.checked = false;
|
if (!supported) structuredView.checked = false;
|
||||||
|
else if (!structuredTouched) structuredView.checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
structuredView.addEventListener("change", emit);
|
structuredView.addEventListener("change", () => {
|
||||||
|
structuredTouched = true;
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
|
||||||
cmdRaw.addEventListener("input", () => {
|
cmdRaw.addEventListener("input", () => {
|
||||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||||
@@ -917,6 +937,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
structuredView: structuredView.checked,
|
structuredView: structuredView.checked,
|
||||||
|
autoApprove: selectedAutoApprove(),
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
|
|||||||
vi.unstubAllGlobals();
|
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();
|
stubApi();
|
||||||
handle = createSpawnForm({ onSuccess() {} });
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
document.body.appendChild(handle.el);
|
document.body.appendChild(handle.el);
|
||||||
@@ -57,7 +57,7 @@ describe("spawn form structured view", () => {
|
|||||||
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||||
expect(disclosure.hidden).toBe(false);
|
expect(disclosure.hidden).toBe(false);
|
||||||
expect(wrap.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"]')!;
|
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||||
custom.click();
|
custom.click();
|
||||||
@@ -65,13 +65,34 @@ describe("spawn form structured view", () => {
|
|||||||
command.value = "FOO=1 /usr/local/bin/codex";
|
command.value = "FOO=1 /usr/local/bin/codex";
|
||||||
command.dispatchEvent(new Event("input"));
|
command.dispatchEvent(new Event("input"));
|
||||||
expect(wrap.hidden).toBe(false);
|
expect(wrap.hidden).toBe(false);
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
command.value = "opencode";
|
command.value = "opencode";
|
||||||
command.dispatchEvent(new Event("input"));
|
command.dispatchEvent(new Event("input"));
|
||||||
expect(wrap.hidden).toBe(true);
|
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();
|
stubApi();
|
||||||
handle = createSpawnForm({ onSuccess() {} });
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
document.body.appendChild(handle.el);
|
document.body.appendChild(handle.el);
|
||||||
@@ -83,7 +104,6 @@ describe("spawn form structured view", () => {
|
|||||||
command.dispatchEvent(new Event("input"));
|
command.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||||
checkbox.click();
|
|
||||||
expect(checkbox.checked).toBe(true);
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
command.value = "opencode";
|
command.value = "opencode";
|
||||||
@@ -93,7 +113,7 @@ describe("spawn form structured view", () => {
|
|||||||
expect(checkbox.checked).toBe(false);
|
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();
|
const requests = stubApi();
|
||||||
handle = createSpawnForm({
|
handle = createSpawnForm({
|
||||||
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
||||||
@@ -109,7 +129,6 @@ describe("spawn form structured view", () => {
|
|||||||
command.dispatchEvent(new Event("input"));
|
command.dispatchEvent(new Event("input"));
|
||||||
}
|
}
|
||||||
|
|
||||||
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
|
|
||||||
handle.el.requestSubmit();
|
handle.el.requestSubmit();
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
@@ -119,4 +138,21 @@ describe("spawn form structured view", () => {
|
|||||||
view: "Structured",
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock("./api.js", async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
|
||||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||||
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
cancelStructuredTurn,
|
cancelStructuredTurn,
|
||||||
resolveStructuredApproval,
|
resolveStructuredApproval,
|
||||||
sendStructuredPrompt,
|
sendStructuredPrompt,
|
||||||
|
uploadStructuredFile,
|
||||||
setStructuredConfig,
|
setStructuredConfig,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
||||||
@@ -95,6 +97,53 @@ function mount(opts: {
|
|||||||
describe("structured connection: data flow", () => {
|
describe("structured connection: data flow", () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
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", () => {
|
it("renders every row from the initial snapshot", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
|
|||||||
+106
-8
@@ -10,6 +10,7 @@ import {
|
|||||||
cancelStructuredTurn,
|
cancelStructuredTurn,
|
||||||
resolveStructuredApproval,
|
resolveStructuredApproval,
|
||||||
sendStructuredPrompt,
|
sendStructuredPrompt,
|
||||||
|
uploadStructuredFile,
|
||||||
setStructuredConfig,
|
setStructuredConfig,
|
||||||
structuredWsUrl,
|
structuredWsUrl,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@@ -90,7 +91,12 @@ export function createStructuredConnection(
|
|||||||
<span class="structured-activity-text">Thinking</span>
|
<span class="structured-activity-text">Thinking</span>
|
||||||
</div>
|
</div>
|
||||||
<form class="structured-composer">
|
<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-input-wrap">
|
||||||
|
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
|
||||||
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
|
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
|
||||||
role="listbox" hidden></div>
|
role="listbox" hidden></div>
|
||||||
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
|
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
|
||||||
@@ -128,6 +134,79 @@ export function createStructuredConnection(
|
|||||||
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||||
|
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));
|
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
|
||||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
|
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
|
||||||
@@ -527,9 +606,12 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderComposerState(): void {
|
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;
|
const projectedActive = state.turn.responding || state.turn.thinking;
|
||||||
input.disabled = active;
|
input.disabled = active;
|
||||||
|
attachBtn.disabled = active;
|
||||||
|
fileInput.disabled = active;
|
||||||
|
renderAttachments();
|
||||||
sendBtn.disabled = active;
|
sendBtn.disabled = active;
|
||||||
sendBtn.hidden = projectedActive;
|
sendBtn.hidden = projectedActive;
|
||||||
cancelBtn.hidden = !projectedActive;
|
cancelBtn.hidden = !projectedActive;
|
||||||
@@ -797,7 +879,9 @@ export function createStructuredConnection(
|
|||||||
composerForm.addEventListener("submit", (e) => {
|
composerForm.addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = input.value;
|
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 generation = ++submissionGeneration;
|
||||||
const submissionSocketGeneration = socketGeneration;
|
const submissionSocketGeneration = socketGeneration;
|
||||||
awaitingBeginGeneration = generation;
|
awaitingBeginGeneration = generation;
|
||||||
@@ -807,13 +891,26 @@ export function createStructuredConnection(
|
|||||||
autoResize();
|
autoResize();
|
||||||
clearError();
|
clearError();
|
||||||
renderComposerState();
|
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 (
|
if (disposed || generation !== submissionGeneration) return;
|
||||||
disposed
|
uploading = false;
|
||||||
|| generation !== submissionGeneration
|
if (attachments === submittedAttachments) {
|
||||||
|| awaitingBeginGeneration !== generation
|
attachments = [];
|
||||||
) return;
|
renderAttachments();
|
||||||
|
}
|
||||||
|
renderComposerState();
|
||||||
|
if (awaitingBeginGeneration !== generation) return;
|
||||||
const acceptanceSocketGeneration = socketGeneration;
|
const acceptanceSocketGeneration = socketGeneration;
|
||||||
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
|
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
|
||||||
// A replacement socket may have captured idle before the POST claimed
|
// A replacement socket may have captured idle before the POST claimed
|
||||||
@@ -821,6 +918,7 @@ export function createStructuredConnection(
|
|||||||
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
||||||
},
|
},
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
|
uploading = false;
|
||||||
if (disposed || generation !== submissionGeneration) return;
|
if (disposed || generation !== submissionGeneration) return;
|
||||||
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
||||||
if (input.value === "") {
|
if (input.value === "") {
|
||||||
|
|||||||
@@ -594,14 +594,7 @@ export function buildToolGroup(
|
|||||||
glyph.setAttribute("aria-hidden", "true");
|
glyph.setAttribute("aria-hidden", "true");
|
||||||
const title = el("span", "st-tool-group-title");
|
const title = el("span", "st-tool-group-title");
|
||||||
title.textContent = `${rows.length} tool calls`;
|
title.textContent = `${rows.length} tool calls`;
|
||||||
const names = el("span", "st-tool-group-names");
|
head.append(glyph, title);
|
||||||
names.textContent = rows
|
|
||||||
.map((row) => {
|
|
||||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
|
||||||
return splitToolTitle(tool.title, tool.name, tool.kind).label;
|
|
||||||
})
|
|
||||||
.join(" · ");
|
|
||||||
head.append(glyph, title, names);
|
|
||||||
|
|
||||||
const body = el("div", "st-tool-group-body st-disclosure");
|
const body = el("div", "st-tool-group-body st-disclosure");
|
||||||
const bodyClip = el("div", "st-disclosure-clip");
|
const bodyClip = el("div", "st-disclosure-clip");
|
||||||
|
|||||||
+113
-53
@@ -2828,12 +2828,12 @@ input.leaf-rename:focus {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
background: color-mix(in oklab, var(--surface-2) 55%, transparent);
|
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);
|
transition: background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-tool-group:hover {
|
.st-tool-group:hover {
|
||||||
background: var(--surface-2);
|
background: color-mix(in oklab, var(--surface-2) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-tool-group-head {
|
.st-tool-group-head {
|
||||||
@@ -2870,15 +2870,6 @@ input.leaf-rename:focus {
|
|||||||
font-weight: 500;
|
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 {
|
.st-tool-group-body {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
@@ -2886,11 +2877,22 @@ input.leaf-rename:focus {
|
|||||||
.st-tool-group-body .st-disclosure-inner {
|
.st-tool-group-body .st-disclosure-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: var(--sp-2);
|
padding-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
|
.st-tool-group-body .st-disclosure-inner > .st-row-tool {
|
||||||
padding: var(--sp-1) 0;
|
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] {
|
.st-tool-group-body[hidden] {
|
||||||
@@ -3062,31 +3064,24 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The composer is a sibling of the scrolling transcript, not part of it: it
|
/* 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 {
|
.structured-composer {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
|
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
|
||||||
padding: var(--sp-1);
|
border-top: 1px solid var(--border);
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--r-3);
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: var(--sp-1);
|
gap: var(--sp-1);
|
||||||
box-shadow:
|
transition: border-color var(--dur-fast) var(--ease-out);
|
||||||
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
|
|
||||||
var(--shadow-1);
|
|
||||||
transition:
|
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
|
||||||
box-shadow var(--dur-fast) var(--ease-out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-composer:focus-within {
|
.structured-composer:focus-within {
|
||||||
border-color: var(--accent);
|
border-top-color: var(--accent);
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
|
|
||||||
0 0 0 3px var(--accent-tint),
|
|
||||||
var(--shadow-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-input-wrap {
|
.structured-input-wrap {
|
||||||
@@ -3095,6 +3090,45 @@ input.leaf-rename:focus {
|
|||||||
min-width: 0;
|
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 {
|
.structured-input {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -3112,6 +3146,10 @@ input.leaf-rename:focus {
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.structured-input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.structured-composer .structured-send,
|
.structured-composer .structured-send,
|
||||||
.structured-composer .structured-cancel {
|
.structured-composer .structured-cancel {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -3169,17 +3207,29 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@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 {
|
.structured-composer {
|
||||||
margin: var(--sp-1) 0 0;
|
flex-wrap: wrap;
|
||||||
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
|
justify-content: flex-end;
|
||||||
border-right: 0;
|
padding-left: var(--sp-2);
|
||||||
border-bottom: 0;
|
padding-right: var(--sp-2);
|
||||||
border-left: 0;
|
}
|
||||||
border-radius: var(--r-2) var(--r-2) 0 0;
|
|
||||||
|
/* `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 {
|
.structured-config-menu {
|
||||||
right: calc(-1 * (var(--control-h) + var(--sp-2)));
|
left: 0;
|
||||||
|
right: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3393,14 +3443,9 @@ input.leaf-rename:focus {
|
|||||||
overflow-y: visible;
|
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 {
|
.spawn-modal .form-actions {
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding: var(--sp-2) 0 calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(var(--sp-2) + env(safe-area-inset-bottom, 0px));
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
|
box-shadow: 0 -8px 12px -8px oklch(0 0 0 / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3482,20 +3527,16 @@ input.leaf-rename:focus {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree {
|
.advanced {
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-2);
|
|
||||||
padding: 0 var(--sp-3);
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree .wt-toggle {
|
.advanced .wt-toggle {
|
||||||
margin: 0 calc(var(--sp-3) * -1);
|
border-top: 1px solid var(--border);
|
||||||
padding-left: var(--sp-3);
|
border-radius: 0;
|
||||||
width: calc(100% + var(--sp-3) * 2);
|
padding-top: var(--sp-2);
|
||||||
|
padding-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wt-summary {
|
.wt-summary {
|
||||||
@@ -3507,13 +3548,27 @@ input.leaf-rename:focus {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
padding-bottom: var(--sp-3);
|
padding: var(--sp-2) 0 var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wt-collapsible[hidden] {
|
.wt-collapsible[hidden] {
|
||||||
display: none;
|
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 {
|
.form-error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -3831,6 +3886,11 @@ input.leaf-rename:focus {
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
background: var(--surface);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reroll {
|
.reroll {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export interface SpawnPreset {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
auto_approve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
|
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
|
||||||
@@ -277,6 +278,7 @@ export interface CreateSessionRequest {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
worktree?: WorktreeSpec;
|
worktree?: WorktreeSpec;
|
||||||
view?: SessionView;
|
view?: SessionView;
|
||||||
|
auto_approve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CatalogEvent =
|
export type CatalogEvent =
|
||||||
|
|||||||
@@ -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