Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c8217385a | ||
|
|
758bacf3fc | ||
|
|
0af91296df | ||
|
|
370edfa763 | ||
|
|
e7086512e7 | ||
|
|
aca8d39886 | ||
|
|
6ae7c3eb97 | ||
|
|
a73eb6284f | ||
|
|
57efb615f6 | ||
|
|
fe26dab6a8 | ||
|
|
8eecd9961e | ||
|
|
ac2eb01bda | ||
|
|
1576380080 | ||
|
|
85feb433ae | ||
|
|
a11933b7bf | ||
|
|
fa40eb7378 | ||
|
|
b680266990 | ||
|
|
ded1f050b2 | ||
|
|
a58ebfed93 | ||
|
|
31b429a840 | ||
|
|
12bd68a2af | ||
|
|
f765d415d6 | ||
|
|
d3e3010245 | ||
|
|
7a0dbbf7f2 | ||
|
|
84fa4a3076 | ||
|
|
eafeac5c05 | ||
|
|
f96f94db8f | ||
|
|
efb54750ed | ||
|
|
9e59121eec | ||
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 |
@@ -19,6 +19,8 @@ Environment knobs, all optional:
|
|||||||
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
|
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
|
||||||
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
|
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
|
||||||
`session/new`
|
`session/new`
|
||||||
|
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
|
||||||
|
`session/new`
|
||||||
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
|
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
|
||||||
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
|
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
|
||||||
so the SIGTERM path is exercised)
|
so the SIGTERM path is exercised)
|
||||||
@@ -133,6 +135,9 @@ for raw in sys.stdin:
|
|||||||
"authMethods": [],
|
"authMethods": [],
|
||||||
}})
|
}})
|
||||||
elif method == "session/new":
|
elif method == "session/new":
|
||||||
|
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
|
||||||
|
while gate and not os.path.exists(gate):
|
||||||
|
time.sleep(0.01)
|
||||||
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
||||||
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
|
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
|
||||||
send(chunk("hello from fake"))
|
send(chunk("hello from fake"))
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
|
|||||||
pub enum AttachError {
|
pub enum AttachError {
|
||||||
#[error("ws: {0}")]
|
#[error("ws: {0}")]
|
||||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||||
|
#[error("daemon returned {status} ({code}): {message}")]
|
||||||
|
Rejected {
|
||||||
|
status: String,
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error("termios: {0}")]
|
#[error("termios: {0}")]
|
||||||
@@ -32,6 +38,21 @@ pub enum AttachError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AttachError {
|
impl AttachError {
|
||||||
|
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||||
|
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
|
||||||
|
if let Some(body) = response.body().as_deref() {
|
||||||
|
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
|
||||||
|
return Self::Rejected {
|
||||||
|
status: response.status().to_string(),
|
||||||
|
code: error_body.error.code,
|
||||||
|
message: error_body.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::WebSocket(error)
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the failure was an inability to reach the WS endpoint at the
|
/// True when the failure was an inability to reach the WS endpoint at the
|
||||||
/// transport level (connection refused), as opposed to an HTTP-level
|
/// transport level (connection refused), as opposed to an HTTP-level
|
||||||
/// rejection from a daemon that did answer.
|
/// rejection from a daemon that did answer.
|
||||||
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
|||||||
/// byte that exits cleanly without killing the session.
|
/// byte that exits cleanly without killing the session.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
let (ws, _resp) = tokio_tungstenite::connect_async(url)
|
||||||
|
.await
|
||||||
|
.map_err(AttachError::from_websocket)?;
|
||||||
let (mut sink, mut stream) = ws.split();
|
let (mut sink, mut stream) = ws.split();
|
||||||
|
|
||||||
let raw = RawTty::enable()?;
|
let raw = RawTty::enable()?;
|
||||||
@@ -233,4 +256,25 @@ mod tests {
|
|||||||
Err(AttachError::InvalidDetachKey(_))
|
Err(AttachError::InvalidDetachKey(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_surfaces_structured_handshake_errors() {
|
||||||
|
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
|
||||||
|
"not_terminal",
|
||||||
|
"session is not a terminal session",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let response = tokio_tungstenite::tungstenite::http::Response::builder()
|
||||||
|
.status(409)
|
||||||
|
.body(Some(body))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error =
|
||||||
|
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::io::Write;
|
|||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
Session, SessionStatus, WorktreeSpec,
|
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -179,8 +179,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
||||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
for s in sorted {
|
for s in sorted {
|
||||||
@@ -190,6 +190,10 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
SessionStatus::Running => "running",
|
SessionStatus::Running => "running",
|
||||||
SessionStatus::Dead => "dead",
|
SessionStatus::Dead => "dead",
|
||||||
};
|
};
|
||||||
|
let view = match s.view {
|
||||||
|
SessionView::Terminal => "terminal",
|
||||||
|
SessionView::Structured => "structured",
|
||||||
|
};
|
||||||
let activity = match s.activity {
|
let activity = match s.activity {
|
||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
@@ -207,8 +211,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
let blocked = blocked_label(s);
|
let blocked = blocked_label(s);
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
||||||
s.id, status, activity, dir, command
|
s.id, status, view, activity, dir, command
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -230,7 +234,7 @@ mod tests {
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::DaemonInfo;
|
use super::DaemonInfo;
|
||||||
@@ -286,6 +290,22 @@ mod tests {
|
|||||||
assert!(s.contains("idle"), "{s}");
|
assert!(s.contains("idle"), "{s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_exposes_terminal_and_structured_views() {
|
||||||
|
let terminal = session("/tmp", "bash", 1);
|
||||||
|
let mut structured = session("/tmp", "claude", 2);
|
||||||
|
structured.view = SessionView::Structured;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
||||||
|
let out = String::from_utf8(buf).unwrap();
|
||||||
|
let lines: Vec<&str> = out.lines().collect();
|
||||||
|
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
||||||
|
|
||||||
|
assert!(lines[header + 1].contains("terminal"), "{out}");
|
||||||
|
assert!(lines[header + 2].contains("structured"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use maestro_protocol::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::session::{QueueAcpPrompt, SessionManager};
|
use crate::session::{QueueAcpPrompt, SessionError, SessionManager};
|
||||||
use crate::sidecar::EventCursor;
|
use crate::sidecar::EventCursor;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -179,6 +179,7 @@ fn session_error_status(code: &str) -> StatusCode {
|
|||||||
// The session outlived its pane: nothing to type into, and nothing
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
// the daemon can do about it.
|
// the daemon can do about it.
|
||||||
"not_running" => StatusCode::CONFLICT,
|
"not_running" => StatusCode::CONFLICT,
|
||||||
|
"not_terminal" => StatusCode::CONFLICT,
|
||||||
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
@@ -534,12 +535,23 @@ async fn attach_ws(
|
|||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
ws: axum::extract::WebSocketUpgrade,
|
ws: axum::extract::WebSocketUpgrade,
|
||||||
) -> Result<Response, Response> {
|
) -> Result<Response, Response> {
|
||||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
||||||
return Err(error_response(
|
Ok(subscription) => subscription,
|
||||||
StatusCode::NOT_FOUND,
|
Err(SessionError::NotFound(_)) => {
|
||||||
"unknown_session",
|
return Err(error_response(
|
||||||
"no such session on this daemon",
|
StatusCode::NOT_FOUND,
|
||||||
));
|
"unknown_session",
|
||||||
|
"no such session on this daemon",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let info = error.to_error_info();
|
||||||
|
return Err(error_response(
|
||||||
|
session_error_status(&info.code),
|
||||||
|
&info.code,
|
||||||
|
&info.message,
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let manager = s.manager.clone();
|
let manager = s.manager.clone();
|
||||||
Ok(ws.on_upgrade(move |socket| async move {
|
Ok(ws.on_upgrade(move |socket| async move {
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
//! handler interact with tmux.
|
//! handler interact with tmux.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::future::Future;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Weak};
|
||||||
|
use std::task::{Context, Poll};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -18,7 +21,7 @@ use maestro_protocol::{
|
|||||||
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, WorktreeSpec, NAME_KEY,
|
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, WorktreeSpec, NAME_KEY,
|
||||||
NAME_SOURCE_KEY,
|
NAME_SOURCE_KEY,
|
||||||
};
|
};
|
||||||
use tokio::sync::{broadcast, oneshot, watch, Mutex};
|
use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::panes::Panes;
|
use crate::panes::Panes;
|
||||||
@@ -99,8 +102,12 @@ pub struct SessionRecord {
|
|||||||
/// outside the sessions lock. Daemon-local; never rides the wire.
|
/// outside the sessions lock. Daemon-local; never rides the wire.
|
||||||
pub acp: Option<Arc<AcpSession>>,
|
pub acp: Option<Arc<AcpSession>>,
|
||||||
/// Open log plus its post-append live sender. Their shared lock is the
|
/// Open log plus its post-append live sender. Their shared lock is the
|
||||||
/// replay/live seam used by the structured WebSocket in #138.
|
/// replay/live seam used by the structured WebSocket in #138. Callers may
|
||||||
|
/// clone this handle under `sessions`, but must never hold either guard
|
||||||
|
/// while acquiring the other.
|
||||||
acp_events: Option<Arc<Mutex<AcpEventStream>>>,
|
acp_events: Option<Arc<Mutex<AcpEventStream>>>,
|
||||||
|
/// Latest activity snapshot applied, keyed to its originating stream.
|
||||||
|
structured_activity_watermark: Option<StructuredActivityWatermark>,
|
||||||
/// 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>>,
|
||||||
@@ -115,6 +122,12 @@ impl SessionRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct StructuredActivityWatermark {
|
||||||
|
stream: Weak<Mutex<AcpEventStream>>,
|
||||||
|
sequence: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct AcpEventStream {
|
struct AcpEventStream {
|
||||||
log: EventLog,
|
log: EventLog,
|
||||||
@@ -126,6 +139,14 @@ struct AcpEventStream {
|
|||||||
/// claimed: it is queued onto ACP before another operation can proceed.
|
/// claimed: it is queued onto ACP before another operation can proceed.
|
||||||
operation: Arc<Mutex<()>>,
|
operation: Arc<Mutex<()>>,
|
||||||
dispatch: AcpDispatchState,
|
dispatch: AcpDispatchState,
|
||||||
|
activity_sequence: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcpEventStream {
|
||||||
|
fn activity_snapshot(&mut self) -> (u64, ActivityState) {
|
||||||
|
self.activity_sequence += 1;
|
||||||
|
(self.activity_sequence, self.projection.activity())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -169,6 +190,8 @@ pub enum SessionError {
|
|||||||
/// a stray keystroke on an attach socket, which is dropped.
|
/// a stray keystroke on an attach socket, which is dropped.
|
||||||
#[error("session is no longer running: {0}")]
|
#[error("session is no longer running: {0}")]
|
||||||
NotRunning(Uuid),
|
NotRunning(Uuid),
|
||||||
|
#[error("session is not a terminal session")]
|
||||||
|
NotTerminal(Uuid),
|
||||||
#[error("daemon is shutting down")]
|
#[error("daemon is shutting down")]
|
||||||
ShuttingDown,
|
ShuttingDown,
|
||||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||||
@@ -205,6 +228,7 @@ impl SessionError {
|
|||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::InvalidEnv(_) => "invalid_env",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
SessionError::NotRunning(_) => "not_running",
|
SessionError::NotRunning(_) => "not_running",
|
||||||
|
SessionError::NotTerminal(_) => "not_terminal",
|
||||||
SessionError::ShuttingDown => "shutting_down",
|
SessionError::ShuttingDown => "shutting_down",
|
||||||
SessionError::KillGuarded(_) => "kill_guarded",
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
};
|
};
|
||||||
@@ -260,9 +284,99 @@ struct Inner {
|
|||||||
/// Once daemon shutdown starts, it owns structured-session finalization.
|
/// Once daemon shutdown starts, it owns structured-session finalization.
|
||||||
/// Background passes must not classify those deliberate exits as natural.
|
/// Background passes must not classify those deliberate exits as natural.
|
||||||
shutting_down: AtomicBool,
|
shutting_down: AtomicBool,
|
||||||
/// Serializes structured creation with shutdown so every adapter child is
|
/// Admission is serialized with shutdown, while admitted handshakes run
|
||||||
/// either rejected or included in shutdown's final drain.
|
/// independently and are counted until registration or rollback finishes.
|
||||||
structured_lifecycle: Mutex<()>,
|
structured_lifecycle: StructuredLifecycle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct StructuredLifecycle {
|
||||||
|
gate: std::sync::Mutex<()>,
|
||||||
|
in_flight: AtomicUsize,
|
||||||
|
settled: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StructuredLifecycle {
|
||||||
|
fn admit<'a>(
|
||||||
|
&'a self,
|
||||||
|
shutting_down: &AtomicBool,
|
||||||
|
) -> Result<StructuredCreate<'a>, SessionError> {
|
||||||
|
let _gate = self.gate.lock().expect("structured lifecycle gate");
|
||||||
|
if shutting_down.load(Ordering::Acquire) {
|
||||||
|
return Err(SessionError::ShuttingDown);
|
||||||
|
}
|
||||||
|
self.in_flight.fetch_add(1, Ordering::AcqRel);
|
||||||
|
Ok(StructuredCreate { lifecycle: self })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until_settled(&self) {
|
||||||
|
loop {
|
||||||
|
let settled = self.settled.notified();
|
||||||
|
if self.in_flight.load(Ordering::Acquire) == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StructuredCreate<'a> {
|
||||||
|
lifecycle: &'a StructuredLifecycle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StructuredCreate<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.lifecycle.in_flight.fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||||
|
self.lifecycle.settled.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdmittedStructuredCreate<'a> {
|
||||||
|
create: Pin<Box<dyn Future<Output = Result<Session, SessionError>> + Send + 'a>>,
|
||||||
|
/// Field order matters: dropping a cancelled create rolls back its inner
|
||||||
|
/// future before this permit releases shutdown's in-flight barrier.
|
||||||
|
_permit: StructuredCreate<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Future for AdmittedStructuredCreate<'_> {
|
||||||
|
type Output = Result<Session, SessionError>;
|
||||||
|
|
||||||
|
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||||
|
self.create.as_mut().poll(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingCreate<'a> {
|
||||||
|
inner: &'a Inner,
|
||||||
|
id: Uuid,
|
||||||
|
fifo_path: PathBuf,
|
||||||
|
worktree_path: Option<PathBuf>,
|
||||||
|
worktree_base_repo: Option<PathBuf>,
|
||||||
|
committed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingCreate<'_> {
|
||||||
|
fn commit(&mut self) {
|
||||||
|
self.committed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PendingCreate<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.committed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(&self.fifo_path);
|
||||||
|
if let Err(error) = self.inner.sidecar.delete(self.id) {
|
||||||
|
tracing::warn!(%error, id = %self.id, "rollback: failed to delete create artifacts");
|
||||||
|
}
|
||||||
|
if let (Some(base), Some(path)) = (&self.worktree_base_repo, &self.worktree_path) {
|
||||||
|
if let Err(error) = self.inner.worktree_mgr.remove(base, path) {
|
||||||
|
tracing::warn!(%error, ?path, "rollback: failed to remove worktree after partial create");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
@@ -306,7 +420,7 @@ impl SessionManager {
|
|||||||
viewports: Mutex::new(Viewports::new()),
|
viewports: Mutex::new(Viewports::new()),
|
||||||
next_viewport: AtomicU64::new(0),
|
next_viewport: AtomicU64::new(0),
|
||||||
shutting_down: AtomicBool::new(false),
|
shutting_down: AtomicBool::new(false),
|
||||||
structured_lifecycle: Mutex::new(()),
|
structured_lifecycle: StructuredLifecycle::default(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -824,10 +938,18 @@ impl SessionManager {
|
|||||||
/// is on disk after the restart is truthful. tmux sessions are left
|
/// is on disk after the restart is truthful. tmux sessions are left
|
||||||
/// alone — surviving a restart is what they are for.
|
/// alone — surviving a restart is what they are for.
|
||||||
pub async fn shutdown(&self) {
|
pub async fn shutdown(&self) {
|
||||||
if self.inner.shutting_down.swap(true, Ordering::AcqRel) {
|
{
|
||||||
return;
|
let _gate = self
|
||||||
|
.inner
|
||||||
|
.structured_lifecycle
|
||||||
|
.gate
|
||||||
|
.lock()
|
||||||
|
.expect("structured lifecycle gate");
|
||||||
|
if self.inner.shutting_down.swap(true, Ordering::AcqRel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _lifecycle = self.inner.structured_lifecycle.lock().await;
|
self.inner.structured_lifecycle.wait_until_settled().await;
|
||||||
let children: Vec<(Uuid, Arc<AcpSession>, Option<watch::Receiver<bool>>)> = {
|
let children: Vec<(Uuid, Arc<AcpSession>, Option<watch::Receiver<bool>>)> = {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
sessions
|
sessions
|
||||||
@@ -879,11 +1001,17 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to a session's PTY broadcast. `None` if the session is
|
/// Subscribe to a terminal session's PTY broadcast.
|
||||||
/// unknown to this daemon.
|
pub async fn subscribe_pty(
|
||||||
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<broadcast::Receiver<Vec<u8>>, SessionError> {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
sessions.get(&id).map(|r| r.bridge.subscribe())
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if record.session.view != SessionView::Terminal {
|
||||||
|
return Err(SessionError::NotTerminal(id));
|
||||||
|
}
|
||||||
|
Ok(record.bridge.subscribe())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
||||||
@@ -897,11 +1025,14 @@ impl SessionManager {
|
|||||||
pub async fn subscribe_pty_with_replay(
|
pub async fn subscribe_pty_with_replay(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
|
) -> Result<(Vec<u8>, broadcast::Receiver<Vec<u8>>), SessionError> {
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let (result, updated) = {
|
let (result, updated) = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let rec = sessions.get_mut(&id)?;
|
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if rec.session.view != SessionView::Terminal {
|
||||||
|
return Err(SessionError::NotTerminal(id));
|
||||||
|
}
|
||||||
rec.session.last_focused_at = Some(Utc::now());
|
rec.session.last_focused_at = Some(Utc::now());
|
||||||
let updated = rec.session.clone();
|
let updated = rec.session.clone();
|
||||||
// For a live session, tmux's own screen beats the byte tail: the
|
// For a live session, tmux's own screen beats the byte tail: the
|
||||||
@@ -932,7 +1063,7 @@ impl SessionManager {
|
|||||||
.inner
|
.inner
|
||||||
.events
|
.events
|
||||||
.send(CatalogEvent::SessionUpdated { session: updated });
|
.send(CatalogEvent::SessionUpdated { session: updated });
|
||||||
Some(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has(&self, id: Uuid) -> bool {
|
pub async fn has(&self, id: Uuid) -> bool {
|
||||||
@@ -945,6 +1076,15 @@ impl SessionManager {
|
|||||||
sessions.get(&id).map(|r| r.session.status)
|
sessions.get(&id).map(|r| r.session.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn terminal_status(&self, id: Uuid) -> Result<SessionStatus, SessionError> {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if record.session.view != SessionView::Terminal {
|
||||||
|
return Err(SessionError::NotTerminal(id));
|
||||||
|
}
|
||||||
|
Ok(record.session.status)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the natural-exit settle step (#82) has finished for this
|
/// Whether the natural-exit settle step (#82) has finished for this
|
||||||
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
||||||
/// still pending, `None` if the session is unknown. `Dead` is observable
|
/// still pending, `None` if the session is unknown. `Dead` is observable
|
||||||
@@ -959,11 +1099,15 @@ impl SessionManager {
|
|||||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||||
if req.view == SessionView::Structured {
|
if req.view == SessionView::Structured {
|
||||||
let _lifecycle = self.inner.structured_lifecycle.lock().await;
|
let permit = self
|
||||||
if self.inner.shutting_down.load(Ordering::Acquire) {
|
.inner
|
||||||
return Err(SessionError::ShuttingDown);
|
.structured_lifecycle
|
||||||
|
.admit(&self.inner.shutting_down)?;
|
||||||
|
return AdmittedStructuredCreate {
|
||||||
|
create: Box::pin(self.create_inner(req)),
|
||||||
|
_permit: permit,
|
||||||
}
|
}
|
||||||
return self.create_inner(req).await;
|
.await;
|
||||||
}
|
}
|
||||||
self.create_inner(req).await
|
self.create_inner(req).await
|
||||||
}
|
}
|
||||||
@@ -1001,22 +1145,17 @@ impl SessionManager {
|
|||||||
// Each side effect below unwinds the ones before it on failure, so a
|
// Each side effect below unwinds the ones before it on failure, so a
|
||||||
// partial create never leaks a worktree or — worse — a ghost tmux
|
// partial create never leaks a worktree or — worse — a ghost tmux
|
||||||
// session with no in-memory record (NAP-260).
|
// session with no in-memory record (NAP-260).
|
||||||
let bridge =
|
let mut pending = PendingCreate {
|
||||||
match PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await {
|
inner: &self.inner,
|
||||||
Ok(b) => b,
|
id,
|
||||||
Err(e) => {
|
fifo_path: fifo_path.clone(),
|
||||||
self.rollback_worktree(&worktree_base_repo, &worktree_path);
|
worktree_path: worktree_path.clone(),
|
||||||
return Err(e.into());
|
worktree_base_repo: worktree_base_repo.clone(),
|
||||||
}
|
committed: false,
|
||||||
};
|
|
||||||
let mut session_env = match sanitize_env(&req.env) {
|
|
||||||
Ok(env) => env,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = std::fs::remove_file(&fifo_path);
|
|
||||||
self.rollback_worktree(&worktree_base_repo, &worktree_path);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
let bridge =
|
||||||
|
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
|
||||||
|
let mut session_env = sanitize_env(&req.env)?;
|
||||||
// Identity + reporting target for the in-session hook (#106). Ours
|
// Identity + reporting target for the in-session hook (#106). Ours
|
||||||
// win over whatever the request carried, so a client cannot spoof
|
// win over whatever the request carried, so a client cannot spoof
|
||||||
// another session's identity (#103).
|
// another session's identity (#103).
|
||||||
@@ -1063,10 +1202,6 @@ impl SessionManager {
|
|||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
if let Err(e) = spawned {
|
if let Err(e) = spawned {
|
||||||
// `bridge` drops at the end of this block, closing the
|
|
||||||
// FIFO reader.
|
|
||||||
let _ = std::fs::remove_file(&fifo_path);
|
|
||||||
self.rollback_worktree(&worktree_base_repo, &worktree_path);
|
|
||||||
return Err(SessionError::Tmux(e));
|
return Err(SessionError::Tmux(e));
|
||||||
}
|
}
|
||||||
(None, None)
|
(None, None)
|
||||||
@@ -1084,8 +1219,6 @@ impl SessionManager {
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
.await;
|
.await;
|
||||||
let _ = std::fs::remove_file(&fifo_path);
|
|
||||||
self.rollback_worktree(&worktree_base_repo, &worktree_path);
|
|
||||||
return Err(SessionError::Sidecar(error));
|
return Err(SessionError::Sidecar(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1105,13 +1238,12 @@ impl SessionManager {
|
|||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
operation: Arc::new(Mutex::new(())),
|
operation: Arc::new(Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
|
activity_sequence: 0,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
.await;
|
.await;
|
||||||
let _ = std::fs::remove_file(&fifo_path);
|
|
||||||
self.rollback_worktree(&worktree_base_repo, &worktree_path);
|
|
||||||
return Err(SessionError::Sidecar(e));
|
return Err(SessionError::Sidecar(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1180,9 +1312,6 @@ impl SessionManager {
|
|||||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&fifo_path);
|
|
||||||
let _ = self.inner.sidecar.delete(id);
|
|
||||||
self.rollback_worktree(&worktree_base_repo, &worktree_path);
|
|
||||||
return Err(SessionError::Sidecar(e));
|
return Err(SessionError::Sidecar(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1192,6 +1321,11 @@ impl SessionManager {
|
|||||||
.map(|(acp, events)| (acp.clone(), events.clone()));
|
.map(|(acp, events)| (acp.clone(), events.clone()));
|
||||||
{
|
{
|
||||||
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 done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||||
|
start_acp_permission_pump(&self.inner, id, &acp, events);
|
||||||
|
done
|
||||||
|
});
|
||||||
sessions.insert(
|
sessions.insert(
|
||||||
id,
|
id,
|
||||||
SessionRecord {
|
SessionRecord {
|
||||||
@@ -1208,17 +1342,11 @@ impl SessionManager {
|
|||||||
awaiting_suppressed: false,
|
awaiting_suppressed: false,
|
||||||
acp,
|
acp,
|
||||||
acp_events,
|
acp_events,
|
||||||
acp_event_pump: None,
|
structured_activity_watermark: None,
|
||||||
|
acp_event_pump: event_pump,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some((acp, events)) = event_pump {
|
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
|
||||||
start_acp_permission_pump(&self.inner, id, &acp, events);
|
|
||||||
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
|
||||||
record.acp_event_pump = Some(done);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = self.inner.events.send(CatalogEvent::SessionAdded {
|
let _ = self.inner.events.send(CatalogEvent::SessionAdded {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
});
|
});
|
||||||
@@ -1243,19 +1371,10 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pending.commit();
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort removal of a worktree created earlier in `create` when a
|
|
||||||
/// later step fails. No-op when the request had no worktree.
|
|
||||||
fn rollback_worktree(&self, base: &Option<PathBuf>, path: &Option<PathBuf>) {
|
|
||||||
if let (Some(base), Some(path)) = (base, path) {
|
|
||||||
if let Err(e) = self.inner.worktree_mgr.remove(base, path) {
|
|
||||||
tracing::warn!(error = %e, ?path, "rollback: failed to remove worktree after partial create");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kill a session and remove its on-disk artifacts.
|
/// Kill a session and remove its on-disk artifacts.
|
||||||
///
|
///
|
||||||
/// Refused (#79) when the session's worktree holds unsaved work —
|
/// Refused (#79) when the session's worktree holds unsaved work —
|
||||||
@@ -1435,6 +1554,7 @@ impl SessionManager {
|
|||||||
// daemon; only tmux-backed sessions can be adopted.
|
// daemon; only tmux-backed sessions can be adopted.
|
||||||
acp: None,
|
acp: None,
|
||||||
acp_events: None,
|
acp_events: None,
|
||||||
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1556,6 +1676,7 @@ impl SessionManager {
|
|||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
operation: Arc::new(Mutex::new(())),
|
operation: Arc::new(Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
|
activity_sequence: 0,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -1603,6 +1724,7 @@ impl SessionManager {
|
|||||||
awaiting_suppressed: false,
|
awaiting_suppressed: false,
|
||||||
acp,
|
acp,
|
||||||
acp_events,
|
acp_events,
|
||||||
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1626,10 +1748,9 @@ impl SessionManager {
|
|||||||
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
||||||
/// client would have to interpret.
|
/// client would have to interpret.
|
||||||
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.terminal_status(id).await? {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
SessionStatus::Dead => return Ok(()),
|
||||||
Some(SessionStatus::Dead) => return Ok(()),
|
SessionStatus::Running => {}
|
||||||
Some(SessionStatus::Running) => {}
|
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let bytes = bytes.to_vec();
|
let bytes = bytes.to_vec();
|
||||||
@@ -1645,10 +1766,9 @@ impl SessionManager {
|
|||||||
/// asked for text to land somewhere, and a silent success would let a
|
/// asked for text to land somewhere, and a silent success would let a
|
||||||
/// script believe an agent had been told something it never heard.
|
/// script believe an agent had been told something it never heard.
|
||||||
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.terminal_status(id).await? {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
SessionStatus::Dead => return Err(SessionError::NotRunning(id)),
|
||||||
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
SessionStatus::Running => {}
|
||||||
Some(SessionStatus::Running) => {}
|
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let bytes = text.as_bytes().to_vec();
|
let bytes = text.as_bytes().to_vec();
|
||||||
@@ -1668,10 +1788,9 @@ impl SessionManager {
|
|||||||
/// Resize a session's window. No-op for a dead session, as with
|
/// Resize a session's window. No-op for a dead session, as with
|
||||||
/// [`Self::send_keys`].
|
/// [`Self::send_keys`].
|
||||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.terminal_status(id).await? {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
SessionStatus::Dead => return Ok(()),
|
||||||
Some(SessionStatus::Dead) => return Ok(()),
|
SessionStatus::Running => {}
|
||||||
Some(SessionStatus::Running) => {}
|
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
tmux_blocking(move || tmux.resize_window(id, cols, rows))
|
tmux_blocking(move || tmux.resize_window(id, cols, rows))
|
||||||
@@ -1703,6 +1822,7 @@ impl SessionManager {
|
|||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
) -> Result<(), SessionError> {
|
) -> Result<(), SessionError> {
|
||||||
|
self.terminal_status(id).await?;
|
||||||
let target = self
|
let target = self
|
||||||
.inner
|
.inner
|
||||||
.viewports
|
.viewports
|
||||||
@@ -2025,57 +2145,59 @@ async fn sync_structured_activity_from_stream(
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
events: Arc<Mutex<AcpEventStream>>,
|
events: Arc<Mutex<AcpEventStream>>,
|
||||||
) {
|
) {
|
||||||
let event_stream = events.lock().await;
|
let (sequence, raw) = events.lock().await.activity_snapshot();
|
||||||
let raw = event_stream.projection.activity();
|
apply_structured_activity(inner, id, Some((&events, sequence)), raw).await;
|
||||||
// Stream state must stay fixed until its catalog transition is applied;
|
|
||||||
// every other path releases `sessions` before awaiting this stream lock.
|
|
||||||
apply_structured_activity(inner, id, Some(&events), raw).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_structured_activity(
|
async fn apply_structured_activity(
|
||||||
inner: &Arc<Inner>,
|
inner: &Arc<Inner>,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
expected_events: Option<&Arc<Mutex<AcpEventStream>>>,
|
expected_events: Option<(&Arc<Mutex<AcpEventStream>>, u64)>,
|
||||||
raw: ActivityState,
|
raw: ActivityState,
|
||||||
) {
|
) {
|
||||||
let updated = {
|
let mut sessions = inner.sessions.lock().await;
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let Some(record) = sessions.get_mut(&id) else {
|
||||||
let Some(record) = sessions.get_mut(&id) else {
|
return;
|
||||||
return;
|
|
||||||
};
|
|
||||||
if record.session.view != SessionView::Structured {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_events = record.acp_events.as_ref();
|
|
||||||
let stream_matches = match (expected_events, current_events) {
|
|
||||||
(Some(expected), Some(current)) => Arc::ptr_eq(expected, current),
|
|
||||||
(None, None) => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
if !stream_matches {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let raw = if record.session.status == SessionStatus::Dead {
|
|
||||||
ActivityState::Error
|
|
||||||
} else {
|
|
||||||
raw
|
|
||||||
};
|
|
||||||
let activity = crate::activity::apply_focus_suppression(
|
|
||||||
raw,
|
|
||||||
record.focused_since,
|
|
||||||
std::time::Instant::now(),
|
|
||||||
&mut record.awaiting_suppressed,
|
|
||||||
);
|
|
||||||
if record.session.activity == activity {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
record.session.activity = activity;
|
|
||||||
record.session.activity_since = Utc::now();
|
|
||||||
Some(record.session.clone())
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if let Some(session) = updated {
|
if record.session.view != SessionView::Structured {
|
||||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
return;
|
||||||
|
}
|
||||||
|
let current_events = record.acp_events.as_ref();
|
||||||
|
match (expected_events, current_events) {
|
||||||
|
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
|
||||||
|
let stream = Arc::downgrade(current);
|
||||||
|
if record
|
||||||
|
.structured_activity_watermark
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|watermark| {
|
||||||
|
watermark.stream.ptr_eq(&stream) && sequence < watermark.sequence
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
record.structured_activity_watermark =
|
||||||
|
Some(StructuredActivityWatermark { stream, sequence });
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
let raw = if record.session.status == SessionStatus::Dead {
|
||||||
|
ActivityState::Error
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
let activity = crate::activity::apply_focus_suppression(
|
||||||
|
raw,
|
||||||
|
record.focused_since,
|
||||||
|
std::time::Instant::now(),
|
||||||
|
&mut record.awaiting_suppressed,
|
||||||
|
);
|
||||||
|
if record.session.activity != activity {
|
||||||
|
record.session.activity = activity;
|
||||||
|
record.session.activity_since = Utc::now();
|
||||||
|
let _ = inner.events.send(CatalogEvent::SessionUpdated {
|
||||||
|
session: record.session.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2752,16 +2874,17 @@ mod tests {
|
|||||||
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
|
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
|
||||||
ToolCallUpdate, ToolCallUpdateFields,
|
ToolCallUpdate, ToolCallUpdateFields,
|
||||||
};
|
};
|
||||||
use maestro_protocol::{ActivityState, CreateSessionRequest, SessionView};
|
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionView};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
cancel_pending_approvals, permission_outcome, record_permission_request,
|
apply_structured_activity, cancel_pending_approvals, permission_outcome,
|
||||||
run_structured_activity_pass, sync_structured_activity_from_stream, AcpDispatchState,
|
record_permission_request, run_structured_activity_pass,
|
||||||
AcpEventStream, ApprovalDecision, RecordOutcome, SessionManager,
|
sync_structured_activity_from_stream, AcpDispatchState, AcpEventStream, ApprovalDecision,
|
||||||
|
RecordOutcome, SessionManager,
|
||||||
};
|
};
|
||||||
use crate::activity::FOCUS_CLEAR_HOLD;
|
use crate::activity::FOCUS_CLEAR_HOLD;
|
||||||
use crate::panes::FakePanes;
|
use crate::panes::FakePanes;
|
||||||
use crate::projection::Projection;
|
use crate::projection::{Projection, TurnLifecycle};
|
||||||
use crate::sidecar::{
|
use crate::sidecar::{
|
||||||
EventCursor, EventLog, FaultyLogSink, PersistedEvent, SharedBuf, SidecarStore,
|
EventCursor, EventLog, FaultyLogSink, PersistedEvent, SharedBuf, SidecarStore,
|
||||||
};
|
};
|
||||||
@@ -2790,6 +2913,7 @@ mod tests {
|
|||||||
pending_approvals: std::collections::HashMap::new(),
|
pending_approvals: std::collections::HashMap::new(),
|
||||||
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
|
activity_sequence: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2807,13 +2931,15 @@ mod tests {
|
|||||||
async fn manager_with_structured_stream() -> (
|
async fn manager_with_structured_stream() -> (
|
||||||
tempfile::TempDir,
|
tempfile::TempDir,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
|
Arc<FakePanes>,
|
||||||
uuid::Uuid,
|
uuid::Uuid,
|
||||||
Arc<tokio::sync::Mutex<AcpEventStream>>,
|
Arc<tokio::sync::Mutex<AcpEventStream>>,
|
||||||
uuid::Uuid,
|
uuid::Uuid,
|
||||||
) {
|
) {
|
||||||
let state = tempfile::tempdir().unwrap();
|
let state = tempfile::tempdir().unwrap();
|
||||||
|
let panes = Arc::new(FakePanes::new());
|
||||||
let manager = SessionManager::new(
|
let manager = SessionManager::new(
|
||||||
Arc::new(FakePanes::new()),
|
panes.clone(),
|
||||||
SidecarStore::new(state.path().join("sessions")),
|
SidecarStore::new(state.path().join("sessions")),
|
||||||
WorktreeManager::new(None),
|
WorktreeManager::new(None),
|
||||||
state.path().join("fifos"),
|
state.path().join("fifos"),
|
||||||
@@ -2848,12 +2974,12 @@ mod tests {
|
|||||||
record.focused_since = Some(std::time::Instant::now() - FOCUS_CLEAR_HOLD);
|
record.focused_since = Some(std::time::Instant::now() - FOCUS_CLEAR_HOLD);
|
||||||
record.acp_events = Some(events.clone());
|
record.acp_events = Some(events.clone());
|
||||||
}
|
}
|
||||||
(state, manager, structured.id, events, terminal.id)
|
(state, manager, panes, structured.id, events, terminal.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn blocked_structured_activity_read_does_not_block_the_catalog() {
|
async fn blocked_structured_activity_read_does_not_block_the_catalog() {
|
||||||
let (state, manager, structured_id, events, terminal_id) =
|
let (state, manager, _panes, structured_id, events, terminal_id) =
|
||||||
manager_with_structured_stream().await;
|
manager_with_structured_stream().await;
|
||||||
let held_stream = events.lock().await;
|
let held_stream = events.lock().await;
|
||||||
let activity_pass = run_structured_activity_pass(&manager.inner);
|
let activity_pass = run_structured_activity_pass(&manager.inner);
|
||||||
@@ -2898,9 +3024,136 @@ mod tests {
|
|||||||
assert_eq!(structured.activity, ActivityState::Idle);
|
assert_eq!(structured.activity, ActivityState::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_sessions_reject_every_pty_operation_before_panes() {
|
||||||
|
let (_state, manager, panes, structured_id, _events, terminal_id) =
|
||||||
|
manager_with_structured_stream().await;
|
||||||
|
let before = panes.calls();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
manager.subscribe_pty(structured_id).await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager.subscribe_pty_with_replay(structured_id).await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager.send_keys(structured_id, b"ignored").await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager.send_input(structured_id, "ignored", false).await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager.resize(structured_id, 80, 24).await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager
|
||||||
|
.set_viewport(structured_id, manager.attach_viewport(), 80, 24)
|
||||||
|
.await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
|
||||||
|
let after = panes.calls();
|
||||||
|
assert_eq!(after, before, "structured PTY operations touched panes");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.send_input(terminal_id, "works", false)
|
||||||
|
.await
|
||||||
|
.expect("terminal input remains available");
|
||||||
|
manager
|
||||||
|
.resize(terminal_id, 100, 30)
|
||||||
|
.await
|
||||||
|
.expect("terminal resize remains available");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_activity_releases_stream_before_waiting_for_catalog() {
|
||||||
|
let (_state, manager, _panes, structured_id, events, _terminal_id) =
|
||||||
|
manager_with_structured_stream().await;
|
||||||
|
let sessions = manager.inner.sessions.lock().await;
|
||||||
|
let sync =
|
||||||
|
sync_structured_activity_from_stream(&manager.inner, structured_id, events.clone());
|
||||||
|
tokio::pin!(sync);
|
||||||
|
assert!(matches!(futures_util::poll!(sync.as_mut()), Poll::Pending));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
events.try_lock().is_ok(),
|
||||||
|
"activity sync held the event stream while waiting for the catalog"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(sessions);
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), sync)
|
||||||
|
.await
|
||||||
|
.expect("activity sync did not finish");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn newer_structured_activity_snapshot_cannot_be_overtaken() {
|
||||||
|
let (_state, manager, _panes, structured_id, events, _terminal_id) =
|
||||||
|
manager_with_structured_stream().await;
|
||||||
|
let mut catalog = manager.events();
|
||||||
|
manager
|
||||||
|
.inner
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get_mut(&structured_id)
|
||||||
|
.unwrap()
|
||||||
|
.focused_since = None;
|
||||||
|
|
||||||
|
let (older_sequence, older_activity) = events.lock().await.activity_snapshot();
|
||||||
|
let (newer_sequence, newer_activity) = {
|
||||||
|
let mut event_stream = events.lock().await;
|
||||||
|
let turn = event_stream.projection.active_turn().unwrap();
|
||||||
|
event_stream.projection.clear_approval();
|
||||||
|
event_stream
|
||||||
|
.projection
|
||||||
|
.turn_lifecycle(TurnLifecycle::Finish, turn);
|
||||||
|
event_stream.activity_snapshot()
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_structured_activity(
|
||||||
|
&manager.inner,
|
||||||
|
structured_id,
|
||||||
|
Some((&events, newer_sequence)),
|
||||||
|
newer_activity,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
apply_structured_activity(
|
||||||
|
&manager.inner,
|
||||||
|
structured_id,
|
||||||
|
Some((&events, older_sequence)),
|
||||||
|
older_activity,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let CatalogEvent::SessionUpdated { session } = catalog.try_recv().unwrap() else {
|
||||||
|
panic!("unexpected catalog event");
|
||||||
|
};
|
||||||
|
assert_eq!(session.activity, ActivityState::Idle);
|
||||||
|
assert!(matches!(
|
||||||
|
catalog.try_recv(),
|
||||||
|
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|session| session.id == structured_id)
|
||||||
|
.unwrap()
|
||||||
|
.activity,
|
||||||
|
ActivityState::Idle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delayed_activity_from_a_replaced_stream_is_discarded() {
|
async fn delayed_activity_from_a_replaced_stream_is_discarded() {
|
||||||
let (_state, manager, structured_id, old_events, _terminal_id) =
|
let (_state, manager, _panes, structured_id, old_events, _terminal_id) =
|
||||||
manager_with_structured_stream().await;
|
manager_with_structured_stream().await;
|
||||||
let replacement = Arc::new(tokio::sync::Mutex::new(stream(
|
let replacement = Arc::new(tokio::sync::Mutex::new(stream(
|
||||||
manager
|
manager
|
||||||
@@ -2931,6 +3184,73 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replacement_accepts_new_activity_after_a_high_old_watermark() {
|
||||||
|
let (_state, manager, _panes, structured_id, old_events, _terminal_id) =
|
||||||
|
manager_with_structured_stream().await;
|
||||||
|
let replacement = Arc::new(tokio::sync::Mutex::new(stream(
|
||||||
|
manager
|
||||||
|
.inner
|
||||||
|
.sidecar
|
||||||
|
.open_events(uuid::Uuid::new_v4())
|
||||||
|
.expect("open replacement event log"),
|
||||||
|
)));
|
||||||
|
manager
|
||||||
|
.inner
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get_mut(&structured_id)
|
||||||
|
.unwrap()
|
||||||
|
.focused_since = None;
|
||||||
|
let _ = old_events.lock().await.activity_snapshot();
|
||||||
|
let (old_high_sequence, old_high_activity) = old_events.lock().await.activity_snapshot();
|
||||||
|
apply_structured_activity(
|
||||||
|
&manager.inner,
|
||||||
|
structured_id,
|
||||||
|
Some((&old_events, old_high_sequence)),
|
||||||
|
old_high_activity,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (delayed_old_sequence, delayed_old_activity) =
|
||||||
|
old_events.lock().await.activity_snapshot();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut sessions = manager.inner.sessions.lock().await;
|
||||||
|
let record = sessions.get_mut(&structured_id).unwrap();
|
||||||
|
record.acp_events = Some(replacement.clone());
|
||||||
|
record.session.activity = ActivityState::Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (new_sequence, new_activity) = replacement.lock().await.activity_snapshot();
|
||||||
|
assert!(new_sequence < old_high_sequence);
|
||||||
|
apply_structured_activity(
|
||||||
|
&manager.inner,
|
||||||
|
structured_id,
|
||||||
|
Some((&replacement, new_sequence)),
|
||||||
|
new_activity,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
apply_structured_activity(
|
||||||
|
&manager.inner,
|
||||||
|
structured_id,
|
||||||
|
Some((&old_events, delayed_old_sequence)),
|
||||||
|
delayed_old_activity,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let sessions = manager.inner.sessions.lock().await;
|
||||||
|
let record = sessions.get(&structured_id).unwrap();
|
||||||
|
assert_eq!(record.session.activity, ActivityState::Idle);
|
||||||
|
let watermark = record.structured_activity_watermark.as_ref().unwrap();
|
||||||
|
assert_eq!(watermark.sequence, new_sequence);
|
||||||
|
assert!(watermark.stream.ptr_eq(&Arc::downgrade(&replacement)));
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
record.acp_events.as_ref().unwrap(),
|
||||||
|
&replacement
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// A handle that can never append *or* roll back: opened read-only, so
|
/// A handle that can never append *or* roll back: opened read-only, so
|
||||||
/// both `write_all` and the rollback `truncate` fail with the OS's own
|
/// both `write_all` and the rollback `truncate` fail with the OS's own
|
||||||
/// error (`ftruncate` requires a writable fd too) instead of a
|
/// error (`ftruncate` requires a writable fd too) instead of a
|
||||||
|
|||||||
@@ -950,6 +950,191 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
|
|||||||
assert_eq!(fake.log().matches("signal SIGTERM").count(), 1);
|
assert_eq!(fake.log().matches("signal SIGTERM").count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_handshakes_run_independently() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let blocked = Fake::new();
|
||||||
|
let ready = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let gate = cwd.path().join("release-first");
|
||||||
|
let mut blocked_env = blocked.env();
|
||||||
|
blocked_env.insert(
|
||||||
|
"FAKE_ACP_SESSION_NEW_GATE".into(),
|
||||||
|
gate.to_string_lossy().into_owned(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let first_cwd = cwd.path().to_path_buf();
|
||||||
|
let manager = fx.manager.clone();
|
||||||
|
let first =
|
||||||
|
tokio::spawn(async move { manager.create(structured(&first_cwd, blocked_env)).await });
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
blocked.log().contains("recv session/new")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let second = tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
fx.manager.create(structured(cwd.path(), ready.env())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("second handshake was blocked by the first")
|
||||||
|
.expect("second create");
|
||||||
|
assert_eq!(second.status, SessionStatus::Running);
|
||||||
|
assert!(!first.is_finished(), "the gate must still block the first");
|
||||||
|
|
||||||
|
std::fs::write(&gate, b"release").unwrap();
|
||||||
|
let first = first.await.expect("first task").expect("first create");
|
||||||
|
fx.manager.kill(first.id, false).await.expect("kill first");
|
||||||
|
fx.manager
|
||||||
|
.kill(second.id, false)
|
||||||
|
.await
|
||||||
|
.expect("kill second");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_waits_for_every_admitted_structured_create() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let a = Fake::new();
|
||||||
|
let b = Fake::new();
|
||||||
|
let late = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let gate_a = cwd.path().join("release-a");
|
||||||
|
let gate_b = cwd.path().join("release-b");
|
||||||
|
let mut env_a = a.env();
|
||||||
|
let mut env_b = b.env();
|
||||||
|
env_a.insert(
|
||||||
|
"FAKE_ACP_SESSION_NEW_GATE".into(),
|
||||||
|
gate_a.to_string_lossy().into_owned(),
|
||||||
|
);
|
||||||
|
env_b.insert(
|
||||||
|
"FAKE_ACP_SESSION_NEW_GATE".into(),
|
||||||
|
gate_b.to_string_lossy().into_owned(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let create_a = {
|
||||||
|
let manager = fx.manager.clone();
|
||||||
|
let cwd = cwd.path().to_path_buf();
|
||||||
|
tokio::spawn(async move { manager.create(structured(&cwd, env_a)).await })
|
||||||
|
};
|
||||||
|
let create_b = {
|
||||||
|
let manager = fx.manager.clone();
|
||||||
|
let cwd = cwd.path().to_path_buf();
|
||||||
|
tokio::spawn(async move { manager.create(structured(&cwd, env_b)).await })
|
||||||
|
};
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
a.log().contains("recv session/new") && b.log().contains("recv session/new")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let shutdown = {
|
||||||
|
let manager = fx.manager.clone();
|
||||||
|
tokio::spawn(async move { manager.shutdown().await })
|
||||||
|
};
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
matches!(
|
||||||
|
fx.manager.create(structured(cwd.path(), late.env())).await,
|
||||||
|
Err(maestro_daemon::SessionError::ShuttingDown)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(!shutdown.is_finished());
|
||||||
|
|
||||||
|
std::fs::write(&gate_a, b"release").unwrap();
|
||||||
|
let session_a = create_a.await.expect("create a task").expect("create a");
|
||||||
|
assert!(
|
||||||
|
!shutdown.is_finished(),
|
||||||
|
"shutdown ignored the second create"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(&gate_b, b"release").unwrap();
|
||||||
|
let session_b = create_b.await.expect("create b task").expect("create b");
|
||||||
|
shutdown.await.expect("shutdown task");
|
||||||
|
|
||||||
|
wait_for_exit(a.pid()).await;
|
||||||
|
wait_for_exit(b.pid()).await;
|
||||||
|
assert_eq!(
|
||||||
|
fx.manager.status(session_a.id).await,
|
||||||
|
Some(SessionStatus::Dead)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fx.manager.status(session_b.id).await,
|
||||||
|
Some(SessionStatus::Dead)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelling_a_structured_handshake_rolls_back_and_settles_admission() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let fake = Fake::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let gate = repo.path().join("release-create");
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
||||||
|
env.insert(
|
||||||
|
"FAKE_ACP_SESSION_NEW_GATE".into(),
|
||||||
|
gate.to_string_lossy().into_owned(),
|
||||||
|
);
|
||||||
|
let request = CreateSessionRequest {
|
||||||
|
view: SessionView::Structured,
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "claude".into(),
|
||||||
|
env,
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "cancelled-create".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let create = {
|
||||||
|
let manager = fx.manager.clone();
|
||||||
|
tokio::spawn(async move { manager.create(request).await })
|
||||||
|
};
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
fake.log().contains("recv session/new")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let id = fake
|
||||||
|
.log()
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("env MAESTRO_SESSION_ID="))
|
||||||
|
.and_then(|id| uuid::Uuid::parse_str(id).ok())
|
||||||
|
.expect("session id in adapter log");
|
||||||
|
let pid = fake.pid();
|
||||||
|
|
||||||
|
create.abort();
|
||||||
|
let shutdown = {
|
||||||
|
let manager = fx.manager.clone();
|
||||||
|
tokio::spawn(async move { manager.shutdown().await })
|
||||||
|
};
|
||||||
|
assert!(create.await.unwrap_err().is_cancelled());
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), shutdown)
|
||||||
|
.await
|
||||||
|
.expect("cancelled admission kept shutdown waiting")
|
||||||
|
.expect("shutdown task");
|
||||||
|
wait_for_exit(pid).await;
|
||||||
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
|
!fx.fifo_dir().join(format!("{id}.fifo")).exists()
|
||||||
|
&& !repo.path().join("cancelled-create").exists()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(fx.manager.snapshot().await.is_empty());
|
||||||
|
let id = id.to_string();
|
||||||
|
assert!(
|
||||||
|
std::fs::read_dir(fx.sidecar_dir())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.all(|entry| !entry
|
||||||
|
.unwrap()
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with(&id)),
|
||||||
|
"cancelled create left a sidecar artifact"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn shutdown_drains_final_notifications_to_the_log() {
|
async fn shutdown_drains_final_notifications_to_the_log() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
|
|||||||
@@ -1311,6 +1311,39 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
assert!(saw_hello, "PTY did not echo through the attach WS");
|
assert!(saw_hello, "PTY did not echo through the attach WS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
|
||||||
|
.json(&maestro_protocol::SendInputRequest {
|
||||||
|
text: "ignored".into(),
|
||||||
|
submit: false,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let error: ApiError = response.json().await.unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_terminal");
|
||||||
|
assert_eq!(error.error.message, "session is not a terminal session");
|
||||||
|
|
||||||
|
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
|
||||||
|
.await
|
||||||
|
.expect_err("structured session must not upgrade to a PTY socket");
|
||||||
|
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
|
||||||
|
panic!("expected an HTTP handshake rejection, got {error}");
|
||||||
|
};
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let body = response.body().as_deref().expect("ApiError response body");
|
||||||
|
let error: ApiError = serde_json::from_slice(body).unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_terminal");
|
||||||
|
assert_eq!(error.error.message, "session is not a terminal session");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn attach_ws_for_unknown_session_returns_404() {
|
async fn attach_ws_for_unknown_session_returns_404() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
|
|||||||
@@ -166,45 +166,32 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
|||||||
cur
|
cur
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Command wrappers that run another program: the program is what follows.
|
|
||||||
const WRAPPERS: [&str; 8] = [
|
|
||||||
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The program a command line runs, basenamed and stripped of its arguments —
|
/// The program a command line runs, basenamed and stripped of its arguments —
|
||||||
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
||||||
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
||||||
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
|
/// `npx`, `pnpm dlx`, …) are looked through, and quoting/escaping is honoured
|
||||||
|
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
|
||||||
|
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
|
||||||
|
/// substitution, an unterminated quote) falls back to the naive first word
|
||||||
|
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
|
||||||
pub fn command_program(command: &str) -> String {
|
pub fn command_program(command: &str) -> String {
|
||||||
let words: Vec<&str> = command.split_whitespace().collect();
|
match resolve(command) {
|
||||||
let mut i = 0;
|
Some(r) => match r.words.get(r.program_index) {
|
||||||
while i < words.len() {
|
Some(word) => basename(word).to_string(),
|
||||||
let word = words[i];
|
// Nothing but wrappers and assignments: say what was there
|
||||||
if is_env_assignment(word) {
|
// rather than rendering an empty fragment.
|
||||||
i += 1;
|
None => command.trim().to_string(),
|
||||||
continue;
|
},
|
||||||
}
|
None => legacy_first_word(command),
|
||||||
let wrapper = basename(word).to_lowercase();
|
|
||||||
if WRAPPERS.contains(&wrapper.as_str()) {
|
|
||||||
i += 1;
|
|
||||||
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match words.get(i) {
|
|
||||||
Some(word) => basename(word).to_string(),
|
|
||||||
// Nothing but wrappers and assignments: say what was there rather than
|
|
||||||
// rendering an empty fragment.
|
|
||||||
None => command.trim().to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
||||||
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
||||||
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
||||||
|
/// `claude-code-acp` — the daemon's actual structured-session child — is
|
||||||
|
/// deliberately absent: the daemon launches it directly and ignores
|
||||||
|
/// `req.command`, so it is never itself a valid user-facing launch form.
|
||||||
const BY_PROGRAM: [(&str, &str); 17] = [
|
const BY_PROGRAM: [(&str, &str); 17] = [
|
||||||
("claude", "claude"),
|
("claude", "claude"),
|
||||||
("claude-code", "claude"),
|
("claude-code", "claude"),
|
||||||
@@ -226,29 +213,491 @@ const BY_PROGRAM: [(&str, &str); 17] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
||||||
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
|
/// `opencode`, ...), or `None` when the program is not one we have a mark for
|
||||||
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
|
/// — including a command line [`resolve`] would only guess at. The command
|
||||||
/// the same tools the web's `splitCommand().tool` does.
|
/// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
|
||||||
|
/// tools the web's `splitCommand().tool` does.
|
||||||
pub fn command_tool(command: &str) -> Option<&'static str> {
|
pub fn command_tool(command: &str) -> Option<&'static str> {
|
||||||
let program = command_program(command).to_lowercase();
|
let r = resolve(command)?;
|
||||||
|
let program = basename(r.words.get(r.program_index)?).to_lowercase();
|
||||||
BY_PROGRAM
|
BY_PROGRAM
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(name, _)| *name == program)
|
.find(|(name, _)| *name == program)
|
||||||
.map(|(_, tool)| *tool)
|
.map(|(_, tool)| *tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
||||||
fn is_env_assignment(word: &str) -> bool {
|
/// of the word that is the actual program (past every assignment/wrapper it
|
||||||
let mut chars = word.chars();
|
/// resolved through).
|
||||||
|
struct Resolution {
|
||||||
|
words: Vec<String>,
|
||||||
|
program_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokenize `command` and walk past leading `FOO=bar` assignments and known
|
||||||
|
/// wrapper forms (interleaved in any order/count) to find the program. An
|
||||||
|
/// assignment run is only consumed at the very start of the line or right
|
||||||
|
/// after a wrapper whose own semantics accept one there ([`Wrapper::accepts_assignment`]) —
|
||||||
|
/// `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
|
||||||
|
/// skipping to `claude`, since `command`/`nohup` just exec their next argument
|
||||||
|
/// as the program, they don't setenv from an assignment-shaped one (#199).
|
||||||
|
/// `None` when [`tokenize`] can't safely make sense of the line at all.
|
||||||
|
fn resolve(command: &str) -> Option<Resolution> {
|
||||||
|
let words = tokenize(command)?;
|
||||||
|
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut assignment_allowed = true;
|
||||||
|
loop {
|
||||||
|
if assignment_allowed {
|
||||||
|
while matches!(words.get(i), Some(word) if is_env_assignment(word)) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(word) = words.get(i) else { break };
|
||||||
|
let wrapper = basename(&word.text).to_lowercase();
|
||||||
|
let Some(w) = wrapper_resolver(&wrapper) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
match (w.resolve)(&refs, i) {
|
||||||
|
Some(next) if next > i => {
|
||||||
|
i = next;
|
||||||
|
assignment_allowed = w.accepts_assignment;
|
||||||
|
}
|
||||||
|
// Not a form of the wrapper we recognise (an unknown option, or
|
||||||
|
// — for `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it):
|
||||||
|
// the wrapper word itself is the program, not a launcher.
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Resolution {
|
||||||
|
words: words.into_iter().map(|w| w.text).collect(),
|
||||||
|
program_index: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The naive "first whitespace word, basenamed" reading used only when
|
||||||
|
/// [`tokenize`] rejects the line outright — still something sane to show,
|
||||||
|
/// never a wrapper/tool match to act on.
|
||||||
|
fn legacy_first_word(command: &str) -> String {
|
||||||
|
match command.split_whitespace().next() {
|
||||||
|
Some(word) => basename(word).to_string(),
|
||||||
|
None => command.trim().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One token from [`tokenize`]: its dequoted text, and — when both the name
|
||||||
|
/// and `=` were entirely unquoted/unescaped in the source — the byte length
|
||||||
|
/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
|
||||||
|
/// makes the word an executable named `FOO=1`; quoting/escaping only the value
|
||||||
|
/// preserves the assignment, matching Bash's assignment-word grammar (#199).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Word {
|
||||||
|
text: String,
|
||||||
|
plain_eq: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
|
||||||
|
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
|
||||||
|
/// `None` the moment it needs real shell grammar this resolver doesn't
|
||||||
|
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
|
||||||
|
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
|
||||||
|
/// separator, like `;` — #199), or an unterminated quote/escape. Never
|
||||||
|
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
|
||||||
|
/// backslash immediately before a newline, quoted or not, is a line
|
||||||
|
/// continuation: removed entirely rather than embedding the newline or
|
||||||
|
/// ending the word (#199).
|
||||||
|
fn tokenize(command: &str) -> Option<Vec<Word>> {
|
||||||
|
let mut words = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut has_current = false;
|
||||||
|
let mut plain_eq: Option<usize> = None;
|
||||||
|
let mut assignment_name_is_plain = true;
|
||||||
|
let mut chars = command.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
' ' | '\t' | '\r' => {
|
||||||
|
if has_current {
|
||||||
|
words.push(Word {
|
||||||
|
text: std::mem::take(&mut current),
|
||||||
|
plain_eq: plain_eq.take(),
|
||||||
|
});
|
||||||
|
has_current = false;
|
||||||
|
assignment_name_is_plain = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'\n' => return None,
|
||||||
|
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
|
||||||
|
'$' if chars.peek() == Some(&'(') => return None,
|
||||||
|
'\'' => {
|
||||||
|
has_current = true;
|
||||||
|
if plain_eq.is_none() {
|
||||||
|
assignment_name_is_plain = false;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
match chars.next() {
|
||||||
|
Some('\'') => break,
|
||||||
|
Some(c2) => current.push(c2),
|
||||||
|
None => return None, // unterminated single quote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'"' => {
|
||||||
|
has_current = true;
|
||||||
|
if plain_eq.is_none() {
|
||||||
|
assignment_name_is_plain = false;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
match chars.next() {
|
||||||
|
Some('"') => break,
|
||||||
|
Some('\\') => match chars.next() {
|
||||||
|
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
|
||||||
|
Some('\n') => {} // line continuation, removed entirely
|
||||||
|
Some(c2) => {
|
||||||
|
current.push('\\');
|
||||||
|
current.push(c2);
|
||||||
|
}
|
||||||
|
None => return None, // trailing backslash
|
||||||
|
},
|
||||||
|
Some(c2) => current.push(c2),
|
||||||
|
None => return None, // unterminated double quote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'\\' => match chars.next() {
|
||||||
|
Some('\n') => {} // line continuation, removed entirely
|
||||||
|
Some(c2) => {
|
||||||
|
has_current = true;
|
||||||
|
if plain_eq.is_none() {
|
||||||
|
assignment_name_is_plain = false;
|
||||||
|
}
|
||||||
|
current.push(c2);
|
||||||
|
}
|
||||||
|
None => return None, // trailing backslash
|
||||||
|
},
|
||||||
|
other => {
|
||||||
|
has_current = true;
|
||||||
|
if other == '=' && plain_eq.is_none() && assignment_name_is_plain {
|
||||||
|
plain_eq = Some(current.len());
|
||||||
|
}
|
||||||
|
current.push(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_current {
|
||||||
|
words.push(Word {
|
||||||
|
text: current,
|
||||||
|
plain_eq,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper's known `-`/`--` options: which take no value, and which
|
||||||
|
/// consume either a following word or a `--flag=value` as theirs. A
|
||||||
|
/// `-`-prefixed word this table doesn't know halts resolution rather than
|
||||||
|
/// guessing past it (#199) — callers read that as [`OptionTable::consume`]
|
||||||
|
/// returning `None`.
|
||||||
|
struct OptionTable {
|
||||||
|
flags: &'static [&'static str],
|
||||||
|
valued: &'static [&'static str],
|
||||||
|
/// Whether this wrapper's short options may be combined (`-iv`) or take
|
||||||
|
/// an attached value (`-uroot`), the way real getopt-based tools do
|
||||||
|
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
|
||||||
|
/// `dlx`) don't get this — guessing at bundling they don't actually
|
||||||
|
/// support would resolve through options they'd reject outright.
|
||||||
|
bundles_short_options: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OptionTable {
|
||||||
|
/// Consume this table's options starting at `words[i]`, returning the
|
||||||
|
/// index just past them (which may equal `i` if there are none) — or
|
||||||
|
/// `None` on an option this table doesn't recognise, or a valued option
|
||||||
|
/// missing its operand.
|
||||||
|
fn consume(&self, words: &[&str], mut i: usize) -> Option<usize> {
|
||||||
|
loop {
|
||||||
|
let Some(&word) = words.get(i) else {
|
||||||
|
return Some(i);
|
||||||
|
};
|
||||||
|
if word == "--" {
|
||||||
|
return Some(i + 1);
|
||||||
|
}
|
||||||
|
if word == "-" || !word.starts_with('-') {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
if let Some((flag, value)) = word.split_once('=') {
|
||||||
|
return if self.valued.contains(&flag) && !value.is_empty() {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if self.flags.contains(&word) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.valued.contains(&word) {
|
||||||
|
let value = words.get(i + 1)?;
|
||||||
|
if value.is_empty() || value.starts_with('-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.bundles_short_options
|
||||||
|
&& !word.starts_with("--")
|
||||||
|
&& word.len() > 2
|
||||||
|
&& self.matches_short_cluster(word)
|
||||||
|
{
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
|
||||||
|
/// option with its value attached) — getopt's traditional short-option
|
||||||
|
/// forms. `false` the moment a character isn't a short option this
|
||||||
|
/// table knows, or a valued one ends the word with no attached value
|
||||||
|
/// left to be its operand (#199: rejected rather than guessed to come
|
||||||
|
/// from the next word instead).
|
||||||
|
fn matches_short_cluster(&self, word: &str) -> bool {
|
||||||
|
if !word.is_ascii() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let bytes = word.as_bytes();
|
||||||
|
let mut buf = [b'-', 0];
|
||||||
|
let mut idx = 1;
|
||||||
|
while idx < bytes.len() {
|
||||||
|
buf[1] = bytes[idx];
|
||||||
|
let short = std::str::from_utf8(&buf).expect("ascii buffer");
|
||||||
|
if self.flags.contains(&short) {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.valued.contains(&short) {
|
||||||
|
return idx + 1 < bytes.len();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_OPTIONS: OptionTable = OptionTable {
|
||||||
|
flags: &[],
|
||||||
|
valued: &[],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENV_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &[
|
||||||
|
"-i",
|
||||||
|
"--ignore-environment",
|
||||||
|
"-0",
|
||||||
|
"--null",
|
||||||
|
"-v",
|
||||||
|
"--debug",
|
||||||
|
],
|
||||||
|
valued: &["-u", "--unset", "-C", "--chdir"],
|
||||||
|
bundles_short_options: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUDO_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &[
|
||||||
|
"-i",
|
||||||
|
"--login",
|
||||||
|
"-n",
|
||||||
|
"--non-interactive",
|
||||||
|
"-E",
|
||||||
|
"--preserve-env",
|
||||||
|
"-H",
|
||||||
|
"--set-home",
|
||||||
|
"-A",
|
||||||
|
"--askpass",
|
||||||
|
"-b",
|
||||||
|
"--background",
|
||||||
|
"-k",
|
||||||
|
"--reset-timestamp",
|
||||||
|
"-K",
|
||||||
|
"--remove-timestamp",
|
||||||
|
"-S",
|
||||||
|
"--stdin",
|
||||||
|
],
|
||||||
|
valued: &["-u", "--user", "-g", "--group"],
|
||||||
|
bundles_short_options: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `-v`/`-V` only print what would run — they never execute it — so
|
||||||
|
/// resolving through them would claim a program runs that `command` never
|
||||||
|
/// launches (#199). Only `-p`, which still executes normally, is safe to
|
||||||
|
/// look through.
|
||||||
|
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["-p"],
|
||||||
|
valued: &[],
|
||||||
|
bundles_short_options: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
|
||||||
|
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
|
||||||
|
/// `/usr/bin/time`, a different program a bare `time` word doesn't
|
||||||
|
/// necessarily name. Assuming them here would be guessing which `time`
|
||||||
|
/// resolves (#199).
|
||||||
|
fn resolve_time(words: &[&str], i: usize) -> Option<usize> {
|
||||||
|
let mut next = i + 1;
|
||||||
|
if words.get(next) == Some(&"-p") {
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
if words.get(next) == Some(&"--") {
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
match words.get(next) {
|
||||||
|
Some(word) if word.starts_with('-') => None,
|
||||||
|
_ => Some(next),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `-c`/`--call` embeds a whole shell command string rather than naming the
|
||||||
|
/// program positionally — resolving past it would mean guessing at a
|
||||||
|
/// program buried inside that string, so it's left unrecognised and
|
||||||
|
/// resolution stops at `npx` (#199).
|
||||||
|
const NPX_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
|
||||||
|
valued: &["-p", "--package", "--shell"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PNPM_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &[
|
||||||
|
"--silent",
|
||||||
|
"-s",
|
||||||
|
"--stream",
|
||||||
|
"--aggregate-output",
|
||||||
|
"--no-progress",
|
||||||
|
],
|
||||||
|
// `--package`/`--package=value` is documented in this pre-`dlx`
|
||||||
|
// position (`pnpm --package=x dlx y`), alongside the identical flag
|
||||||
|
// `dlx` itself also accepts (`PNPM_DLX_OPTS`).
|
||||||
|
valued: &["-C", "--dir", "--filter", "--package"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const YARN_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
|
||||||
|
valued: &["--cwd"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PNPM_DLX_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["-s", "--silent"],
|
||||||
|
valued: &["--package"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const YARN_DLX_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["-q", "--quiet"],
|
||||||
|
valued: &["-p", "--package"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUN_X_OPTS: OptionTable = OptionTable {
|
||||||
|
flags: &["--bun", "--no-install", "--verbose", "--silent"],
|
||||||
|
valued: &["-p", "--package"],
|
||||||
|
bundles_short_options: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A resolver for one wrapper form: given the tokenized command and the
|
||||||
|
/// index of the wrapper word itself, the index of the word after it (past
|
||||||
|
/// its options and any subcommand it requires), or `None` if this occurrence
|
||||||
|
/// isn't a form we resolve through.
|
||||||
|
type WrapperResolver = fn(&[&str], usize) -> Option<usize>;
|
||||||
|
|
||||||
|
/// One recognised wrapper form: how to walk past it, and whether an
|
||||||
|
/// assignment word is a valid follow-on once it's been walked past.
|
||||||
|
struct Wrapper {
|
||||||
|
resolve: WrapperResolver,
|
||||||
|
/// `true` only for wrappers that themselves interpret a following
|
||||||
|
/// `FOO=bar` as an environment assignment for the command they launch
|
||||||
|
/// (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
|
||||||
|
/// grammar for a brand new simple command (`time`). Every other wrapper
|
||||||
|
/// here is a plain executable that execs its next argument as the
|
||||||
|
/// program outright — an assignment-shaped one names a program literally
|
||||||
|
/// called `FOO=1`, not an env var (#199).
|
||||||
|
accepts_assignment: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrapper_resolver(name: &str) -> Option<Wrapper> {
|
||||||
|
let (resolve, accepts_assignment): (WrapperResolver, bool) = match name {
|
||||||
|
"env" => (|words, i| ENV_OPTS.consume(words, i + 1), true),
|
||||||
|
"sudo" => (|words, i| SUDO_OPTS.consume(words, i + 1), true),
|
||||||
|
"command" => (|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1), false),
|
||||||
|
"nohup" => (|words, i| NO_OPTIONS.consume(words, i + 1), false),
|
||||||
|
"time" => (resolve_time, true),
|
||||||
|
"npx" => (|words, i| NPX_OPTS.consume(words, i + 1), false),
|
||||||
|
"pnpm" => (
|
||||||
|
|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
"yarn" => (
|
||||||
|
|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
"bun" => (resolve_bun, false),
|
||||||
|
"bunx" => (|words, i| BUN_X_OPTS.consume(words, i + 1), false),
|
||||||
|
"uvx" => (|_words, i| Some(i + 1), false),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(Wrapper {
|
||||||
|
resolve,
|
||||||
|
accepts_assignment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
|
||||||
|
/// — without it, the next word is a `package.json` script name, not a
|
||||||
|
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
|
||||||
|
/// itself takes launcher-specific options too, consumed before the executable.
|
||||||
|
fn resolve_dlx(
|
||||||
|
opts: &OptionTable,
|
||||||
|
dlx_opts: &OptionTable,
|
||||||
|
words: &[&str],
|
||||||
|
i: usize,
|
||||||
|
) -> Option<usize> {
|
||||||
|
let j = opts.consume(words, i + 1)?;
|
||||||
|
if words.get(j) != Some(&"dlx") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
dlx_opts.consume(words, j + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
|
||||||
|
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
|
||||||
|
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
|
||||||
|
/// the executable.
|
||||||
|
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
|
||||||
|
if words.get(i + 1) != Some(&"x") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
BUN_X_OPTS.consume(words, i + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FOO=bar`, the shell's own prefix for one command's environment —
|
||||||
|
/// recognised only when the `=` came from plain, unquoted/unescaped source
|
||||||
|
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
|
||||||
|
/// an assignment.
|
||||||
|
fn is_env_assignment(word: &Word) -> bool {
|
||||||
|
let Some(eq) = word.plain_eq else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let name = &word.text[..eq];
|
||||||
|
let mut chars = name.chars();
|
||||||
match chars.next() {
|
match chars.next() {
|
||||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||||
_ => return false,
|
_ => return false,
|
||||||
}
|
}
|
||||||
let rest: String = chars.collect();
|
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
match rest.split_once('=') {
|
|
||||||
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn basename(word: &str) -> &str {
|
fn basename(word: &str) -> &str {
|
||||||
@@ -497,38 +946,122 @@ mod tests {
|
|||||||
"claude"
|
"claude"
|
||||||
);
|
);
|
||||||
assert_eq!(command_program(" zsh -l "), "zsh");
|
assert_eq!(command_program(" zsh -l "), "zsh");
|
||||||
assert_eq!(command_program("hx src/main.rs"), "hx");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The #199 resolver contract: every case in the shared parity fixture,
|
||||||
|
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
|
||||||
|
/// resolver regression in either language fails its own suite.
|
||||||
#[test]
|
#[test]
|
||||||
fn command_program_looks_through_paths_env_and_wrappers() {
|
fn resolves_the_shared_command_resolution_fixture() {
|
||||||
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
|
#[derive(serde::Deserialize)]
|
||||||
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
|
struct Case {
|
||||||
assert_eq!(command_program("npx opencode"), "opencode");
|
command: String,
|
||||||
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
|
program: String,
|
||||||
|
tool: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Fixture {
|
||||||
|
cases: Vec<Case>,
|
||||||
|
}
|
||||||
|
let raw = include_str!("../../../fixtures/command-resolution.json");
|
||||||
|
let fixture: Fixture = serde_json::from_str(raw).expect("valid fixture JSON");
|
||||||
|
assert!(!fixture.cases.is_empty());
|
||||||
|
for case in &fixture.cases {
|
||||||
|
assert_eq!(
|
||||||
|
command_program(&case.command),
|
||||||
|
case.program,
|
||||||
|
"program for {:?}",
|
||||||
|
case.command
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
command_tool(&case.command),
|
||||||
|
case.tool.as_deref(),
|
||||||
|
"tool for {:?}",
|
||||||
|
case.command
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn word(text: &str) -> Word {
|
||||||
fn command_program_keeps_a_line_that_is_only_wrappers() {
|
Word {
|
||||||
assert_eq!(command_program("sudo"), "sudo");
|
text: text.into(),
|
||||||
assert_eq!(command_program(""), "");
|
plain_eq: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
|
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
|
||||||
/// tools the web's `splitCommand().tool` does.
|
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
|
||||||
#[test]
|
#[test]
|
||||||
fn command_tool_names_the_mark_the_card_shows() {
|
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
|
||||||
assert_eq!(command_tool("claude --resume"), Some("claude"));
|
assert_eq!(
|
||||||
assert_eq!(command_tool("npx opencode"), Some("opencode"));
|
tokenize("claude --resume"),
|
||||||
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
|
Some(vec![word("claude"), word("--resume")])
|
||||||
assert_eq!(command_tool("CLAUDE"), Some("claude"));
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("\"/opt/my agent/claude\" --resume"),
|
||||||
|
Some(vec![word("/opt/my agent/claude"), word("--resume")])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("FOO=\"bar baz\" claude"),
|
||||||
|
Some(vec![
|
||||||
|
Word {
|
||||||
|
text: "FOO=bar baz".into(),
|
||||||
|
plain_eq: Some(3),
|
||||||
|
},
|
||||||
|
word("claude"),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("claude --name=$HOME"),
|
||||||
|
Some(vec![
|
||||||
|
word("claude"),
|
||||||
|
Word {
|
||||||
|
text: "--name=$HOME".into(),
|
||||||
|
plain_eq: Some(6),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"a bare $ is kept literal, never expanded"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Narrow on purpose: an unrecognised program has no mark, so the
|
/// Real shell grammar this resolver doesn't attempt — pipelines, list
|
||||||
/// candidate has no `tool` field to match rather than a wrong one.
|
/// operators, subshells/substitution, an unescaped newline, an
|
||||||
|
/// unterminated quote — is rejected outright rather than guessed
|
||||||
|
/// through (#199).
|
||||||
#[test]
|
#[test]
|
||||||
fn command_tool_is_absent_for_an_unrecognised_program() {
|
fn tokenize_rejects_ambiguous_shell_grammar() {
|
||||||
assert_eq!(command_tool("hx src/main.rs"), None);
|
for command in [
|
||||||
assert_eq!(command_tool(""), None);
|
"claude | tee log.txt",
|
||||||
|
"claude; echo done",
|
||||||
|
"claude && echo done",
|
||||||
|
"echo hi $(claude)",
|
||||||
|
"claude \"unterminated",
|
||||||
|
"claude 'unterminated",
|
||||||
|
"claude \\",
|
||||||
|
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
|
||||||
|
] {
|
||||||
|
assert_eq!(tokenize(command), None, "{command:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A backslash immediately before a newline is a line continuation:
|
||||||
|
/// removed entirely, joining what it split rather than embedding a
|
||||||
|
/// literal newline or ending the command (#199).
|
||||||
|
#[test]
|
||||||
|
fn tokenize_treats_backslash_newline_as_a_continuation() {
|
||||||
|
assert_eq!(
|
||||||
|
tokenize("clau\\\nde --resume"),
|
||||||
|
Some(vec![word("claude"), word("--resume")])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
|
||||||
|
/// quoting or escaping the `=` itself disqualifies the word, even
|
||||||
|
/// though the dequoted text looks identical (#199).
|
||||||
|
#[test]
|
||||||
|
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
|
||||||
|
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
|
||||||
|
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
||||||
|
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ impl App {
|
|||||||
.state
|
.state
|
||||||
.lock()
|
.lock()
|
||||||
.expect("structured state mutex")
|
.expect("structured state mutex")
|
||||||
.approval_failed(error.to_string());
|
.approval_failed(&nonce, error.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+263
-11
@@ -41,8 +41,10 @@ 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;
|
use maestro_protocol::display::{abbreviate_dir, command_tool};
|
||||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
use maestro_protocol::{
|
||||||
|
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||||
|
};
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -86,9 +88,11 @@ pub enum DraftField {
|
|||||||
/// apply immediately, mirroring the web form's per-click choice.
|
/// apply immediately, mirroring the web form's per-click choice.
|
||||||
WorktreeChoice,
|
WorktreeChoice,
|
||||||
Name,
|
Name,
|
||||||
/// Disclosure collapsing the worktree/branch controls below (#93).
|
/// Disclosure collapsing the optional view and worktree controls below.
|
||||||
/// Closed by default; Enter / Space toggles.
|
/// Closed by default; Enter / Space toggles.
|
||||||
Advanced,
|
Advanced,
|
||||||
|
/// ACP-backed transcript instead of a terminal. Claude Code only.
|
||||||
|
StructuredToggle,
|
||||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||||
WorktreeToggle,
|
WorktreeToggle,
|
||||||
/// New-vs-existing branch source; Left / Right switches.
|
/// New-vs-existing branch source; Left / Right switches.
|
||||||
@@ -251,6 +255,9 @@ 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
|
||||||
|
/// when the resolved command is Claude Code.
|
||||||
|
pub structured_view: 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>,
|
||||||
@@ -311,6 +318,7 @@ impl Default for DraftPane {
|
|||||||
name_touched: false,
|
name_touched: false,
|
||||||
worktree_enabled: true,
|
worktree_enabled: true,
|
||||||
advanced_collapsed: true,
|
advanced_collapsed: true,
|
||||||
|
structured_view: false,
|
||||||
base_repo: None,
|
base_repo: None,
|
||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
@@ -348,6 +356,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -663,6 +672,22 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn supports_structured_view(&self) -> bool {
|
||||||
|
command_tool(self.command()) == Some("claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_unsupported_structured_view(&mut self) {
|
||||||
|
if !self.supports_structured_view() {
|
||||||
|
self.structured_view = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_structured_view(&mut self) {
|
||||||
|
if self.supports_structured_view() {
|
||||||
|
self.structured_view = !self.structured_view;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The chosen working directory: a picked project row, else the raw path
|
/// The chosen working directory: a picked project row, else the raw path
|
||||||
/// input when it holds something.
|
/// input when it holds something.
|
||||||
pub fn cwd(&self) -> Option<PathBuf> {
|
pub fn cwd(&self) -> Option<PathBuf> {
|
||||||
@@ -694,7 +719,11 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
let worktree = self.build_worktree()?;
|
let worktree = self.build_worktree()?;
|
||||||
Ok(CreateSessionRequest {
|
Ok(CreateSessionRequest {
|
||||||
view: Default::default(),
|
view: if self.structured_view && self.supports_structured_view() {
|
||||||
|
SessionView::Structured
|
||||||
|
} else {
|
||||||
|
SessionView::Terminal
|
||||||
|
},
|
||||||
cwd,
|
cwd,
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -754,6 +783,9 @@ impl DraftPane {
|
|||||||
order.push(DraftField::Name);
|
order.push(DraftField::Name);
|
||||||
order.push(DraftField::Advanced);
|
order.push(DraftField::Advanced);
|
||||||
if !self.advanced_collapsed {
|
if !self.advanced_collapsed {
|
||||||
|
if self.supports_structured_view() {
|
||||||
|
order.push(DraftField::StructuredToggle);
|
||||||
|
}
|
||||||
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||||
order.push(DraftField::WorktreeChoice);
|
order.push(DraftField::WorktreeChoice);
|
||||||
}
|
}
|
||||||
@@ -795,6 +827,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||||
@@ -1001,9 +1034,9 @@ impl DraftPane {
|
|||||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||||
// highlighted row isn't the picked one until a first Enter chooses
|
// highlighted row isn't the picked one until a first Enter chooses
|
||||||
// it (the next Enter, now that it's active, falls through to
|
// it (the next Enter, now that it's active, falls through to
|
||||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
// submit); and the disclosure/checkbox fields, which activate on
|
||||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
// Enter like buttons rather than submitting, matching the web
|
||||||
// than submitting, same as the web form's own `<button>` controls.
|
// form controls.
|
||||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||||
let choose_only =
|
let choose_only =
|
||||||
@@ -1023,6 +1056,10 @@ impl DraftPane {
|
|||||||
self.toggle_worktree();
|
self.toggle_worktree();
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
}
|
}
|
||||||
|
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
|
||||||
|
self.toggle_structured_view();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
InputKey::Enter => DraftInputResult::Submit,
|
InputKey::Enter => DraftInputResult::Submit,
|
||||||
InputKey::Char {
|
InputKey::Char {
|
||||||
c: ' ',
|
c: ' ',
|
||||||
@@ -1040,6 +1077,14 @@ impl DraftPane {
|
|||||||
self.toggle_worktree();
|
self.toggle_worktree();
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
}
|
}
|
||||||
|
InputKey::Char {
|
||||||
|
c: ' ',
|
||||||
|
ctrl: false,
|
||||||
|
..
|
||||||
|
} if self.focused_field == DraftField::StructuredToggle => {
|
||||||
|
self.toggle_structured_view();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
||||||
self.move_selection(-1);
|
self.move_selection(-1);
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
@@ -1083,7 +1128,10 @@ impl DraftPane {
|
|||||||
other => {
|
other => {
|
||||||
match self.focused_field {
|
match self.focused_field {
|
||||||
DraftField::Preset | DraftField::Submit => {}
|
DraftField::Preset | DraftField::Submit => {}
|
||||||
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
DraftField::CustomCommand => {
|
||||||
|
self.custom_command.handle_key(other);
|
||||||
|
self.clear_unsupported_structured_view();
|
||||||
|
}
|
||||||
// Typing filters the list; the highlight resets to the top
|
// 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.
|
||||||
DraftField::DirPicker => {
|
DraftField::DirPicker => {
|
||||||
@@ -1121,9 +1169,9 @@ impl DraftPane {
|
|||||||
|
|
||||||
let show_custom = self.is_custom();
|
let show_custom = self.is_custom();
|
||||||
let show_raw = self.cwd_raw_visible;
|
let show_raw = self.cwd_raw_visible;
|
||||||
// The worktree choice step, the worktree toggle and its body all stay
|
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
|
||||||
// behind the "Advanced options" disclosure (#93): collapsed hides
|
// Optional view and worktree controls stay behind the "Advanced
|
||||||
// them, regardless of what they'd otherwise show.
|
// options" disclosure (#93), regardless of what they'd otherwise show.
|
||||||
let show_wt_toggle = !self.advanced_collapsed;
|
let show_wt_toggle = !self.advanced_collapsed;
|
||||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||||
let show_wt_choice =
|
let show_wt_choice =
|
||||||
@@ -1142,6 +1190,7 @@ impl DraftPane {
|
|||||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||||
+ u16::from(show_custom)
|
+ u16::from(show_custom)
|
||||||
+ u16::from(show_raw)
|
+ u16::from(show_raw)
|
||||||
|
+ u16::from(show_structured)
|
||||||
+ u16::from(show_wt_toggle)
|
+ u16::from(show_wt_toggle)
|
||||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||||
@@ -1161,6 +1210,9 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
constraints.push(Constraint::Length(1)); // name
|
constraints.push(Constraint::Length(1)); // name
|
||||||
constraints.push(Constraint::Length(1)); // advanced header
|
constraints.push(Constraint::Length(1)); // advanced header
|
||||||
|
if show_structured {
|
||||||
|
constraints.push(Constraint::Length(1));
|
||||||
|
}
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||||
}
|
}
|
||||||
@@ -1359,6 +1411,24 @@ impl DraftPane {
|
|||||||
advanced_row,
|
advanced_row,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if show_structured {
|
||||||
|
let row = next_row();
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(format!("{:<GUTTER$}", "view:")),
|
||||||
|
Span::styled(
|
||||||
|
if self.structured_view {
|
||||||
|
"[x] structured view"
|
||||||
|
} else {
|
||||||
|
"[ ] structured view"
|
||||||
|
},
|
||||||
|
field_style(DraftField::StructuredToggle),
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
row,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||||
// worktrees newest-first.
|
// worktrees newest-first.
|
||||||
@@ -1817,6 +1887,188 @@ mod tests {
|
|||||||
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
assert!(!draft.structured_view);
|
||||||
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
assert!(rendered.contains("[ ] structured view"), "{rendered}");
|
||||||
|
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "opencode");
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"a hidden value must not stay checked"
|
||||||
|
);
|
||||||
|
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
assert!(!rendered.contains("structured view"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_view_request_is_guarded_against_a_stale_command() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
structured_view: true,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
|
draft.selected_preset = 1;
|
||||||
|
assert_eq!(draft.command(), "opencode");
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A custom command typed into the free-text field, run through the same
|
||||||
|
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
|
||||||
|
/// env prefix, or a wrapper still resolves to Claude, so the toggle stays
|
||||||
|
/// offered and the request carries `Structured`; an unrelated program
|
||||||
|
/// does not.
|
||||||
|
#[test]
|
||||||
|
fn structured_view_follows_command_resolution_for_custom_commands() {
|
||||||
|
let cases: &[(&str, bool)] = &[
|
||||||
|
("claude", true),
|
||||||
|
("claude --resume", true),
|
||||||
|
("CLAUDE", true),
|
||||||
|
("/usr/local/bin/claude -p hi", true),
|
||||||
|
("./bin/claude", true),
|
||||||
|
("FOO=1 BAR=2 claude", true),
|
||||||
|
("npx claude-code", true),
|
||||||
|
("npx --yes claude-code", true),
|
||||||
|
("sudo claude", true),
|
||||||
|
("sudo -u root claude", true),
|
||||||
|
("env -i claude", true),
|
||||||
|
("nohup claude", true),
|
||||||
|
("time claude", true),
|
||||||
|
("command claude", true),
|
||||||
|
("pnpm --silent dlx claude-code", true),
|
||||||
|
("yarn --silent dlx claude-code", true),
|
||||||
|
("bun x claude-code", true),
|
||||||
|
("\"/opt/my agent/claude\" --resume", true),
|
||||||
|
("bash", false),
|
||||||
|
("opencode", false),
|
||||||
|
("/usr/local/bin/bash -l", false),
|
||||||
|
// A package-manager wrapper without `dlx`/`x` runs a
|
||||||
|
// `package.json` script, not the package (#199) — must not be
|
||||||
|
// mistaken for a Claude launch.
|
||||||
|
("pnpm claude", false),
|
||||||
|
("yarn claude", false),
|
||||||
|
// The daemon's structured-session child, never a valid
|
||||||
|
// user-facing launch form (#199).
|
||||||
|
("claude-code-acp", false),
|
||||||
|
];
|
||||||
|
for &(command, supported) in cases {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
|
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
draft.field_order().contains(&DraftField::StructuredToggle),
|
||||||
|
supported,
|
||||||
|
"toggle visibility for {command:?}"
|
||||||
|
);
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert_eq!(
|
||||||
|
draft.structured_view, supported,
|
||||||
|
"toggle takes effect for {command:?}"
|
||||||
|
);
|
||||||
|
let view = draft.build_request().unwrap().view;
|
||||||
|
assert_eq!(
|
||||||
|
view,
|
||||||
|
if supported {
|
||||||
|
SessionView::Structured
|
||||||
|
} else {
|
||||||
|
SessionView::Terminal
|
||||||
|
},
|
||||||
|
"request view for {command:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A configured preset (the spawn-form "alias" for a command) is gated
|
||||||
|
/// the same way as free text: a preset whose command resolves to Claude
|
||||||
|
/// through a wrapper still offers structured view.
|
||||||
|
#[test]
|
||||||
|
fn structured_view_follows_command_resolution_for_presets() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![
|
||||||
|
SpawnPreset {
|
||||||
|
id: "claude-wrapped".into(),
|
||||||
|
label: "Claude (npx)".into(),
|
||||||
|
command: "npx claude-code --resume".into(),
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "aider".into(),
|
||||||
|
label: "Aider".into(),
|
||||||
|
command: "aider".into(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(draft.command(), "npx claude-code --resume");
|
||||||
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
|
// Switching to a preset that does not resolve to Claude clears the
|
||||||
|
// stale selection instead of letting an invalid request through.
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "aider");
|
||||||
|
assert!(!draft.structured_view, "stale selection must clear");
|
||||||
|
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Editing the custom-command text away from Claude clears an armed
|
||||||
|
/// selection immediately, matching the preset-switch case above — an
|
||||||
|
/// invalid `Structured` request can never reach `build_request`.
|
||||||
|
#[test]
|
||||||
|
fn structured_view_clears_when_custom_command_edited_away_from_claude() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
advanced_collapsed: false,
|
||||||
|
focused_field: DraftField::CustomCommand,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
|
draft.custom_command = crate::text_input::TextInput::with_value("claude");
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
|
||||||
|
typed(&mut draft, " -p");
|
||||||
|
assert_eq!(draft.command(), "claude -p");
|
||||||
|
assert!(draft.structured_view, "still Claude, selection survives");
|
||||||
|
|
||||||
|
for _ in 0.."claude -p".chars().count() {
|
||||||
|
draft.on_input(&InputKey::Backspace);
|
||||||
|
}
|
||||||
|
typed(&mut draft, "bash");
|
||||||
|
assert_eq!(draft.command(), "bash");
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"editing the program away from claude clears the stale selection"
|
||||||
|
);
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use crate::session_task::SessionState;
|
|||||||
use crate::structured_task::{offered_decisions, StructuredHandle, StructuredState};
|
use crate::structured_task::{offered_decisions, StructuredHandle, StructuredState};
|
||||||
|
|
||||||
const DIFF_PREVIEW_LINES: usize = 3;
|
const DIFF_PREVIEW_LINES: usize = 3;
|
||||||
|
const DIFF_CONTEXT_LINES: usize = 3;
|
||||||
|
const LCS_CELL_LIMIT: usize = 250_000;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StructuredPane {
|
pub struct StructuredPane {
|
||||||
@@ -88,6 +90,7 @@ impl StructuredPane {
|
|||||||
|
|
||||||
let viewport = usize::from(area.height);
|
let viewport = usize::from(area.height);
|
||||||
let max_scroll = lines.len().saturating_sub(viewport);
|
let max_scroll = lines.len().saturating_sub(viewport);
|
||||||
|
state.scroll = reconcile_scroll(&state.scroll, &state.line_rows, &lines);
|
||||||
if state.follow_selection {
|
if state.follow_selection {
|
||||||
if let Some(selected) = state.selected.as_deref() {
|
if let Some(selected) = state.selected.as_deref() {
|
||||||
let first = lines
|
let first = lines
|
||||||
@@ -136,6 +139,7 @@ impl StructuredPane {
|
|||||||
Rect::new(area.x, area.y + offset as u16, area.width, 1),
|
Rect::new(area.x, area.y + offset as u16, area.width, 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
state.line_rows = lines.iter().map(|line| line.row_id.clone()).collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
|
fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
|
||||||
@@ -212,6 +216,96 @@ fn error_line(text: &str) -> VisualLine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Translate `scroll` — a raw line index into `old_rows` (the previous
|
||||||
|
/// render's row-per-line layout) — into the equivalent index into `lines`
|
||||||
|
/// (this render's freshly rebuilt layout), anchored on row identity rather
|
||||||
|
/// than raw position. A row's wrapped lines above or intersecting the
|
||||||
|
/// viewport growing, shrinking, or moving index therefore shifts `scroll`
|
||||||
|
/// along with it instead of leaving the viewport looking at different
|
||||||
|
/// content. Falls back to the nearest surviving neighbour row when the
|
||||||
|
/// anchor row itself is gone, and to the raw index (clamped) when nothing
|
||||||
|
/// nearby survived either.
|
||||||
|
fn reconcile_scroll(scroll: &usize, old_rows: &[Option<String>], lines: &[VisualLine]) -> usize {
|
||||||
|
if old_rows.is_empty() {
|
||||||
|
return *scroll;
|
||||||
|
}
|
||||||
|
let old_index = (*scroll).min(old_rows.len() - 1);
|
||||||
|
let anchor = old_rows[old_index].clone();
|
||||||
|
match anchor {
|
||||||
|
Some(id) => match find_row_block(lines, &id) {
|
||||||
|
Some((start, len)) => {
|
||||||
|
start + (old_index - block_start(old_rows, old_index)).min(len - 1)
|
||||||
|
}
|
||||||
|
None => fallback_after_removal(old_rows, lines, old_index),
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
// Untagged tail lines (connecting/dead/error) carry no row identity;
|
||||||
|
// approximate by preserving distance from the end of the transcript.
|
||||||
|
let distance_from_end = old_rows.len() - old_index;
|
||||||
|
lines.len().saturating_sub(distance_from_end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index of the first line belonging to `id`, and how many lines it spans.
|
||||||
|
/// A row's wrapped lines are always contiguous in `lines` — `build_lines`
|
||||||
|
/// emits them as one run per row — so a single forward scan suffices.
|
||||||
|
fn find_row_block(lines: &[VisualLine], id: &str) -> Option<(usize, usize)> {
|
||||||
|
let start = lines
|
||||||
|
.iter()
|
||||||
|
.position(|line| line.row_id.as_deref() == Some(id))?;
|
||||||
|
let mut end = start;
|
||||||
|
while end + 1 < lines.len() && lines[end + 1].row_id.as_deref() == Some(id) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
Some((start, end - start + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First index of the contiguous run that `index` sits in within `rows`.
|
||||||
|
fn block_start(rows: &[Option<String>], index: usize) -> usize {
|
||||||
|
let mut start = index;
|
||||||
|
while start > 0 && rows[start - 1] == rows[index] {
|
||||||
|
start -= 1;
|
||||||
|
}
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The anchor row is gone (removed, or replaced wholesale by a fresh
|
||||||
|
/// snapshot). Search outward from its old position, nearest first, for a
|
||||||
|
/// still-live neighbour row and land on its first line; if none of the old
|
||||||
|
/// layout's rows survived, fall back to the raw old index clamped into the
|
||||||
|
/// new layout.
|
||||||
|
fn fallback_after_removal(
|
||||||
|
old_rows: &[Option<String>],
|
||||||
|
lines: &[VisualLine],
|
||||||
|
old_index: usize,
|
||||||
|
) -> usize {
|
||||||
|
let len = old_rows.len();
|
||||||
|
let mut distance = 1usize;
|
||||||
|
loop {
|
||||||
|
if distance > old_index && old_index + distance >= len {
|
||||||
|
return old_index.min(lines.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
if distance <= old_index {
|
||||||
|
if let Some((start, _)) = old_rows[old_index - distance]
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|id| find_row_block(lines, id))
|
||||||
|
{
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if old_index + distance < len {
|
||||||
|
if let Some((start, _)) = old_rows[old_index + distance]
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|id| find_row_block(lines, id))
|
||||||
|
{
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
distance += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
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 row in &state.rows {
|
||||||
@@ -322,8 +416,8 @@ fn tool_lines(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let content = tool_content_lines(row);
|
|
||||||
if row.kind == TranscriptRowKind::ToolDiff && !expanded {
|
if row.kind == TranscriptRowKind::ToolDiff && !expanded {
|
||||||
|
let content = tool_content_lines(row, Some(DIFF_PREVIEW_LINES + 1));
|
||||||
let shown = content.len().min(DIFF_PREVIEW_LINES);
|
let shown = content.len().min(DIFF_PREVIEW_LINES);
|
||||||
for text in content.iter().take(shown) {
|
for text in content.iter().take(shown) {
|
||||||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(text), false);
|
push_wrapped(out, row, " ", text.clone(), width, diff_tone(text), false);
|
||||||
@@ -340,6 +434,7 @@ fn tool_lines(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if expanded {
|
} else if expanded {
|
||||||
|
let content = tool_content_lines(row, None);
|
||||||
for text in content {
|
for text in content {
|
||||||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(&text), false);
|
push_wrapped(out, row, " ", text.clone(), width, diff_tone(&text), false);
|
||||||
}
|
}
|
||||||
@@ -448,7 +543,7 @@ fn tool_title(row: &TranscriptRow) -> (String, String) {
|
|||||||
(explicit.unwrap_or(fallback).to_string(), title.to_string())
|
(explicit.unwrap_or(fallback).to_string(), title.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
fn tool_content_lines(row: &TranscriptRow, limit: Option<usize>) -> Vec<String> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for block in row
|
for block in row
|
||||||
.data
|
.data
|
||||||
@@ -457,6 +552,9 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
{
|
{
|
||||||
|
if limit.is_some_and(|limit| out.len() >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
match block.get("type").and_then(serde_json::Value::as_str) {
|
match block.get("type").and_then(serde_json::Value::as_str) {
|
||||||
Some("content") => {
|
Some("content") => {
|
||||||
let nested = block.get("content").cloned().unwrap_or_default();
|
let nested = block.get("content").cloned().unwrap_or_default();
|
||||||
@@ -466,7 +564,8 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
|||||||
data: serde_json::json!({"content": nested}),
|
data: serde_json::json!({"content": nested}),
|
||||||
};
|
};
|
||||||
let text = content_text(&fake);
|
let text = content_text(&fake);
|
||||||
out.extend(text.lines().map(str::to_string));
|
let remaining = limit.map_or(usize::MAX, |limit| limit.saturating_sub(out.len()));
|
||||||
|
out.extend(text.lines().take(remaining).map(str::to_string));
|
||||||
}
|
}
|
||||||
Some("diff") => {
|
Some("diff") => {
|
||||||
let path = block
|
let path = block
|
||||||
@@ -474,11 +573,10 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("file");
|
.unwrap_or("file");
|
||||||
out.push(format!("{path}:"));
|
out.push(format!("{path}:"));
|
||||||
if let Some(old) = block.get("oldText").and_then(serde_json::Value::as_str) {
|
let old = block.get("oldText").and_then(serde_json::Value::as_str);
|
||||||
out.extend(old.lines().map(|line| format!("- {line}")));
|
|
||||||
}
|
|
||||||
if let Some(new) = block.get("newText").and_then(serde_json::Value::as_str) {
|
if let Some(new) = block.get("newText").and_then(serde_json::Value::as_str) {
|
||||||
out.extend(new.lines().map(|line| format!("+ {line}")));
|
let remaining = limit.map(|limit| limit.saturating_sub(out.len()));
|
||||||
|
out.extend(render_diff(old, new, remaining));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("terminal") => out.push("[embedded terminal]".into()),
|
Some("terminal") => out.push("[embedded terminal]".into()),
|
||||||
@@ -488,6 +586,236 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
struct SourceLine<'a> {
|
||||||
|
text: &'a str,
|
||||||
|
terminated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum DiffKind {
|
||||||
|
Add,
|
||||||
|
Delete,
|
||||||
|
Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
struct DiffOp<'a> {
|
||||||
|
kind: DiffKind,
|
||||||
|
line: SourceLine<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_diff_lines(text: &str) -> Vec<SourceLine<'_>> {
|
||||||
|
text.split_inclusive('\n')
|
||||||
|
.map(|part| SourceLine {
|
||||||
|
text: part.strip_suffix('\n').unwrap_or(part),
|
||||||
|
terminated: part.ends_with('\n'),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lcs_diff<'a>(old: &[SourceLine<'a>], new: &[SourceLine<'a>]) -> Vec<DiffOp<'a>> {
|
||||||
|
if old.is_empty() {
|
||||||
|
return new
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Add,
|
||||||
|
line,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
if new.is_empty() {
|
||||||
|
return old
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Delete,
|
||||||
|
line,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
if old.len().saturating_mul(new.len()) > LCS_CELL_LIMIT {
|
||||||
|
return old
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Delete,
|
||||||
|
line,
|
||||||
|
})
|
||||||
|
.chain(new.iter().copied().map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Add,
|
||||||
|
line,
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let columns = new.len() + 1;
|
||||||
|
let mut table = vec![0_u32; (old.len() + 1) * columns];
|
||||||
|
for i in (0..old.len()).rev() {
|
||||||
|
for j in (0..new.len()).rev() {
|
||||||
|
table[i * columns + j] = if old[i] == new[j] {
|
||||||
|
table[(i + 1) * columns + j + 1] + 1
|
||||||
|
} else {
|
||||||
|
table[(i + 1) * columns + j].max(table[i * columns + j + 1])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ops = Vec::with_capacity(old.len() + new.len());
|
||||||
|
let (mut i, mut j) = (0, 0);
|
||||||
|
while i < old.len() && j < new.len() {
|
||||||
|
if old[i] == new[j] {
|
||||||
|
ops.push(DiffOp {
|
||||||
|
kind: DiffKind::Context,
|
||||||
|
line: old[i],
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
} else if table[(i + 1) * columns + j] >= table[i * columns + j + 1] {
|
||||||
|
ops.push(DiffOp {
|
||||||
|
kind: DiffKind::Delete,
|
||||||
|
line: old[i],
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
ops.push(DiffOp {
|
||||||
|
kind: DiffKind::Add,
|
||||||
|
line: new[j],
|
||||||
|
});
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ops.extend(old[i..].iter().copied().map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Delete,
|
||||||
|
line,
|
||||||
|
}));
|
||||||
|
ops.extend(new[j..].iter().copied().map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Add,
|
||||||
|
line,
|
||||||
|
}));
|
||||||
|
ops
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_ops<'a>(old: &'a str, new: &'a str) -> Vec<DiffOp<'a>> {
|
||||||
|
let old = split_diff_lines(old);
|
||||||
|
let new = split_diff_lines(new);
|
||||||
|
let mut prefix = 0;
|
||||||
|
while prefix < old.len() && prefix < new.len() && old[prefix] == new[prefix] {
|
||||||
|
prefix += 1;
|
||||||
|
}
|
||||||
|
let mut suffix = 0;
|
||||||
|
while suffix < old.len() - prefix
|
||||||
|
&& suffix < new.len() - prefix
|
||||||
|
&& old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix]
|
||||||
|
{
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ops = old[..prefix]
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Context,
|
||||||
|
line,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
ops.extend(lcs_diff(
|
||||||
|
&old[prefix..old.len() - suffix],
|
||||||
|
&new[prefix..new.len() - suffix],
|
||||||
|
));
|
||||||
|
ops.extend(
|
||||||
|
old[old.len() - suffix..]
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Context,
|
||||||
|
line,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ops
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_diff_ops(ops: &[DiffOp<'_>]) -> Vec<String> {
|
||||||
|
let mut rendered = Vec::with_capacity(ops.len());
|
||||||
|
let mut index = 0;
|
||||||
|
while index < ops.len() {
|
||||||
|
if ops[index].kind != DiffKind::Context {
|
||||||
|
push_diff_op(&mut rendered, ops[index]);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut end = index;
|
||||||
|
while end < ops.len() && ops[end].kind == DiffKind::Context {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
let run = &ops[index..end];
|
||||||
|
let whole_diff = index == 0 && end == ops.len();
|
||||||
|
let keep_head = if whole_diff || index > 0 {
|
||||||
|
DIFF_CONTEXT_LINES
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let keep_tail = if whole_diff || end < ops.len() {
|
||||||
|
DIFF_CONTEXT_LINES
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if run.len() <= keep_head + keep_tail {
|
||||||
|
for op in run {
|
||||||
|
push_diff_op(&mut rendered, *op);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for op in run.iter().take(keep_head) {
|
||||||
|
push_diff_op(&mut rendered, *op);
|
||||||
|
}
|
||||||
|
let collapsed = &run[keep_head..run.len() - keep_tail];
|
||||||
|
rendered.push(format!("… {} unchanged lines …", collapsed.len()));
|
||||||
|
if collapsed.iter().any(|op| !op.line.terminated) {
|
||||||
|
rendered.push("\\ No newline at end of file".into());
|
||||||
|
}
|
||||||
|
for op in run.iter().skip(run.len() - keep_tail) {
|
||||||
|
push_diff_op(&mut rendered, *op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_diff_op(rendered: &mut Vec<String>, op: DiffOp<'_>) {
|
||||||
|
let prefix = match op.kind {
|
||||||
|
DiffKind::Add => "+ ",
|
||||||
|
DiffKind::Delete => "- ",
|
||||||
|
DiffKind::Context => " ",
|
||||||
|
};
|
||||||
|
rendered.push(format!("{prefix}{}", op.line.text));
|
||||||
|
if !op.line.terminated {
|
||||||
|
rendered.push("\\ No newline at end of file".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_diff(old: Option<&str>, new: &str, limit: Option<usize>) -> Vec<String> {
|
||||||
|
let ops = old.map_or_else(
|
||||||
|
|| {
|
||||||
|
split_diff_lines(new)
|
||||||
|
.into_iter()
|
||||||
|
.map(|line| DiffOp {
|
||||||
|
kind: DiffKind::Add,
|
||||||
|
line,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
},
|
||||||
|
|old| diff_ops(old, new),
|
||||||
|
);
|
||||||
|
let mut rendered = render_diff_ops(&ops);
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
rendered.truncate(limit);
|
||||||
|
}
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
|
||||||
fn diff_tone(text: &str) -> Tone {
|
fn diff_tone(text: &str) -> Tone {
|
||||||
if text.starts_with("+ ") {
|
if text.starts_with("+ ") {
|
||||||
Tone::Active
|
Tone::Active
|
||||||
@@ -722,6 +1050,230 @@ mod tests {
|
|||||||
assert!(lines.last().unwrap().text.contains("Enter to expand"));
|
assert!(lines.last().unwrap().text.contains("Enter to expand"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn diff_content(old_text: Option<&str>, new_text: &str) -> Vec<String> {
|
||||||
|
let old_text = old_text.map_or(serde_json::Value::Null, |text| json!(text));
|
||||||
|
let row = row(
|
||||||
|
"d",
|
||||||
|
TranscriptRowKind::ToolDiff,
|
||||||
|
json!({"content":[{"type":"diff","path":"x","oldText":old_text,"newText":new_text}]}),
|
||||||
|
);
|
||||||
|
tool_content_lines(&row, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_preserves_unchanged_lines() {
|
||||||
|
assert_eq!(diff_content(Some("a\nb\n"), "a\nb\n"), ["x:", " a", " b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_handles_insert_delete_replace_and_empty_files() {
|
||||||
|
assert_eq!(
|
||||||
|
diff_content(Some("a\nc\n"), "a\nb\nc\n"),
|
||||||
|
["x:", " a", "+ b", " c"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diff_content(Some("a\nb\nc\n"), "a\nc\n"),
|
||||||
|
["x:", " a", "- b", " c"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diff_content(Some("a\nb\n"), "a\nc\n"),
|
||||||
|
["x:", " a", "- b", "+ c"]
|
||||||
|
);
|
||||||
|
assert_eq!(diff_content(Some(""), ""), ["x:"]);
|
||||||
|
assert_eq!(diff_content(None, "a\n"), ["x:", "+ a"]);
|
||||||
|
assert_eq!(diff_content(Some("a\n"), ""), ["x:", "- a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_marks_added_and_removed_final_newlines() {
|
||||||
|
assert_eq!(
|
||||||
|
diff_content(Some("a"), "a\n"),
|
||||||
|
["x:", "- a", "\\ No newline at end of file", "+ a"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diff_content(Some("a\n"), "a"),
|
||||||
|
["x:", "- a", "+ a", "\\ No newline at end of file"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diff_content(None, "a"),
|
||||||
|
["x:", "+ a", "\\ No newline at end of file"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_marks_unchanged_unterminated_final_context() {
|
||||||
|
assert_eq!(
|
||||||
|
diff_content(Some("a\nb\nc"), "a\nX\nc"),
|
||||||
|
[
|
||||||
|
"x:",
|
||||||
|
" a",
|
||||||
|
"- b",
|
||||||
|
"+ X",
|
||||||
|
" c",
|
||||||
|
"\\ No newline at end of file"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_collapses_long_context_around_changes() {
|
||||||
|
let common = (0..20)
|
||||||
|
.map(|index| format!("line{index}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let old = common[..10]
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(["OLD".into()])
|
||||||
|
.chain(common[10..].iter().cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let new = common[..10]
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(["NEW".into()])
|
||||||
|
.chain(common[10..].iter().cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
render_diff(Some(&old), &new, None),
|
||||||
|
[
|
||||||
|
"… 7 unchanged lines …",
|
||||||
|
" line7",
|
||||||
|
" line8",
|
||||||
|
" line9",
|
||||||
|
"- OLD",
|
||||||
|
"+ NEW",
|
||||||
|
" line10",
|
||||||
|
" line11",
|
||||||
|
" line12",
|
||||||
|
"… 7 unchanged lines …",
|
||||||
|
"\\ No newline at end of file",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_preserves_eof_marker_through_trailing_collapse() {
|
||||||
|
let trailing = (0..10).map(|index| format!("t{index}")).collect::<Vec<_>>();
|
||||||
|
let old = ["OLD".into()]
|
||||||
|
.into_iter()
|
||||||
|
.chain(trailing.iter().cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let new = ["NEW".into()]
|
||||||
|
.into_iter()
|
||||||
|
.chain(trailing)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
render_diff(Some(&old), &new, None),
|
||||||
|
[
|
||||||
|
"- OLD",
|
||||||
|
"+ NEW",
|
||||||
|
" t0",
|
||||||
|
" t1",
|
||||||
|
" t2",
|
||||||
|
"… 7 unchanged lines …",
|
||||||
|
"\\ No newline at end of file",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_collapses_only_long_interior_context() {
|
||||||
|
let middle = (0..10).map(|index| format!("c{index}")).collect::<Vec<_>>();
|
||||||
|
let old = ["lead0", "lead1", "OLD1"]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.chain(middle.iter().cloned())
|
||||||
|
.chain(["OLD2", "trail0", "trail1"].into_iter().map(str::to_string))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let new = ["lead0", "lead1", "NEW1"]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.chain(middle.iter().cloned())
|
||||||
|
.chain(["NEW2", "trail0", "trail1"].into_iter().map(str::to_string))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let rendered = render_diff(Some(&old), &new, None);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rendered
|
||||||
|
.iter()
|
||||||
|
.filter(|line| line.contains("unchanged lines"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(&rendered[..4], [" lead0", " lead1", "- OLD1", "+ NEW1"]);
|
||||||
|
assert_eq!(
|
||||||
|
&rendered[4..11],
|
||||||
|
[
|
||||||
|
" c0",
|
||||||
|
" c1",
|
||||||
|
" c2",
|
||||||
|
"… 4 unchanged lines …",
|
||||||
|
" c7",
|
||||||
|
" c8",
|
||||||
|
" c9"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&rendered[11..],
|
||||||
|
[
|
||||||
|
"- OLD2",
|
||||||
|
"+ NEW2",
|
||||||
|
" trail0",
|
||||||
|
" trail1",
|
||||||
|
"\\ No newline at end of file"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_preview_remains_bounded_after_hunking() {
|
||||||
|
let common = (0..10)
|
||||||
|
.map(|index| format!("line{index}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let old = common
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(["OLD".into()])
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let new = common
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(["NEW".into()])
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let expanded = render_diff(Some(&old), &new, None);
|
||||||
|
|
||||||
|
assert_eq!(render_diff(Some(&old), &new, Some(4)), expanded[..4]);
|
||||||
|
assert_eq!(expanded[0], "… 7 unchanged lines …");
|
||||||
|
assert!(expanded.contains(&"- OLD".into()));
|
||||||
|
assert!(expanded.contains(&"+ NEW".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_diff_bounds_pathological_changed_middles() {
|
||||||
|
let old_lines = (0..501)
|
||||||
|
.map(|index| format!("line{index}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut new_lines = old_lines[1..].to_vec();
|
||||||
|
new_lines.push(old_lines[0].clone());
|
||||||
|
let old = format!("{}\n", old_lines.join("\n"));
|
||||||
|
let new = format!("{}\n", new_lines.join("\n"));
|
||||||
|
let rendered = render_diff(Some(&old), &new, None);
|
||||||
|
|
||||||
|
assert_eq!(rendered.len(), 1_002);
|
||||||
|
assert!(rendered[..501].iter().all(|line| line.starts_with("- ")));
|
||||||
|
assert!(rendered[501..].iter().all(|line| line.starts_with("+ ")));
|
||||||
|
assert_eq!(render_diff(Some(&old), &new, Some(4)), rendered[..4]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plan_is_not_a_transcript_row_and_status_is_two_lines() {
|
fn plan_is_not_a_transcript_row_and_status_is_two_lines() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
@@ -812,4 +1364,213 @@ mod tests {
|
|||||||
assert!(text.contains("adapter exited"), "{text}");
|
assert!(text.contains("adapter exited"), "{text}");
|
||||||
assert!(text.contains("request failed"), "{text}");
|
assert!(text.contains("request failed"), "{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_pane() -> StructuredPane {
|
||||||
|
StructuredPane {
|
||||||
|
handle: StructuredHandle::test_handle(StructuredState::default()),
|
||||||
|
focused: true,
|
||||||
|
mode: Mode::Normal,
|
||||||
|
session_status: SessionStatus::Running,
|
||||||
|
activity_detail: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numbered_rows(state: &mut StructuredState, count: usize) {
|
||||||
|
for n in 0..count {
|
||||||
|
state.rows.push(row(
|
||||||
|
&format!("r{n}"),
|
||||||
|
TranscriptRowKind::AgentText,
|
||||||
|
json!({"content":{"type":"text","text":format!("line {n}")}}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grow_row(state: &mut StructuredState, id: &str) {
|
||||||
|
let index = state.rows.iter().position(|r| r.id == id).unwrap();
|
||||||
|
state.rows[index] = row(
|
||||||
|
id,
|
||||||
|
TranscriptRowKind::AgentText,
|
||||||
|
json!({"content":{"type":"text","text":"a very long streaming reply that keeps growing and will wrap across several lines now"}}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_transcript_lines(
|
||||||
|
pane: &StructuredPane,
|
||||||
|
state: &mut StructuredState,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| pane.render_transcript(frame, frame.area(), state))
|
||||||
|
.unwrap();
|
||||||
|
let buffer = terminal.backend().buffer().clone();
|
||||||
|
(0..height)
|
||||||
|
.map(|y| {
|
||||||
|
(0..width)
|
||||||
|
.map(|x| buffer[(x, y)].symbol())
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn row_growing_above_the_viewport_keeps_the_visual_anchor() {
|
||||||
|
let pane = test_pane();
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.follow_selection = false;
|
||||||
|
numbered_rows(&mut state, 20);
|
||||||
|
state.scroll = 10;
|
||||||
|
|
||||||
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
|
|
||||||
|
grow_row(&mut state, "r3");
|
||||||
|
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(
|
||||||
|
after[0].contains("line 10"),
|
||||||
|
"a row growing above the viewport must not shift what's on screen: {after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appending_a_row_below_the_viewport_keeps_the_visual_anchor() {
|
||||||
|
let pane = test_pane();
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.follow_selection = false;
|
||||||
|
numbered_rows(&mut state, 20);
|
||||||
|
state.scroll = 10;
|
||||||
|
|
||||||
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
|
|
||||||
|
state.rows.push(row(
|
||||||
|
"r20",
|
||||||
|
TranscriptRowKind::AgentText,
|
||||||
|
json!({"content":{"type":"text","text":"line 20"}}),
|
||||||
|
));
|
||||||
|
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert_eq!(before, after, "growth below the viewport must not move it");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_a_row_above_the_viewport_keeps_the_visual_anchor() {
|
||||||
|
let pane = test_pane();
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.follow_selection = false;
|
||||||
|
numbered_rows(&mut state, 20);
|
||||||
|
state.scroll = 10;
|
||||||
|
|
||||||
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
|
|
||||||
|
state.rows.retain(|r| r.id != "r3");
|
||||||
|
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(
|
||||||
|
after[0].contains("line 10"),
|
||||||
|
"a row removed above the viewport must not shift what's on screen: {after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_the_anchor_row_falls_back_to_the_nearest_surviving_neighbor() {
|
||||||
|
let pane = test_pane();
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.follow_selection = false;
|
||||||
|
numbered_rows(&mut state, 20);
|
||||||
|
state.scroll = 10;
|
||||||
|
|
||||||
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(before[0].contains("line 10"), "{before:?}");
|
||||||
|
|
||||||
|
state.rows.retain(|r| r.id != "r10");
|
||||||
|
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(
|
||||||
|
after[0].contains("line 9"),
|
||||||
|
"removing the anchor row should fall back to its nearest surviving neighbor: {after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patching_a_row_above_the_viewport_keeps_the_visual_anchor() {
|
||||||
|
let pane = test_pane();
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.follow_selection = false;
|
||||||
|
numbered_rows(&mut state, 20);
|
||||||
|
state.scroll = 10;
|
||||||
|
|
||||||
|
render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||||||
|
// viewport expanding into a multi-line diff.
|
||||||
|
let index = state.rows.iter().position(|r| r.id == "r5").unwrap();
|
||||||
|
state.rows[index] = row(
|
||||||
|
"r5",
|
||||||
|
TranscriptRowKind::ToolDiff,
|
||||||
|
json!({"title":"Edit(x)","content":[{"type":"diff","path":"x","oldText":"a","newText":"a\nb\nc\nd"}]}),
|
||||||
|
);
|
||||||
|
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(
|
||||||
|
after[0].contains("line 10"),
|
||||||
|
"a patched row above the viewport must not shift what's on screen: {after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_width_reconciles_without_panicking() {
|
||||||
|
let pane = test_pane();
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.follow_selection = false;
|
||||||
|
numbered_rows(&mut state, 20);
|
||||||
|
state.scroll = 10;
|
||||||
|
|
||||||
|
render_transcript_lines(&pane, &mut state, 1, 3);
|
||||||
|
grow_row(&mut state, "r3");
|
||||||
|
render_transcript_lines(&pane, &mut state, 1, 3);
|
||||||
|
assert!(state.scroll <= state.line_rows.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follow_selection_without_a_selection_stays_pinned_to_the_live_edge() {
|
||||||
|
let pane = test_pane();
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.follow_selection = true;
|
||||||
|
state.selected = None;
|
||||||
|
numbered_rows(&mut state, 20);
|
||||||
|
|
||||||
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(before.last().unwrap().contains("line 19"), "{before:?}");
|
||||||
|
|
||||||
|
state.rows.push(row(
|
||||||
|
"r20",
|
||||||
|
TranscriptRowKind::AgentText,
|
||||||
|
json!({"content":{"type":"text","text":"line 20"}}),
|
||||||
|
));
|
||||||
|
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(
|
||||||
|
after.last().unwrap().contains("line 20"),
|
||||||
|
"bottom-follow must track the live edge as rows are appended: {after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follow_selection_keeps_the_selected_row_visible_when_a_row_above_it_grows() {
|
||||||
|
let pane = test_pane();
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.follow_selection = true;
|
||||||
|
numbered_rows(&mut state, 20);
|
||||||
|
state.selected = Some("r15".into());
|
||||||
|
|
||||||
|
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(
|
||||||
|
before.iter().any(|line| line.contains("line 15")),
|
||||||
|
"{before:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
grow_row(&mut state, "r3");
|
||||||
|
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||||
|
assert!(
|
||||||
|
after.iter().any(|line| line.contains("line 15")),
|
||||||
|
"the selected row must stay visible while follow_selection is on: {after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ pub struct StructuredState {
|
|||||||
pub selected: Option<String>,
|
pub selected: Option<String>,
|
||||||
pub expanded: HashSet<String>,
|
pub expanded: HashSet<String>,
|
||||||
pub scroll: usize,
|
pub scroll: usize,
|
||||||
|
/// Row id per rendered line from the last render pass, parallel to that
|
||||||
|
/// pass's `lines`. Lets the next render translate `scroll` — a raw index
|
||||||
|
/// into that now-stale layout — back into a row identity before mapping
|
||||||
|
/// it onto the freshly rebuilt layout, so growth/shrinkage above the
|
||||||
|
/// viewport shifts indices without shifting what's on screen.
|
||||||
|
pub(crate) line_rows: Vec<Option<String>>,
|
||||||
pub follow_selection: bool,
|
pub follow_selection: bool,
|
||||||
pub composer: TextInput,
|
pub composer: TextInput,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
@@ -42,6 +48,7 @@ impl Default for StructuredState {
|
|||||||
selected: None,
|
selected: None,
|
||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
|
line_rows: Vec::new(),
|
||||||
follow_selection: true,
|
follow_selection: true,
|
||||||
composer: TextInput::default(),
|
composer: TextInput::default(),
|
||||||
error: None,
|
error: None,
|
||||||
@@ -289,7 +296,10 @@ impl StructuredState {
|
|||||||
Some((nonce, decision))
|
Some((nonce, decision))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn approval_failed(&mut self, error: String) {
|
pub fn approval_failed(&mut self, nonce: &str, error: String) {
|
||||||
|
if self.pending_nonce() != Some(nonce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.approval_submitting = false;
|
self.approval_submitting = false;
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
}
|
}
|
||||||
@@ -554,6 +564,81 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_approval_ignores_its_late_failure() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:1",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
));
|
||||||
|
state.selected = Some("approval:1".into());
|
||||||
|
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||||
|
assert!(state.approval_decision('a').is_some());
|
||||||
|
|
||||||
|
state.apply(StructuredEvent::ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: ReducedState::default(),
|
||||||
|
});
|
||||||
|
state.approval_failed("old", "response lost".into());
|
||||||
|
|
||||||
|
assert_eq!(state.error, None);
|
||||||
|
assert!(!state.approval_submitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn superseded_approval_ignores_its_late_failure() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:1",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
));
|
||||||
|
state.selected = Some("approval:1".into());
|
||||||
|
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||||
|
assert!(state.approval_decision('a').is_some());
|
||||||
|
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:2",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
));
|
||||||
|
state.apply(StructuredEvent::ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: ReducedState {
|
||||||
|
pending_approval: Some(json!({"nonce":"new"})),
|
||||||
|
..ReducedState::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
state.approval_decision('a'),
|
||||||
|
Some(("new".into(), StructuredApprovalDecision::AllowOnce))
|
||||||
|
);
|
||||||
|
|
||||||
|
state.approval_failed("old", "response lost".into());
|
||||||
|
|
||||||
|
assert_eq!(state.error, None);
|
||||||
|
assert!(state.approval_submitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_approval_surfaces_its_failure() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:1",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
));
|
||||||
|
state.selected = Some("approval:1".into());
|
||||||
|
state.reduced.pending_approval = Some(json!({"nonce":"current"}));
|
||||||
|
assert!(state.approval_decision('a').is_some());
|
||||||
|
|
||||||
|
state.approval_failed("current", "request failed".into());
|
||||||
|
|
||||||
|
assert_eq!(state.error.as_deref(), Some("request failed"));
|
||||||
|
assert!(!state.approval_submitting);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
|
|||||||
@@ -162,8 +162,10 @@ choices, not daemon ones.
|
|||||||
**A client decides:**
|
**A client decides:**
|
||||||
|
|
||||||
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
||||||
diff view for `ToolDiff`, a tool card for `ToolCall`, a checklist for
|
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
|
||||||
`Plan`, and a floor presentation for `Notice`.
|
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
||||||
|
from the transcript entirely and render the current plan in a separate
|
||||||
|
plan rail (web) / status line (TUI) instead.
|
||||||
- How `responding` vs. `thinking` are distinguished visually, if at all.
|
- How `responding` vs. `thinking` are distinguished visually, if at all.
|
||||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
- How the operator is asked to resolve an `Approval` row — the daemon only
|
||||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"_description": "the parity fixture for #199's command resolver — every case here is asserted identically by crates/maestro-protocol/src/display.rs (command_program/command_tool) and web/src/toolIcon.ts (splitCommand). `program` is the resolved, basenamed program; `tool` is the icon id it maps to, or null when unrecognised/ambiguous.",
|
||||||
|
"cases": [
|
||||||
|
{ "command": "claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "claude --resume", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "CLAUDE", "program": "CLAUDE", "tool": "claude" },
|
||||||
|
{ "command": "/usr/local/bin/claude -p hi", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "./bin/claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "FOO=1 BAR=2 claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "FOO=\"bar baz\" claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "FOO='bar baz' claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "FOO=bar\\ baz claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "'/usr/local/bin/claude'", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "\"/opt/my agent/claude\" --resume", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "env -i claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "env --ignore-environment claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "sudo -u root claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "sudo --user=root claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "sudo -- claude --resume", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "nohup claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "time claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "time -p claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "time -- claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "time -p -- claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "time -pp claude", "program": "time", "tool": null },
|
||||||
|
{ "command": "time -p -p claude", "program": "time", "tool": null },
|
||||||
|
{ "command": "time -o /tmp/t.log claude", "program": "time", "tool": null },
|
||||||
|
{ "command": "command claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "command -v claude", "program": "command", "tool": null },
|
||||||
|
{ "command": "command -V claude", "program": "command", "tool": null },
|
||||||
|
{ "command": "npx --yes claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "npx claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "pnpm --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "pnpm -C ./pkg dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "pnpm dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "pnpm dlx -s claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "pnpm dlx --silent claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "yarn --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "yarn dlx -q claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "yarn dlx --quiet claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bun x claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bunx claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "uvx claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bun x --bun claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bunx --bun claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bun x -p @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bunx --package @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bun x --package=@anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bunx --no-install claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bun x --verbose claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bunx --silent claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "bun --bun x claude-code", "program": "bun", "tool": null },
|
||||||
|
{ "command": "pnpm dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "pnpm dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "pnpm dlx --package= claude-code", "program": "pnpm", "tool": null },
|
||||||
|
{ "command": "pnpm dlx --package", "program": "pnpm", "tool": null },
|
||||||
|
{ "command": "pnpm dlx -c \"claude-code\"", "program": "pnpm", "tool": null },
|
||||||
|
{ "command": "pnpm --package=typescript dlx tsc", "program": "tsc", "tool": null },
|
||||||
|
{ "command": "pnpm dlx --unknown-flag claude-code", "program": "pnpm", "tool": null },
|
||||||
|
{ "command": "yarn dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "yarn dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "yarn dlx -p foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||||
|
{ "command": "yarn dlx --package= claude-code", "program": "yarn", "tool": null },
|
||||||
|
{ "command": "yarn dlx -p", "program": "yarn", "tool": null },
|
||||||
|
{ "command": "yarn dlx -p --quiet claude-code", "program": "yarn", "tool": null },
|
||||||
|
{ "command": "bunx --package= claude-code", "program": "bunx", "tool": null },
|
||||||
|
{ "command": "bunx --package", "program": "bunx", "tool": null },
|
||||||
|
{ "command": "bun x -p --silent claude-code", "program": "bun", "tool": null },
|
||||||
|
{ "command": "sudo env -i claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "FOO=1 sudo BAR=2 claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "env -iv claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "sudo -nE claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "sudo -uroot claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "sudo -u", "program": "sudo", "tool": null },
|
||||||
|
{ "command": "env --unset= claude", "program": "env", "tool": null },
|
||||||
|
{ "command": "sudo --user= claude", "program": "sudo", "tool": null },
|
||||||
|
{ "command": "sudo --user \"\" claude", "program": "sudo", "tool": null },
|
||||||
|
{ "command": "sudo -nu claude", "program": "sudo", "tool": null },
|
||||||
|
{ "command": "env -S 'python3 -u' script.py", "program": "env", "tool": null },
|
||||||
|
{ "command": "npx -c \"claude --version\"", "program": "npx", "tool": null },
|
||||||
|
{ "command": "npx --call \"claude --version\"", "program": "npx", "tool": null },
|
||||||
|
{ "command": "command FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "nohup FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "npx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "pnpm dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "yarn dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "bun x FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "bunx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "uvx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "sudo time FOO=1 claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "nohup env FOO=1 claude", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "sudo command FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "command nohup FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "time command FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "\"FOO=1\" claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "FOO\\=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "F\"O\"O=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "F\\OO=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "\"FOO\"=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "F\"\"OO=1 claude", "program": "FOO=1", "tool": null },
|
||||||
|
{ "command": "claude\nclaude-code", "program": "claude", "tool": null },
|
||||||
|
{ "command": "clau\\\nde --resume", "program": "claude", "tool": "claude" },
|
||||||
|
{ "command": "npx opencode", "program": "opencode", "tool": "opencode" },
|
||||||
|
{ "command": "pnpm dlx opencode run", "program": "opencode", "tool": "opencode" },
|
||||||
|
{ "command": "pnpm claude", "program": "pnpm", "tool": null },
|
||||||
|
{ "command": "yarn claude", "program": "yarn", "tool": null },
|
||||||
|
{ "command": "claude-code-acp", "program": "claude-code-acp", "tool": null },
|
||||||
|
{ "command": "hx src/main.rs", "program": "hx", "tool": null },
|
||||||
|
{ "command": "bash", "program": "bash", "tool": "shell" },
|
||||||
|
{ "command": "opencode", "program": "opencode", "tool": "opencode" },
|
||||||
|
{ "command": "sudo", "program": "sudo", "tool": null },
|
||||||
|
{ "command": "", "program": "", "tool": null },
|
||||||
|
{ "command": "npx --unknown-flag claude-code", "program": "npx", "tool": null },
|
||||||
|
{ "command": "claude | tee log.txt", "program": "claude", "tool": null },
|
||||||
|
{ "command": "claude; echo done", "program": "claude;", "tool": null },
|
||||||
|
{ "command": "claude && echo done", "program": "claude", "tool": null },
|
||||||
|
{ "command": "echo hi $(claude)", "program": "echo", "tool": null },
|
||||||
|
{ "command": "claude \"unterminated", "program": "claude", "tool": null }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const base: SpawnFormValues = {
|
|||||||
command: "claude",
|
command: "claude",
|
||||||
cwd: "/home/user/project",
|
cwd: "/home/user/project",
|
||||||
name: "amber-fox",
|
name: "amber-fox",
|
||||||
|
structuredView: false,
|
||||||
worktreeEnabled: false,
|
worktreeEnabled: false,
|
||||||
worktreeBranch: "",
|
worktreeBranch: "",
|
||||||
worktreeBranchMode: "new",
|
worktreeBranchMode: "new",
|
||||||
@@ -25,6 +26,22 @@ describe("buildSpawnRequest", () => {
|
|||||||
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requests structured view only for Claude Code", () => {
|
||||||
|
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({
|
||||||
|
req: {
|
||||||
|
cwd: base.cwd,
|
||||||
|
command: "claude",
|
||||||
|
metadata: { name: "amber-fox" },
|
||||||
|
view: "Structured",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
|
||||||
|
).toEqual({
|
||||||
|
req: { cwd: base.cwd, command: "opencode", metadata: { name: "amber-fox" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
||||||
const res = buildSpawnRequest(
|
const res = buildSpawnRequest(
|
||||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
||||||
|
|||||||
+28
-3
@@ -11,6 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
|||||||
import { parentDir } from "./paths.js";
|
import { parentDir } from "./paths.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import type { ProjectGroup } from "./projectList.js";
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
|
import { splitCommand } from "./toolIcon.js";
|
||||||
import { abbreviateCwd } from "./types.js";
|
import { abbreviateCwd } from "./types.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export interface SpawnFormValues {
|
|||||||
command: string;
|
command: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
structuredView: boolean;
|
||||||
worktreeEnabled: boolean;
|
worktreeEnabled: boolean;
|
||||||
worktreeBranch: string;
|
worktreeBranch: string;
|
||||||
worktreeBranchEdited: boolean;
|
worktreeBranchEdited: boolean;
|
||||||
@@ -66,6 +68,10 @@ export function buildSpawnRequest(
|
|||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (values.structuredView && splitCommand(values.command).tool === "claude") {
|
||||||
|
req.view = "Structured";
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -153,6 +159,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
<span class="wt-summary"></span>
|
<span class="wt-summary"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="wt-collapsible" hidden>
|
<div class="wt-collapsible" hidden>
|
||||||
|
<label class="row-inline structured-view" hidden>
|
||||||
|
<input type="checkbox" name="structured_view" />
|
||||||
|
Structured view
|
||||||
|
</label>
|
||||||
<div class="field wt-step" hidden>
|
<div class="field wt-step" hidden>
|
||||||
<span class="field-label">Worktree</span>
|
<span class="field-label">Worktree</span>
|
||||||
<div class="picker wt-choices"></div>
|
<div class="picker wt-choices"></div>
|
||||||
@@ -203,6 +213,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
||||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||||
|
const structuredWrap = form.querySelector(".structured-view") as HTMLElement;
|
||||||
|
const structuredView = form.querySelector("input[name=structured_view]") as HTMLInputElement;
|
||||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||||
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||||
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||||
@@ -248,6 +260,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
|
structuredView: structuredView.checked,
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
@@ -317,6 +330,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
} else {
|
} else {
|
||||||
command = preset!.command;
|
command = preset!.command;
|
||||||
}
|
}
|
||||||
|
updateStructuredAvailability();
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||||
}
|
}
|
||||||
@@ -354,10 +368,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// operator has already touched (#76).
|
// operator has already touched (#76).
|
||||||
let commandTouched = false;
|
let commandTouched = false;
|
||||||
|
|
||||||
|
function updateStructuredAvailability(): void {
|
||||||
|
const supported = splitCommand(command).tool === "claude";
|
||||||
|
structuredWrap.hidden = !supported;
|
||||||
|
if (!supported) structuredView.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
structuredView.addEventListener("change", emit);
|
||||||
|
|
||||||
cmdRaw.addEventListener("input", () => {
|
cmdRaw.addEventListener("input", () => {
|
||||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||||
commandTouched = true;
|
commandTouched = true;
|
||||||
command = cmdRaw.value.trim();
|
command = cmdRaw.value.trim();
|
||||||
|
updateStructuredAvailability();
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -844,10 +867,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
if (target === wtEnabled) {
|
if (target === wtEnabled || target === structuredView) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
wtEnabled.checked = !wtEnabled.checked;
|
const checkbox = target as HTMLInputElement;
|
||||||
wtEnabled.dispatchEvent(new Event("change"));
|
checkbox.checked = !checkbox.checked;
|
||||||
|
checkbox.dispatchEvent(new Event("change"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,6 +909,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
|
structuredView: structuredView.checked,
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||||
|
|
||||||
|
function stubApi(): RequestInit[] {
|
||||||
|
const sessionRequests: RequestInit[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/api/sessions") && init?.method === "POST") {
|
||||||
|
sessionRequests.push(init);
|
||||||
|
return new Response(JSON.stringify({ id: "s1", command: "claude", cwd: "/srv/work" }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/api/info")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user" }),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/names")) {
|
||||||
|
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify([]), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return sessionRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("spawn form structured view", () => {
|
||||||
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
|
||||||
|
async function drain(): Promise<void> {
|
||||||
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
handle?.destroy();
|
||||||
|
handle = null;
|
||||||
|
await drain();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is default-off and shown only for the resolved Claude Code command", async () => {
|
||||||
|
stubApi();
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const wrap = handle.el.querySelector<HTMLElement>(".structured-view")!;
|
||||||
|
const checkbox = wrap.querySelector<HTMLInputElement>("input")!;
|
||||||
|
const disclosure = handle.el.querySelector<HTMLElement>(".wt-collapsible")!;
|
||||||
|
expect(disclosure.hidden).toBe(true);
|
||||||
|
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||||
|
expect(disclosure.hidden).toBe(false);
|
||||||
|
expect(wrap.hidden).toBe(false);
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
|
||||||
|
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click();
|
||||||
|
expect(wrap.hidden).toBe(true);
|
||||||
|
|
||||||
|
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click();
|
||||||
|
expect(wrap.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a checked value when the command changes away from Claude Code", async () => {
|
||||||
|
stubApi();
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||||
|
checkbox.click();
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
|
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||||
|
custom.click();
|
||||||
|
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
|
command.value = "opencode";
|
||||||
|
command.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
|
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits Structured in the submitted request when selected", async () => {
|
||||||
|
const requests = stubApi();
|
||||||
|
handle = createSpawnForm({
|
||||||
|
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
||||||
|
onSuccess() {},
|
||||||
|
});
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
|
||||||
|
handle.el.requestSubmit();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(JSON.parse(String(requests[0].body))).toMatchObject({
|
||||||
|
command: "claude",
|
||||||
|
view: "Structured",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,22 @@ function row(id: string, kind: TranscriptRow["kind"], data: unknown): Transcript
|
|||||||
return { id, kind, data };
|
return { id, kind, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolRow(id: string, text: string): TranscriptRow {
|
||||||
|
return row(id, "tool_call", {
|
||||||
|
title: `Read(${id})`,
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "content", content: { type: "text", text } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffRow(id: string, newText: string): TranscriptRow {
|
||||||
|
return row(id, "tool_diff", {
|
||||||
|
title: `Edit(${id})`,
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "diff", path: "file.ts", oldText: "before", newText }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||||
|
|
||||||
const allApprovalOptions = [
|
const allApprovalOptions = [
|
||||||
@@ -93,6 +109,121 @@ describe("structured connection: data flow", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps snapshot plan rows out of the transcript while rendering the plan rail", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 3,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { type: "text", text: "before plan" } }),
|
||||||
|
row("plan", "plan", { entries: [{ content: "ship it", status: "pending" }] }),
|
||||||
|
row("agent:1", "agent_text", { content: { type: "text", text: "after plan" } }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "reduced_state",
|
||||||
|
state: {
|
||||||
|
...idleState,
|
||||||
|
plan: { entries: [{ content: "ship it", priority: "high", status: "pending" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcript = conn.el.querySelector(".structured-rows")!;
|
||||||
|
expect(transcript.querySelectorAll(".st-row")).toHaveLength(2);
|
||||||
|
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
|
||||||
|
expect(transcript.textContent).toContain("before plan");
|
||||||
|
expect(transcript.textContent).toContain("after plan");
|
||||||
|
expect(transcript.textContent).not.toContain("[plan]");
|
||||||
|
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("ship it");
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies plan append, patch, and remove without disturbing surrounding rows", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [row("user:1", "user", { content: { type: "text", text: "keep me" } })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "append",
|
||||||
|
row: row("plan", "plan", { entries: [{ content: "first", status: "pending" }] }),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
...idleState,
|
||||||
|
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "append",
|
||||||
|
row: row("agent:1", "agent_text", { content: { type: "text", text: "still here" } }),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
...idleState,
|
||||||
|
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("plan", "plan", { entries: [{ content: "updated", status: "completed" }] }),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
...idleState,
|
||||||
|
plan: { entries: [{ content: "updated", priority: "high", status: "completed" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcript = conn.el.querySelector(".structured-rows")!;
|
||||||
|
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
|
||||||
|
"user:1",
|
||||||
|
"agent:1",
|
||||||
|
]);
|
||||||
|
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("updated");
|
||||||
|
expect(conn.el.querySelector(".structured-rail")!.textContent).not.toContain("first");
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: { type: "remove", id: "plan" },
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("agent:1", "agent_text", { content: { type: "text", text: "patched neighbor" } }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: { type: "remove", id: "user:1" },
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conn.el.querySelector(".structured-rail .st-plan")).toBeNull();
|
||||||
|
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
|
||||||
|
"agent:1",
|
||||||
|
]);
|
||||||
|
expect(transcript.textContent).toContain("patched neighbor");
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("appends and patches rows via projection_update without duplicating them", () => {
|
it("appends and patches rows via projection_update without duplicating them", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -115,6 +246,113 @@ describe("structured connection: data flow", () => {
|
|||||||
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [
|
||||||
|
row("tool:1", "tool_call", {
|
||||||
|
title: "Bash(pnpm test)",
|
||||||
|
status: "in_progress",
|
||||||
|
content: [{ type: "content", content: { type: "text", text: "starting" } }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
|
||||||
|
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||||
|
originalHead.click();
|
||||||
|
expect(originalHead.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
const clickListener = vi.fn();
|
||||||
|
window.addEventListener("click", clickListener, { capture: true, once: true });
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("tool:1", "tool_call", {
|
||||||
|
title: "Bash(pnpm test)",
|
||||||
|
status: "in_progress",
|
||||||
|
content: [{ type: "content", content: { type: "text", text: "still running" } }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clickListener).not.toHaveBeenCalled();
|
||||||
|
window.removeEventListener("click", clickListener, true);
|
||||||
|
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||||
|
expect(patchedHead).not.toBe(originalHead);
|
||||||
|
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(conn.el.querySelector<HTMLElement>(".st-tool-out")!.hidden).toBe(false);
|
||||||
|
expect(conn.el.textContent).toContain("still running");
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus to a patched tool header with the same stable row id", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||||
|
originalHead.focus();
|
||||||
|
expect(document.activeElement).toBe(originalHead);
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||||
|
expect(patchedHead).not.toBe(originalHead);
|
||||||
|
expect(document.activeElement).toBe(patchedHead);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not move unrelated focus when a tool row is patched", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("structured connection: turn/composer gating", () => {
|
describe("structured connection: turn/composer gating", () => {
|
||||||
@@ -428,6 +666,110 @@ describe("structured connection: reconnection/error/loading", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not mount plan rows from a reconnect snapshot", () => {
|
||||||
|
const first = new FakeSocket();
|
||||||
|
const second = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||||
|
first.open();
|
||||||
|
first.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [row("agent:old", "agent_text", { content: { type: "text", text: "old" } })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
first.drop();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
second.open();
|
||||||
|
second.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 4,
|
||||||
|
rows: [
|
||||||
|
row("user:new", "user", { content: { type: "text", text: "new" } }),
|
||||||
|
row("plan", "plan", { entries: [{ content: "reconnected", status: "in_progress" }] }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
second.emit({
|
||||||
|
type: "reduced_state",
|
||||||
|
state: {
|
||||||
|
...idleState,
|
||||||
|
plan: { entries: [{ content: "reconnected", priority: "high", status: "in_progress" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcript = conn.el.querySelector(".structured-rows")!;
|
||||||
|
expect(transcript.textContent).toBe("Younew");
|
||||||
|
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
|
||||||
|
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("reconnected");
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps surviving expanded rows open across a reconnect snapshot and leaves fresh rows collapsed", () => {
|
||||||
|
const first = new FakeSocket();
|
||||||
|
const second = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||||
|
first.open();
|
||||||
|
first.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] },
|
||||||
|
});
|
||||||
|
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
|
||||||
|
|
||||||
|
first.drop();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
second.open();
|
||||||
|
second.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 3,
|
||||||
|
rows: [toolRow("tool:1", "after"), diffRow("diff:1", "second"), toolRow("tool:2", "new")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
conn.el.querySelector('[data-row-id="tool:1"] .st-tool-head')?.getAttribute("aria-expanded"),
|
||||||
|
).toBe("true");
|
||||||
|
expect(
|
||||||
|
conn.el.querySelector('[data-row-id="diff:1"] .st-tool-head')?.getAttribute("aria-expanded"),
|
||||||
|
).toBe("true");
|
||||||
|
expect(
|
||||||
|
conn.el.querySelector('[data-row-id="tool:2"] .st-tool-head')?.getAttribute("aria-expanded"),
|
||||||
|
).toBe("false");
|
||||||
|
expect(conn.el.querySelector('[data-row-id="tool:1"] .st-tool-out')?.textContent).toContain("after");
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes expanded IDs removed or made non-expandable by a snapshot", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] },
|
||||||
|
});
|
||||||
|
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 3,
|
||||||
|
rows: [row("tool:plain", "agent_text", { content: { type: "text", text: "not a tool" } })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: { cursor: 5, rows: [toolRow("tool:gone", "returned"), toolRow("tool:plain", "returned")] },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) {
|
||||||
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
}
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
|
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
|
||||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||||
const first = new FakeSocket();
|
const first = new FakeSocket();
|
||||||
@@ -590,6 +932,298 @@ describe("structured connection: reconnection/error/loading", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("structured connection: transcript scrolling", () => {
|
||||||
|
let frames: Map<number, FrameRequestCallback>;
|
||||||
|
let nextFrame: number;
|
||||||
|
let resizeCallback: ResizeObserverCallback;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
frames = new Map();
|
||||||
|
nextFrame = 1;
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||||
|
const id = nextFrame++;
|
||||||
|
frames.set(id, cb);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
||||||
|
frames.delete(id);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("ResizeObserver", class {
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
resizeCallback = callback;
|
||||||
|
}
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function metrics(conn: ReturnType<typeof mount>, height: number, clientHeight: number, top = 0) {
|
||||||
|
const scroll = conn.el.querySelector<HTMLElement>(".structured-scroll")!;
|
||||||
|
const values = { height, clientHeight, top };
|
||||||
|
Object.defineProperties(scroll, {
|
||||||
|
scrollHeight: { configurable: true, get: () => values.height },
|
||||||
|
clientHeight: { configurable: true, get: () => values.clientHeight },
|
||||||
|
scrollTop: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => values.top,
|
||||||
|
set: (value: number) => { values.top = value; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { scroll, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFrames(): void {
|
||||||
|
const pending = [...frames.entries()];
|
||||||
|
frames.clear();
|
||||||
|
for (const [, cb] of pending) cb(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeBody(width: number): void {
|
||||||
|
resizeCallback(
|
||||||
|
[{ contentRect: { width } } as ResizeObserverEntry],
|
||||||
|
{} as ResizeObserver,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows the bottom after the initial transcript snapshot is laid out", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { values } = metrics(conn, 900, 300, 40);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [
|
||||||
|
row("agent:1", "agent_text", { content: { type: "text", text: "latest" } }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.top).toBe(40);
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(600);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pinned appends and patches at the bottom after layout", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { values } = metrics(conn, 600, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.height = 720;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "append",
|
||||||
|
row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(520);
|
||||||
|
|
||||||
|
values.height = 780;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("agent:1", "agent_text", {
|
||||||
|
content: { type: "text", text: "a longer completion" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(580);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ position: "pinned", top: 800, expected: 880, manuallyScrolled: false },
|
||||||
|
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
|
||||||
|
])("preserves the $position position when the composer grows", ({ top, expected, manuallyScrolled }) => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = top;
|
||||||
|
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||||
|
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
|
||||||
|
input.value = "one\ntwo\nthree";
|
||||||
|
input.dispatchEvent(new Event("input"));
|
||||||
|
values.height = 1080;
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
expect(input.style.height).toBe("120px");
|
||||||
|
expect(values.top).toBe(expected);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ position: "pinned", top: 800, expected: 850, manuallyScrolled: false },
|
||||||
|
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
|
||||||
|
])("preserves the $position position across the narrow breakpoint", ({ top, expected, manuallyScrolled }) => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = top;
|
||||||
|
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
resizeBody(500);
|
||||||
|
values.clientHeight = 150;
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
|
||||||
|
expect(values.top).toBe(expected);
|
||||||
|
resizeBody(500);
|
||||||
|
expect(frames.size).toBe(0);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [toolRow("tool:1", "output")] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = 180;
|
||||||
|
scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
values.height = 1120;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: { type: "append", row: row("agent:2", "agent_text", { content: { type: "text", text: "new" } }) },
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(180);
|
||||||
|
|
||||||
|
values.height = 1180;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "append",
|
||||||
|
row: row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions }),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
...idleState,
|
||||||
|
pending_approval: {
|
||||||
|
nonce: "n1",
|
||||||
|
request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(180);
|
||||||
|
|
||||||
|
conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!.click();
|
||||||
|
values.height = 1260;
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(180);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes following when the user scrolls back near the bottom", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = 750;
|
||||||
|
scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
values.height = 1100;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "append",
|
||||||
|
row: row("agent:1", "agent_text", { content: { type: "text", text: "follow" } }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(900);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-scrolled position across a reconnect snapshot", () => {
|
||||||
|
const first = new FakeSocket();
|
||||||
|
const second = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
first.open();
|
||||||
|
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = 160;
|
||||||
|
scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
first.drop();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
second.open();
|
||||||
|
values.height = 1200;
|
||||||
|
second.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [
|
||||||
|
row("agent:1", "agent_text", {
|
||||||
|
content: { type: "text", text: "reconnected" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(160);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a pending transcript frame when disposed or parked", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { values } = metrics(conn, 900, 300, 25);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
expect(frames.size).toBe(1);
|
||||||
|
|
||||||
|
conn.onUnmount();
|
||||||
|
expect(frames.size).toBe(0);
|
||||||
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||||
|
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
|
||||||
|
input.dispatchEvent(new Event("input"));
|
||||||
|
resizeBody(500);
|
||||||
|
expect(frames.size).toBe(0);
|
||||||
|
conn.onMount({ focus: false });
|
||||||
|
expect(frames.size).toBe(2);
|
||||||
|
conn.dispose();
|
||||||
|
expect(frames.size).toBe(0);
|
||||||
|
resizeBody(700);
|
||||||
|
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("structured connection: pane lifecycle", () => {
|
describe("structured connection: pane lifecycle", () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
|||||||
+184
-39
@@ -80,6 +80,7 @@ export function createStructuredConnection(
|
|||||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||||
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
||||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||||
|
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
|
||||||
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
||||||
const railEl = el.querySelector(".structured-rail") as HTMLElement;
|
const railEl = el.querySelector(".structured-rail") as HTMLElement;
|
||||||
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
|
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
|
||||||
@@ -106,6 +107,7 @@ export function createStructuredConnection(
|
|||||||
let rows: TranscriptRow[] = [];
|
let rows: TranscriptRow[] = [];
|
||||||
const rowIndex = new Map<string, number>();
|
const rowIndex = new Map<string, number>();
|
||||||
const rowEls = new Map<string, HTMLElement>();
|
const rowEls = new Map<string, HTMLElement>();
|
||||||
|
const expandedRowIds = new Set<string>();
|
||||||
let state: ReducedState = { turn: idleTurn() };
|
let state: ReducedState = { turn: idleTurn() };
|
||||||
let pendingApprovalRowId: string | null = null;
|
let pendingApprovalRowId: string | null = null;
|
||||||
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
||||||
@@ -116,6 +118,65 @@ export function createStructuredConnection(
|
|||||||
let stateSocketGeneration = 0;
|
let stateSocketGeneration = 0;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
|
// Within 64px, wheel and touch imprecision should still count as following.
|
||||||
|
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||||
|
let followingBottom = true;
|
||||||
|
let receivedSnapshot = false;
|
||||||
|
let scrollWorkActive = true;
|
||||||
|
let parkedScrollTop: number | null = null;
|
||||||
|
let scrollFrame: number | null = null;
|
||||||
|
let pendingScroll: { top: number; follow: boolean } | null = null;
|
||||||
|
|
||||||
|
function cancelScrollFrame(): void {
|
||||||
|
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
|
||||||
|
scrollFrame = null;
|
||||||
|
pendingScroll = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginTranscriptLayout(forceBottom = false): void {
|
||||||
|
if (!scrollWorkActive || disposed) return;
|
||||||
|
if (pendingScroll) {
|
||||||
|
if (forceBottom) pendingScroll.follow = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingScroll = {
|
||||||
|
top: scrollEl.scrollTop,
|
||||||
|
follow: forceBottom || followingBottom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishTranscriptLayout(): void {
|
||||||
|
if (!scrollWorkActive || disposed || !pendingScroll || scrollFrame !== null) return;
|
||||||
|
scrollFrame = window.requestAnimationFrame(() => {
|
||||||
|
scrollFrame = null;
|
||||||
|
const pending = pendingScroll;
|
||||||
|
pendingScroll = null;
|
||||||
|
if (!pending || disposed || !scrollWorkActive) return;
|
||||||
|
scrollEl.scrollTop = pending.follow
|
||||||
|
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
|
||||||
|
: pending.top;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTranscriptLayout(update: () => void, forceBottom = false): void {
|
||||||
|
beginTranscriptLayout(forceBottom);
|
||||||
|
update();
|
||||||
|
finishTranscriptLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTranscriptScroll(): void {
|
||||||
|
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
|
||||||
|
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
|
||||||
|
cancelScrollFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTranscriptClick(event: Event): void {
|
||||||
|
if ((event.target as Element | null)?.closest(".st-tool-head")) beginTranscriptLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollEl.addEventListener("scroll", onTranscriptScroll);
|
||||||
|
scrollEl.addEventListener("click", onTranscriptClick, true);
|
||||||
|
|
||||||
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
|
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
|
||||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||||
return null;
|
return null;
|
||||||
@@ -151,54 +212,106 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountRow(row: TranscriptRow): void {
|
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
const el = buildTranscriptRow(
|
||||||
|
row,
|
||||||
|
approvalStateFor(row),
|
||||||
|
expandedRowIds.has(row.id),
|
||||||
|
(expanded) => {
|
||||||
|
if (expanded) expandedRowIds.add(row.id);
|
||||||
|
else expandedRowIds.delete(row.id);
|
||||||
|
finishTranscriptLayout();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!el) return null;
|
||||||
|
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||||
|
expandedRowIds.delete(row.id);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertRowElement(row: TranscriptRow, el: HTMLElement): void {
|
||||||
|
const index = rowIndex.get(row.id) ?? rows.length - 1;
|
||||||
|
let next: HTMLElement | null = null;
|
||||||
|
for (let i = index + 1; i < rows.length; i++) {
|
||||||
|
next = rowEls.get(rows[i].id) ?? null;
|
||||||
|
if (next) break;
|
||||||
|
}
|
||||||
rowEls.set(row.id, el);
|
rowEls.set(row.id, el);
|
||||||
rowsEl.appendChild(el);
|
rowsEl.insertBefore(el, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountRow(row: TranscriptRow): void {
|
||||||
|
const el = buildRowElement(row);
|
||||||
|
if (el) insertRowElement(row, el);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remountRow(row: TranscriptRow): void {
|
function remountRow(row: TranscriptRow): void {
|
||||||
const old = rowEls.get(row.id);
|
const old = rowEls.get(row.id);
|
||||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||||
rowEls.set(row.id, el);
|
const restoreFocus = document.activeElement === oldToolHead;
|
||||||
if (old) old.replaceWith(el);
|
const el = buildRowElement(row);
|
||||||
else rowsEl.appendChild(el);
|
if (!el) {
|
||||||
|
old?.remove();
|
||||||
|
rowEls.delete(row.id);
|
||||||
|
expandedRowIds.delete(row.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (old) {
|
||||||
|
rowEls.set(row.id, el);
|
||||||
|
old.replaceWith(el);
|
||||||
|
} else {
|
||||||
|
insertRowElement(row, el);
|
||||||
|
}
|
||||||
|
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
|
||||||
|
if (restoreFocus) newToolHead?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||||
rows = [...snapshot.rows];
|
const forceBottom = !receivedSnapshot;
|
||||||
rowIndex.clear();
|
updateTranscriptLayout(() => {
|
||||||
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
rows = [...snapshot.rows];
|
||||||
rowEls.clear();
|
const expandableIds = new Set(
|
||||||
rowsEl.replaceChildren();
|
rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id),
|
||||||
for (const row of rows) mountRow(row);
|
);
|
||||||
|
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
|
||||||
|
rowIndex.clear();
|
||||||
|
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
||||||
|
rowEls.clear();
|
||||||
|
rowsEl.replaceChildren();
|
||||||
|
for (const row of rows) mountRow(row);
|
||||||
|
}, forceBottom);
|
||||||
|
receivedSnapshot = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDelta(delta: TranscriptDelta | null | undefined): void {
|
function applyDelta(delta: TranscriptDelta | null | undefined): void {
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
if (delta.type === "append") {
|
updateTranscriptLayout(() => {
|
||||||
rowIndex.set(delta.row.id, rows.length);
|
if (delta.type === "append") {
|
||||||
rows.push(delta.row);
|
|
||||||
mountRow(delta.row);
|
|
||||||
} else if (delta.type === "patch") {
|
|
||||||
const i = rowIndex.get(delta.row.id);
|
|
||||||
if (i === undefined) {
|
|
||||||
rowIndex.set(delta.row.id, rows.length);
|
rowIndex.set(delta.row.id, rows.length);
|
||||||
rows.push(delta.row);
|
rows.push(delta.row);
|
||||||
mountRow(delta.row);
|
mountRow(delta.row);
|
||||||
|
} else if (delta.type === "patch") {
|
||||||
|
const i = rowIndex.get(delta.row.id);
|
||||||
|
if (i === undefined) {
|
||||||
|
rowIndex.set(delta.row.id, rows.length);
|
||||||
|
rows.push(delta.row);
|
||||||
|
mountRow(delta.row);
|
||||||
|
} else {
|
||||||
|
rows[i] = delta.row;
|
||||||
|
remountRow(delta.row);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
rows[i] = delta.row;
|
const i = rowIndex.get(delta.id);
|
||||||
remountRow(delta.row);
|
if (i === undefined) return;
|
||||||
|
rows.splice(i, 1);
|
||||||
|
expandedRowIds.delete(delta.id);
|
||||||
|
rowIndex.clear();
|
||||||
|
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||||
|
rowEls.get(delta.id)?.remove();
|
||||||
|
rowEls.delete(delta.id);
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
const i = rowIndex.get(delta.id);
|
|
||||||
if (i === undefined) return;
|
|
||||||
rows.splice(i, 1);
|
|
||||||
rowIndex.clear();
|
|
||||||
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
|
||||||
rowEls.get(delta.id)?.remove();
|
|
||||||
rowEls.delete(delta.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolRowCount(): number {
|
function toolRowCount(): number {
|
||||||
@@ -247,10 +360,12 @@ export function createStructuredConnection(
|
|||||||
reconcileAwaitingBegin();
|
reconcileAwaitingBegin();
|
||||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||||
if (newPendingId !== pendingApprovalRowId) {
|
if (newPendingId !== pendingApprovalRowId) {
|
||||||
const prev = pendingApprovalRowId;
|
updateTranscriptLayout(() => {
|
||||||
pendingApprovalRowId = newPendingId;
|
const prev = pendingApprovalRowId;
|
||||||
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
|
pendingApprovalRowId = newPendingId;
|
||||||
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
|
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
|
||||||
|
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
renderPills();
|
renderPills();
|
||||||
renderRailNow();
|
renderRailNow();
|
||||||
@@ -274,8 +389,10 @@ export function createStructuredConnection(
|
|||||||
|
|
||||||
// ---- composer ----
|
// ---- composer ----
|
||||||
function autoResize(): void {
|
function autoResize(): void {
|
||||||
input.style.height = "auto";
|
updateTranscriptLayout(() => {
|
||||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
input.style.height = "auto";
|
||||||
|
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
input.addEventListener("input", autoResize);
|
input.addEventListener("input", autoResize);
|
||||||
input.addEventListener("keydown", (e) => {
|
input.addEventListener("keydown", (e) => {
|
||||||
@@ -331,8 +448,11 @@ export function createStructuredConnection(
|
|||||||
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
|
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
|
||||||
const NARROW_BREAKPOINT = 560;
|
const NARROW_BREAKPOINT = 560;
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
if (disposed) return;
|
||||||
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
|
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
|
||||||
bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT);
|
const narrow = width < NARROW_BREAKPOINT;
|
||||||
|
if (bodyEl.classList.contains("st-narrow") === narrow) return;
|
||||||
|
updateTranscriptLayout(() => bodyEl.classList.toggle("st-narrow", narrow));
|
||||||
});
|
});
|
||||||
resizeObserver.observe(bodyEl);
|
resizeObserver.observe(bodyEl);
|
||||||
|
|
||||||
@@ -447,6 +567,12 @@ export function createStructuredConnection(
|
|||||||
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
|
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
|
||||||
// it is no longer the most recent one.
|
// it is no longer the most recent one.
|
||||||
let mountToken = 0;
|
let mountToken = 0;
|
||||||
|
let focusFrame: number | null = null;
|
||||||
|
|
||||||
|
function cancelFocusFrame(): void {
|
||||||
|
if (focusFrame !== null) window.cancelAnimationFrame(focusFrame);
|
||||||
|
focusFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -454,13 +580,26 @@ export function createStructuredConnection(
|
|||||||
onMount(mountOpts?: { focus?: boolean }) {
|
onMount(mountOpts?: { focus?: boolean }) {
|
||||||
const focusOnMount = mountOpts?.focus !== false;
|
const focusOnMount = mountOpts?.focus !== false;
|
||||||
const token = ++mountToken;
|
const token = ++mountToken;
|
||||||
requestAnimationFrame(() => {
|
scrollWorkActive = true;
|
||||||
|
pendingScroll = {
|
||||||
|
top: parkedScrollTop ?? scrollEl.scrollTop,
|
||||||
|
follow: followingBottom,
|
||||||
|
};
|
||||||
|
parkedScrollTop = null;
|
||||||
|
finishTranscriptLayout();
|
||||||
|
cancelFocusFrame();
|
||||||
|
focusFrame = window.requestAnimationFrame(() => {
|
||||||
|
focusFrame = null;
|
||||||
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
|
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
|
||||||
if (focusOnMount && !input.disabled) input.focus();
|
if (focusOnMount && !input.disabled) input.focus();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onUnmount() {
|
onUnmount() {
|
||||||
mountToken++;
|
mountToken++;
|
||||||
|
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
|
||||||
|
scrollWorkActive = false;
|
||||||
|
cancelScrollFrame();
|
||||||
|
cancelFocusFrame();
|
||||||
},
|
},
|
||||||
focusTerminal() {
|
focusTerminal() {
|
||||||
if (!input.disabled) input.focus();
|
if (!input.disabled) input.focus();
|
||||||
@@ -473,10 +612,16 @@ export function createStructuredConnection(
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
mountToken++;
|
mountToken++;
|
||||||
|
scrollWorkActive = false;
|
||||||
|
cancelScrollFrame();
|
||||||
|
cancelFocusFrame();
|
||||||
cancelReconnect();
|
cancelReconnect();
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
unsubscribeStore?.();
|
unsubscribeStore?.();
|
||||||
closeCurrentSocket();
|
closeCurrentSocket();
|
||||||
|
expandedRowIds.clear();
|
||||||
|
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||||
|
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||||
el.remove();
|
el.remove();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,61 @@ describe("computeDiffLines", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles an insertion without changing surrounding lines", () => {
|
||||||
|
expect(texts(computeDiffLines("a\nc\n", "a\nb\nc\n"))).toEqual([
|
||||||
|
["ctx", "a"],
|
||||||
|
["add", "b"],
|
||||||
|
["ctx", "c"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a deletion without changing surrounding lines", () => {
|
||||||
|
expect(texts(computeDiffLines("a\nb\nc\n", "a\nc\n"))).toEqual([
|
||||||
|
["ctx", "a"],
|
||||||
|
["del", "b"],
|
||||||
|
["ctx", "c"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders two empty files without invented lines", () => {
|
||||||
|
expect(computeDiffLines("", "")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows when a final newline is added", () => {
|
||||||
|
expect(texts(computeDiffLines("a", "a\n"))).toEqual([
|
||||||
|
["del", "a"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
["add", "a"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows when a final newline is removed", () => {
|
||||||
|
expect(texts(computeDiffLines("a\n", "a"))).toEqual([
|
||||||
|
["del", "a"],
|
||||||
|
["add", "a"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an unchanged unterminated final context line", () => {
|
||||||
|
expect(texts(computeDiffLines("a\nb\nc", "a\nX\nc"))).toEqual([
|
||||||
|
["ctx", "a"],
|
||||||
|
["del", "b"],
|
||||||
|
["add", "X"],
|
||||||
|
["ctx", "c"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an unterminated new file only once", () => {
|
||||||
|
const lines = computeDiffLines(null, "a");
|
||||||
|
expect(texts(lines)).toEqual([
|
||||||
|
["add", "a"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
]);
|
||||||
|
expect(lines.filter((line) => line.kind === "eof")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
||||||
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
||||||
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
||||||
@@ -59,6 +114,22 @@ describe("computeDiffLines", () => {
|
|||||||
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
|
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves the EOF marker when a long unterminated trailing run collapses", () => {
|
||||||
|
const trailing = Array.from({ length: 10 }, (_, i) => `t${i}`);
|
||||||
|
const oldText = ["OLD", ...trailing].join("\n");
|
||||||
|
const newText = ["NEW", ...trailing].join("\n");
|
||||||
|
|
||||||
|
expect(texts(computeDiffLines(oldText, newText))).toEqual([
|
||||||
|
["del", "OLD"],
|
||||||
|
["add", "NEW"],
|
||||||
|
["ctx", "t0"],
|
||||||
|
["ctx", "t1"],
|
||||||
|
["ctx", "t2"],
|
||||||
|
["hunk", "⋯ 7 unchanged lines ⋯"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("produces one hunk for the long middle run, none for the short edges", () => {
|
it("produces one hunk for the long middle run, none for the short edges", () => {
|
||||||
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
|
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
|
||||||
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
||||||
@@ -75,9 +146,10 @@ describe("computeDiffLines", () => {
|
|||||||
["ctx", "lead0"],
|
["ctx", "lead0"],
|
||||||
["ctx", "lead1"],
|
["ctx", "lead1"],
|
||||||
]);
|
]);
|
||||||
expect(texts(lines).slice(-2)).toEqual([
|
expect(texts(lines).slice(-3)).toEqual([
|
||||||
["ctx", "trail0"],
|
["ctx", "trail0"],
|
||||||
["ctx", "trail1"],
|
["ctx", "trail1"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,10 +157,22 @@ describe("computeDiffLines", () => {
|
|||||||
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
||||||
expect(texts(lines)).toEqual([
|
expect(texts(lines)).toEqual([
|
||||||
["ctx", "a"],
|
["ctx", "a"],
|
||||||
["ctx", "b"],
|
["del", "b"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
["add", "b"],
|
||||||
["add", "c"],
|
["add", "c"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to a bounded coarse diff for a pathological changed middle", () => {
|
||||||
|
const oldLines = Array.from({ length: 501 }, (_, index) => `line${index}`);
|
||||||
|
const newLines = [...oldLines.slice(1), oldLines[0]];
|
||||||
|
const lines = computeDiffLines(`${oldLines.join("\n")}\n`, `${newLines.join("\n")}\n`);
|
||||||
|
expect(lines).toHaveLength(1_002);
|
||||||
|
expect(lines.slice(0, 501).every((line) => line.kind === "del")).toBe(true);
|
||||||
|
expect(lines.slice(501).every((line) => line.kind === "add")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("diffStats", () => {
|
describe("diffStats", () => {
|
||||||
|
|||||||
+57
-31
@@ -2,7 +2,7 @@
|
|||||||
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
||||||
// coloured hunks the design calls for have to be computed here.
|
// coloured hunks the design calls for have to be computed here.
|
||||||
|
|
||||||
export type DiffLineKind = "add" | "del" | "ctx" | "hunk";
|
export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof";
|
||||||
|
|
||||||
export interface DiffLine {
|
export interface DiffLine {
|
||||||
kind: DiffLineKind;
|
kind: DiffLineKind;
|
||||||
@@ -19,66 +19,82 @@ const CONTEXT = 3;
|
|||||||
* still correct, just not minimal. */
|
* still correct, just not minimal. */
|
||||||
const LCS_CELL_LIMIT = 250_000;
|
const LCS_CELL_LIMIT = 250_000;
|
||||||
|
|
||||||
function splitLines(text: string): string[] {
|
interface SourceLine {
|
||||||
if (text === "") return [];
|
text: string;
|
||||||
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
|
terminated: boolean;
|
||||||
return body.split("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Op = { kind: "add" | "del" | "ctx"; text: string };
|
function splitLines(text: string): SourceLine[] {
|
||||||
|
if (text === "") return [];
|
||||||
|
const parts = text.split("\n");
|
||||||
|
const hasFinalNewline = text.endsWith("\n");
|
||||||
|
if (hasFinalNewline) parts.pop();
|
||||||
|
return parts.map((line, index) => ({
|
||||||
|
text: line,
|
||||||
|
terminated: index < parts.length - 1 || hasFinalNewline,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Op = { kind: "add" | "del" | "ctx"; line: SourceLine };
|
||||||
|
|
||||||
|
function sameLine(a: SourceLine, b: SourceLine): boolean {
|
||||||
|
return a.text === b.text && a.terminated === b.terminated;
|
||||||
|
}
|
||||||
|
|
||||||
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
|
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
|
||||||
* shared prefix/suffix by the caller. */
|
* shared prefix/suffix by the caller. */
|
||||||
function lcsDiff(a: string[], b: string[]): Op[] {
|
function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||||
const n = a.length;
|
const n = a.length;
|
||||||
const m = b.length;
|
const m = b.length;
|
||||||
if (n === 0) return b.map((text) => ({ kind: "add", text }));
|
if (n === 0) return b.map((line) => ({ kind: "add", line }));
|
||||||
if (m === 0) return a.map((text) => ({ kind: "del", text }));
|
if (m === 0) return a.map((line) => ({ kind: "del", line }));
|
||||||
if (n * m > LCS_CELL_LIMIT) {
|
if (n * m > LCS_CELL_LIMIT) {
|
||||||
return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
|
return [...a.map((line) => ({ kind: "del" as const, line })), ...b.map((line) => ({ kind: "add" as const, line }))];
|
||||||
}
|
}
|
||||||
// dp[i][j] = length of the LCS of a[i:] and b[j:].
|
// dp[i][j] = length of the LCS of a[i:] and b[j:].
|
||||||
const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
const columns = m + 1;
|
||||||
|
const dp = new Uint32Array((n + 1) * columns);
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
for (let j = m - 1; j >= 0; j--) {
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
dp[i][j] =
|
dp[i * columns + j] = sameLine(a[i], b[j])
|
||||||
a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
? dp[(i + 1) * columns + j + 1] + 1
|
||||||
|
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ops: Op[] = [];
|
const ops: Op[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let j = 0;
|
let j = 0;
|
||||||
while (i < n && j < m) {
|
while (i < n && j < m) {
|
||||||
if (a[i] === b[j]) {
|
if (sameLine(a[i], b[j])) {
|
||||||
ops.push({ kind: "ctx", text: a[i] });
|
ops.push({ kind: "ctx", line: a[i] });
|
||||||
i++;
|
i++;
|
||||||
j++;
|
j++;
|
||||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
|
||||||
ops.push({ kind: "del", text: a[i] });
|
ops.push({ kind: "del", line: a[i] });
|
||||||
i++;
|
i++;
|
||||||
} else {
|
} else {
|
||||||
ops.push({ kind: "add", text: b[j] });
|
ops.push({ kind: "add", line: b[j] });
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (i < n) ops.push({ kind: "del", text: a[i++] });
|
while (i < n) ops.push({ kind: "del", line: a[i++] });
|
||||||
while (j < m) ops.push({ kind: "add", text: b[j++] });
|
while (j < m) ops.push({ kind: "add", line: b[j++] });
|
||||||
return ops;
|
return ops;
|
||||||
}
|
}
|
||||||
|
|
||||||
function diffLines(a: string[], b: string[]): Op[] {
|
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||||
let pre = 0;
|
let pre = 0;
|
||||||
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
|
while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++;
|
||||||
let suf = 0;
|
let suf = 0;
|
||||||
while (
|
while (
|
||||||
suf < a.length - pre &&
|
suf < a.length - pre &&
|
||||||
suf < b.length - pre &&
|
suf < b.length - pre &&
|
||||||
a[a.length - 1 - suf] === b[b.length - 1 - suf]
|
sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf])
|
||||||
) {
|
) {
|
||||||
suf++;
|
suf++;
|
||||||
}
|
}
|
||||||
const prefixCtx: Op[] = a.slice(0, pre).map((text) => ({ kind: "ctx", text }));
|
const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line }));
|
||||||
const suffixCtx: Op[] = a.slice(a.length - suf).map((text) => ({ kind: "ctx", text }));
|
const suffixCtx: Op[] = a.slice(a.length - suf).map((line) => ({ kind: "ctx", line }));
|
||||||
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
||||||
return [...prefixCtx, ...middle, ...suffixCtx];
|
return [...prefixCtx, ...middle, ...suffixCtx];
|
||||||
}
|
}
|
||||||
@@ -91,10 +107,16 @@ function diffLines(a: string[], b: string[]): Op[] {
|
|||||||
* (unless it is the *only* run, i.e. nothing changed at all). */
|
* (unless it is the *only* run, i.e. nothing changed at all). */
|
||||||
function hunk(ops: Op[]): DiffLine[] {
|
function hunk(ops: Op[]): DiffLine[] {
|
||||||
const out: DiffLine[] = [];
|
const out: DiffLine[] = [];
|
||||||
|
const push = (op: Op) => {
|
||||||
|
out.push({ kind: op.kind, text: op.line.text });
|
||||||
|
if (!op.line.terminated) {
|
||||||
|
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
||||||
|
}
|
||||||
|
};
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < ops.length) {
|
while (i < ops.length) {
|
||||||
if (ops[i].kind !== "ctx") {
|
if (ops[i].kind !== "ctx") {
|
||||||
out.push(ops[i]);
|
push(ops[i]);
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -105,14 +127,18 @@ function hunk(ops: Op[]): DiffLine[] {
|
|||||||
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
||||||
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
||||||
if (run.length <= keepHead + keepTail) {
|
if (run.length <= keepHead + keepTail) {
|
||||||
out.push(...run);
|
run.forEach(push);
|
||||||
} else {
|
} else {
|
||||||
if (keepHead) out.push(...run.slice(0, keepHead));
|
if (keepHead) run.slice(0, keepHead).forEach(push);
|
||||||
|
const collapsed = run.slice(keepHead, run.length - keepTail);
|
||||||
out.push({
|
out.push({
|
||||||
kind: "hunk",
|
kind: "hunk",
|
||||||
text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`,
|
text: `⋯ ${collapsed.length} unchanged lines ⋯`,
|
||||||
});
|
});
|
||||||
if (keepTail) out.push(...run.slice(run.length - keepTail));
|
if (collapsed.some((op) => !op.line.terminated)) {
|
||||||
|
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
||||||
|
}
|
||||||
|
if (keepTail) run.slice(run.length - keepTail).forEach(push);
|
||||||
}
|
}
|
||||||
i = j;
|
i = j;
|
||||||
}
|
}
|
||||||
@@ -123,7 +149,7 @@ function hunk(ops: Op[]): DiffLine[] {
|
|||||||
* (a new file) renders every line of `newText` as added. */
|
* (a new file) renders every line of `newText` as added. */
|
||||||
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
||||||
const newLines = splitLines(newText);
|
const newLines = splitLines(newText);
|
||||||
if (oldText == null) return newLines.map((text) => ({ kind: "add", text }));
|
if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line })));
|
||||||
const ops = diffLines(splitLines(oldText), newLines);
|
const ops = diffLines(splitLines(oldText), newLines);
|
||||||
return hunk(ops);
|
return hunk(ops);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
} from "./structuredRender.js";
|
} from "./structuredRender.js";
|
||||||
import type { Session, TranscriptRow } from "./types.js";
|
import type { Session, TranscriptRow } from "./types.js";
|
||||||
|
|
||||||
function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow {
|
function row<K extends TranscriptRow["kind"]>(
|
||||||
|
kind: K,
|
||||||
|
data: unknown,
|
||||||
|
id = "r1",
|
||||||
|
): TranscriptRow & { kind: K } {
|
||||||
return { id, kind, data };
|
return { id, kind, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +161,17 @@ describe("buildTranscriptRow: tool diffs", () => {
|
|||||||
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||||||
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a visible marker for a removed final newline", () => {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
row("tool_diff", {
|
||||||
|
title: "Edit(x.rs)",
|
||||||
|
content: [{ type: "diff", path: "/repo/x.rs", oldText: "same\n", newText: "same" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||||
|
expect(el.querySelector(".st-ln-eof")!.textContent).toBe("\\ No newline at end of file");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildTranscriptRow: approval", () => {
|
describe("buildTranscriptRow: approval", () => {
|
||||||
@@ -264,9 +279,16 @@ describe("buildTranscriptRow: approval", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||||
it("renders a plan or notice row without crashing", () => {
|
it("renders notice and unknown row kinds safely", () => {
|
||||||
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow();
|
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
|
||||||
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow();
|
"[notice]",
|
||||||
|
);
|
||||||
|
const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow;
|
||||||
|
expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no transcript element for a plan row", () => {
|
||||||
|
expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+44
-10
@@ -187,7 +187,11 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolRow(row: TranscriptRow): HTMLElement {
|
function buildToolRow(
|
||||||
|
row: TranscriptRow,
|
||||||
|
expanded: boolean,
|
||||||
|
onExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): HTMLElement {
|
||||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||||
const content = tool.content ?? [];
|
const content = tool.content ?? [];
|
||||||
const hasDiff = content.some((c) => c.type === "diff");
|
const hasDiff = content.some((c) => c.type === "diff");
|
||||||
@@ -251,13 +255,17 @@ function buildToolRow(row: TranscriptRow): HTMLElement {
|
|||||||
};
|
};
|
||||||
card.appendChild(out);
|
card.appendChild(out);
|
||||||
|
|
||||||
|
const setExpanded = (nextExpanded: boolean) => {
|
||||||
|
if (nextExpanded) buildOut();
|
||||||
|
if (out.children.length === 0) nextExpanded = false;
|
||||||
|
head.setAttribute("aria-expanded", String(nextExpanded));
|
||||||
|
out.hidden = !nextExpanded;
|
||||||
|
};
|
||||||
head.addEventListener("click", () => {
|
head.addEventListener("click", () => {
|
||||||
const expanded = head.getAttribute("aria-expanded") === "true";
|
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
||||||
if (!expanded) buildOut();
|
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
|
||||||
if (out.children.length === 0) return; // nothing to show — stay collapsed
|
|
||||||
head.setAttribute("aria-expanded", String(!expanded));
|
|
||||||
out.hidden = expanded;
|
|
||||||
});
|
});
|
||||||
|
setExpanded(expanded);
|
||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
@@ -359,9 +367,33 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
/** Build the DOM for one transcript row, or `null` for a plan shown only in
|
||||||
* `approval` row; pass it whenever the row might be that kind. */
|
* the rail. `approval` is consulted only for an `approval` row; tool
|
||||||
export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowState): HTMLElement {
|
* expansion is supplied and reported explicitly. */
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow & { kind: "plan" },
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded?: boolean,
|
||||||
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): null;
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan"> },
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded?: boolean,
|
||||||
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): HTMLElement;
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow,
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded?: boolean,
|
||||||
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): HTMLElement | null;
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow,
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded = false,
|
||||||
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): HTMLElement | null {
|
||||||
let built: HTMLElement;
|
let built: HTMLElement;
|
||||||
switch (row.kind) {
|
switch (row.kind) {
|
||||||
case "user":
|
case "user":
|
||||||
@@ -375,11 +407,13 @@ export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowSta
|
|||||||
break;
|
break;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
case "tool_diff":
|
case "tool_diff":
|
||||||
built = buildToolRow(row);
|
built = buildToolRow(row, toolExpanded, onToolExpandedChange);
|
||||||
break;
|
break;
|
||||||
case "approval":
|
case "approval":
|
||||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||||
break;
|
break;
|
||||||
|
case "plan":
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
built = buildPlainRow(row);
|
built = buildPlainRow(row);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -2509,6 +2509,11 @@ input.leaf-rename:focus {
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.st-ln-eof {
|
||||||
|
color: var(--faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* the one lifted, card-like element on the surface: the only thing the
|
/* the one lifted, card-like element on the surface: the only thing the
|
||||||
operator owes a decision on */
|
operator owes a decision on */
|
||||||
.st-approval {
|
.st-approval {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||||
|
import commandResolutionFixture from "../../fixtures/command-resolution.json";
|
||||||
|
|
||||||
|
interface CommandResolutionCase {
|
||||||
|
command: string;
|
||||||
|
program: string;
|
||||||
|
tool: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
||||||
it("recognises the agents and shells sessions are spawned with", () => {
|
it("recognises the agents and shells sessions are spawned with", () => {
|
||||||
@@ -46,6 +53,14 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
|||||||
expect(splitCommand("").tool).toBeNull();
|
expect(splitCommand("").tool).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves every case in the shared command-resolution fixture (#199)", () => {
|
||||||
|
const cases = commandResolutionFixture.cases as CommandResolutionCase[];
|
||||||
|
expect(cases.length).toBeGreaterThan(0);
|
||||||
|
for (const { command, program, tool } of cases) {
|
||||||
|
expect(splitCommand(command), command).toEqual({ tool, program });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("gives every recognised tool a labelled mark", () => {
|
it("gives every recognised tool a labelled mark", () => {
|
||||||
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
|
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
|
||||||
const svg = toolIconSvg(tool);
|
const svg = toolIconSvg(tool);
|
||||||
|
|||||||
+428
-23
@@ -9,10 +9,11 @@
|
|||||||
* as text, so the fragment never renders a mark that stands for the wrong tool.
|
* as text, so the fragment never renders a mark that stands for the wrong tool.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Command wrappers that run another program: the tool is what follows. */
|
/** Program name (already basenamed and lowercased) → icon id.
|
||||||
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]);
|
*
|
||||||
|
* `claude-code-acp` — the daemon's actual structured-session child — is
|
||||||
/** Program name (already basenamed and lowercased) → icon id. */
|
* deliberately absent: the daemon launches it directly and ignores the
|
||||||
|
* session's command, so it is never itself a valid user-facing launch form. */
|
||||||
const BY_PROGRAM: Record<string, string> = {
|
const BY_PROGRAM: Record<string, string> = {
|
||||||
claude: "claude",
|
claude: "claude",
|
||||||
"claude-code": "claude",
|
"claude-code": "claude",
|
||||||
@@ -53,39 +54,443 @@ export interface CommandParts {
|
|||||||
program: string;
|
program: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split a command line into the tool its icon stands for and the program name.
|
/** Split a command line into the tool its icon stands for and the program
|
||||||
|
* name — basenamed and stripped of its arguments. Leading `FOO=bar`
|
||||||
|
* assignments and wrapper forms (`sudo`, `env -i`, `npx`, `pnpm dlx`, …) are
|
||||||
|
* looked through, and quoting/escaping is honoured (never evaluated — no
|
||||||
|
* `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a shell would need real
|
||||||
|
* grammar for (pipelines, `&&`/`;`, redirections, substitution, an
|
||||||
|
* unterminated quote) falls back to the naive first word rather than
|
||||||
|
* guessing through it (#199). Mirrors the Rust `command_program`/
|
||||||
|
* `command_tool` in `crates/maestro-protocol/src/display.rs`.
|
||||||
*
|
*
|
||||||
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
|
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
|
||||||
* `hx src/` keeps `hx` as the program with no tool. */
|
* `hx src/` keeps `hx` as the program with no tool. */
|
||||||
export function splitCommand(command: string): CommandParts {
|
export function splitCommand(command: string): CommandParts {
|
||||||
const words = command.trim().split(/\s+/).filter(Boolean);
|
const words = tokenize(command);
|
||||||
let i = 0;
|
if (words === null) {
|
||||||
// Leading `FOO=bar` assignments and wrappers stand in front of the real
|
const program = legacyFirstWord(command);
|
||||||
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped.
|
return { tool: null, program };
|
||||||
while (i < words.length) {
|
|
||||||
const word = words[i];
|
|
||||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const wrapper = basename(word).toLowerCase();
|
|
||||||
if (WRAPPERS.has(wrapper)) {
|
|
||||||
i += 1;
|
|
||||||
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
const i = programIndex(words);
|
||||||
if (i >= words.length) return { tool: null, program: command.trim() };
|
if (i >= words.length) return { tool: null, program: command.trim() };
|
||||||
const program = basename(words[i]);
|
const program = basename(words[i].text);
|
||||||
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
|
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The naive "first whitespace word, basenamed" reading used only when
|
||||||
|
* {@link tokenize} rejects the line outright — still something sane to
|
||||||
|
* show, never a wrapper/tool match to act on. */
|
||||||
|
function legacyFirstWord(command: string): string {
|
||||||
|
const first = command.trim().split(/\s+/).filter(Boolean)[0];
|
||||||
|
return first === undefined ? command.trim() : basename(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One token from {@link tokenize}: its dequoted text, and — when both the
|
||||||
|
* name and `=` were entirely unquoted/unescaped in the source — the index of
|
||||||
|
* the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
|
||||||
|
* makes the word an executable named `FOO=1`; quoting/escaping only the value
|
||||||
|
* preserves the assignment, matching Bash's assignment-word grammar (#199). */
|
||||||
|
interface Word {
|
||||||
|
text: string;
|
||||||
|
plainEq: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `FOO=bar`, the shell's own prefix for one command's environment —
|
||||||
|
* recognised only when the `=` came from plain, unquoted/unescaped source
|
||||||
|
* text (#199). */
|
||||||
|
function isEnvAssignment(word: Word): boolean {
|
||||||
|
if (word.plainEq === null) return false;
|
||||||
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(word.text.slice(0, word.plainEq));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk `words` past leading `FOO=bar` assignments and known wrapper forms
|
||||||
|
* (interleaved in any order/count) to the index of the actual program. An
|
||||||
|
* assignment run is only consumed at the very start of the line or right
|
||||||
|
* after a wrapper whose own semantics accept one there ({@link Wrapper.acceptsAssignment}) —
|
||||||
|
* `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
|
||||||
|
* skipping to `claude`, since `command`/`nohup` just exec their next argument
|
||||||
|
* as the program, they don't setenv from an assignment-shaped one (#199). */
|
||||||
|
function programIndex(words: Word[]): number {
|
||||||
|
const texts = words.map((w) => w.text);
|
||||||
|
let i = 0;
|
||||||
|
let assignmentAllowed = true;
|
||||||
|
for (;;) {
|
||||||
|
if (assignmentAllowed) {
|
||||||
|
while (i < words.length && isEnvAssignment(words[i])) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i >= words.length) break;
|
||||||
|
const wrapper = basename(texts[i]).toLowerCase();
|
||||||
|
const w = WRAPPER_RESOLVERS[wrapper];
|
||||||
|
if (!w) break;
|
||||||
|
const next = w.resolve(texts, i);
|
||||||
|
// Not a form of the wrapper we recognise (an unknown option, or — for
|
||||||
|
// `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it): the wrapper
|
||||||
|
// word itself is the program, not a launcher.
|
||||||
|
if (next === null || next <= i) break;
|
||||||
|
i = next;
|
||||||
|
assignmentAllowed = w.acceptsAssignment;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
function basename(word: string): string {
|
function basename(word: string): string {
|
||||||
const cut = word.lastIndexOf("/");
|
const cut = word.lastIndexOf("/");
|
||||||
return cut >= 0 ? word.slice(cut + 1) : word;
|
return cut >= 0 ? word.slice(cut + 1) : word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
|
||||||
|
* `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
|
||||||
|
* `null` the moment it needs real shell grammar this resolver doesn't
|
||||||
|
* attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
|
||||||
|
* substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
|
||||||
|
* separator, like `;` — #199), or an unterminated quote/escape. Never
|
||||||
|
* expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
|
||||||
|
* backslash immediately before a newline, quoted or not, is a line
|
||||||
|
* continuation: removed entirely rather than embedding the newline or
|
||||||
|
* ending the word (#199). */
|
||||||
|
function tokenize(command: string): Word[] | null {
|
||||||
|
const words: Word[] = [];
|
||||||
|
let current = "";
|
||||||
|
let hasCurrent = false;
|
||||||
|
let plainEq: number | null = null;
|
||||||
|
let assignmentNameIsPlain = true;
|
||||||
|
let i = 0;
|
||||||
|
const n = command.length;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const c = command[i];
|
||||||
|
if (c === " " || c === "\t" || c === "\r") {
|
||||||
|
if (hasCurrent) {
|
||||||
|
words.push({ text: current, plainEq });
|
||||||
|
current = "";
|
||||||
|
hasCurrent = false;
|
||||||
|
plainEq = null;
|
||||||
|
assignmentNameIsPlain = true;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
} else if (c === "\n") {
|
||||||
|
return null;
|
||||||
|
} else if ("|&;<>()`".includes(c)) {
|
||||||
|
return null;
|
||||||
|
} else if (c === "$" && command[i + 1] === "(") {
|
||||||
|
return null;
|
||||||
|
} else if (c === "'") {
|
||||||
|
hasCurrent = true;
|
||||||
|
if (plainEq === null) assignmentNameIsPlain = false;
|
||||||
|
i += 1;
|
||||||
|
const close = command.indexOf("'", i);
|
||||||
|
if (close === -1) return null; // unterminated single quote
|
||||||
|
current += command.slice(i, close);
|
||||||
|
i = close + 1;
|
||||||
|
} else if (c === '"') {
|
||||||
|
hasCurrent = true;
|
||||||
|
if (plainEq === null) assignmentNameIsPlain = false;
|
||||||
|
i += 1;
|
||||||
|
for (;;) {
|
||||||
|
if (i >= n) return null; // unterminated double quote
|
||||||
|
const c2 = command[i];
|
||||||
|
if (c2 === '"') {
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (c2 === "\\") {
|
||||||
|
const escaped = command[i + 1];
|
||||||
|
if (escaped === undefined) return null; // trailing backslash
|
||||||
|
if (escaped === "\n") {
|
||||||
|
i += 2; // line continuation, removed entirely
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += '"\\$`'.includes(escaped) ? escaped : "\\" + escaped;
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
current += c2;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (c === "\\") {
|
||||||
|
const escaped = command[i + 1];
|
||||||
|
if (escaped === undefined) return null; // trailing backslash
|
||||||
|
if (escaped === "\n") {
|
||||||
|
i += 2; // line continuation, removed entirely
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hasCurrent = true;
|
||||||
|
if (plainEq === null) assignmentNameIsPlain = false;
|
||||||
|
current += escaped;
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
hasCurrent = true;
|
||||||
|
if (c === "=" && plainEq === null && assignmentNameIsPlain) {
|
||||||
|
plainEq = current.length;
|
||||||
|
}
|
||||||
|
current += c;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasCurrent) words.push({ text: current, plainEq });
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wrapper's known `-`/`--` options: which take no value, and which
|
||||||
|
* consume either a following word or a `--flag=value` as theirs. A
|
||||||
|
* `-`-prefixed word this table doesn't know halts resolution rather than
|
||||||
|
* guessing past it (#199) — callers read that as {@link consumeOptions}
|
||||||
|
* returning `null`. */
|
||||||
|
interface OptionTable {
|
||||||
|
flags: Set<string>;
|
||||||
|
valued: Set<string>;
|
||||||
|
/** Whether this wrapper's short options may be combined (`-iv`) or take
|
||||||
|
* an attached value (`-uroot`), the way real getopt-based tools do
|
||||||
|
* (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/`dlx`)
|
||||||
|
* don't get this — guessing at bundling they don't actually support
|
||||||
|
* would resolve through options they'd reject outright. */
|
||||||
|
bundlesShortOptions: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `-iv` (bundled boolean short flags) or `-uroot` (a valued short option
|
||||||
|
* with its value attached) — getopt's traditional short-option forms.
|
||||||
|
* `false` the moment a character isn't a short option this table knows, or
|
||||||
|
* a valued one ends the word with no attached value left to be its operand
|
||||||
|
* (#199: rejected rather than guessed to come from the next word instead). */
|
||||||
|
function matchesShortCluster(opts: OptionTable, word: string): boolean {
|
||||||
|
let idx = 1;
|
||||||
|
while (idx < word.length) {
|
||||||
|
const short = "-" + word[idx];
|
||||||
|
if (opts.flags.has(short)) {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (opts.valued.has(short)) {
|
||||||
|
return idx + 1 < word.length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consume `opts`'s options starting at `words[i]`, returning the index just
|
||||||
|
* past them (which may equal `i` if there are none) — or `null` on an
|
||||||
|
* option this table doesn't recognise, or a valued option missing its
|
||||||
|
* operand. */
|
||||||
|
function consumeOptions(opts: OptionTable, words: string[], i: number): number | null {
|
||||||
|
for (;;) {
|
||||||
|
const word = words[i];
|
||||||
|
if (word === undefined) return i;
|
||||||
|
if (word === "--") return i + 1;
|
||||||
|
if (word === "-" || !word.startsWith("-")) return i;
|
||||||
|
const eq = word.indexOf("=");
|
||||||
|
if (eq >= 0) {
|
||||||
|
if (opts.valued.has(word.slice(0, eq)) && word.slice(eq + 1).length > 0) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (opts.flags.has(word)) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (opts.valued.has(word)) {
|
||||||
|
const value = words[i + 1];
|
||||||
|
if (value === undefined || value.length === 0 || value.startsWith("-")) return null;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
opts.bundlesShortOptions &&
|
||||||
|
!word.startsWith("--") &&
|
||||||
|
word.length > 2 &&
|
||||||
|
matchesShortCluster(opts, word)
|
||||||
|
) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_OPTIONS: OptionTable = { flags: new Set(), valued: new Set(), bundlesShortOptions: false };
|
||||||
|
|
||||||
|
const ENV_OPTS: OptionTable = {
|
||||||
|
flags: new Set(["-i", "--ignore-environment", "-0", "--null", "-v", "--debug"]),
|
||||||
|
valued: new Set(["-u", "--unset", "-C", "--chdir"]),
|
||||||
|
bundlesShortOptions: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUDO_OPTS: OptionTable = {
|
||||||
|
flags: new Set([
|
||||||
|
"-i",
|
||||||
|
"--login",
|
||||||
|
"-n",
|
||||||
|
"--non-interactive",
|
||||||
|
"-E",
|
||||||
|
"--preserve-env",
|
||||||
|
"-H",
|
||||||
|
"--set-home",
|
||||||
|
"-A",
|
||||||
|
"--askpass",
|
||||||
|
"-b",
|
||||||
|
"--background",
|
||||||
|
"-k",
|
||||||
|
"--reset-timestamp",
|
||||||
|
"-K",
|
||||||
|
"--remove-timestamp",
|
||||||
|
"-S",
|
||||||
|
"--stdin",
|
||||||
|
]),
|
||||||
|
valued: new Set(["-u", "--user", "-g", "--group"]),
|
||||||
|
bundlesShortOptions: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `-v`/`-V` only print what would run — they never execute it — so
|
||||||
|
* resolving through them would claim a program runs that `command` never
|
||||||
|
* launches (#199). Only `-p`, which still executes normally, is safe to
|
||||||
|
* look through. */
|
||||||
|
const COMMAND_BUILTIN_OPTS: OptionTable = {
|
||||||
|
flags: new Set(["-p"]),
|
||||||
|
valued: new Set(),
|
||||||
|
bundlesShortOptions: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The bash *keyword* `time` only ever parses `-p` (POSIX output format);
|
||||||
|
* `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
|
||||||
|
* `/usr/bin/time`, a different program a bare `time` word doesn't
|
||||||
|
* necessarily name. Assuming them here would be guessing which `time`
|
||||||
|
* resolves (#199). */
|
||||||
|
function resolveTime(words: string[], i: number): number | null {
|
||||||
|
let next = i + 1;
|
||||||
|
if (words[next] === "-p") next += 1;
|
||||||
|
if (words[next] === "--") next += 1;
|
||||||
|
const command = words[next];
|
||||||
|
return command?.startsWith("-") ? null : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `-c`/`--call` embeds a whole shell command string rather than naming the
|
||||||
|
* program positionally — resolving past it would mean guessing at a
|
||||||
|
* program buried inside that string, so it's left unrecognised and
|
||||||
|
* resolution stops at `npx` (#199). */
|
||||||
|
const NPX_OPTS: OptionTable = {
|
||||||
|
flags: new Set(["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"]),
|
||||||
|
valued: new Set(["-p", "--package", "--shell"]),
|
||||||
|
bundlesShortOptions: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PNPM_OPTS: OptionTable = {
|
||||||
|
flags: new Set(["--silent", "-s", "--stream", "--aggregate-output", "--no-progress"]),
|
||||||
|
// `--package`/`--package=value` is documented in this pre-`dlx` position
|
||||||
|
// (`pnpm --package=x dlx y`), alongside the identical flag `dlx` itself
|
||||||
|
// also accepts (`PNPM_DLX_OPTS`).
|
||||||
|
valued: new Set(["-C", "--dir", "--filter", "--package"]),
|
||||||
|
bundlesShortOptions: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const YARN_OPTS: OptionTable = {
|
||||||
|
flags: new Set(["--silent", "-s", "--no-progress", "--non-interactive"]),
|
||||||
|
valued: new Set(["--cwd"]),
|
||||||
|
bundlesShortOptions: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PNPM_DLX_OPTS: OptionTable = {
|
||||||
|
flags: new Set(["-s", "--silent"]),
|
||||||
|
valued: new Set(["--package"]),
|
||||||
|
bundlesShortOptions: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const YARN_DLX_OPTS: OptionTable = {
|
||||||
|
flags: new Set(["-q", "--quiet"]),
|
||||||
|
valued: new Set(["-p", "--package"]),
|
||||||
|
bundlesShortOptions: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUN_X_OPTS: OptionTable = {
|
||||||
|
flags: new Set(["--bun", "--no-install", "--verbose", "--silent"]),
|
||||||
|
valued: new Set(["-p", "--package"]),
|
||||||
|
bundlesShortOptions: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
|
||||||
|
* — without it, the next word is a `package.json` script name, not a
|
||||||
|
* package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
|
||||||
|
* itself takes launcher-specific options too, consumed before the executable. */
|
||||||
|
function resolveDlx(
|
||||||
|
opts: OptionTable,
|
||||||
|
dlxOpts: OptionTable,
|
||||||
|
words: string[],
|
||||||
|
i: number,
|
||||||
|
): number | null {
|
||||||
|
const j = consumeOptions(opts, words, i + 1);
|
||||||
|
if (j === null) return null;
|
||||||
|
if (words[j] !== "dlx") return null;
|
||||||
|
return consumeOptions(dlxOpts, words, j + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
|
||||||
|
* (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
|
||||||
|
* subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
|
||||||
|
* the executable. */
|
||||||
|
function resolveBun(words: string[], i: number): number | null {
|
||||||
|
if (words[i + 1] !== "x") return null;
|
||||||
|
return consumeOptions(BUN_X_OPTS, words, i + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolver for one wrapper form: given the tokenized command and the
|
||||||
|
* index of the wrapper word itself, the index of the word after it (past
|
||||||
|
* its options and any subcommand it requires), or `null` if this occurrence
|
||||||
|
* isn't a form we resolve through. */
|
||||||
|
type WrapperResolver = (words: string[], i: number) => number | null;
|
||||||
|
|
||||||
|
/** One recognised wrapper form: how to walk past it, and whether an
|
||||||
|
* assignment word is a valid follow-on once it's been walked past. */
|
||||||
|
interface Wrapper {
|
||||||
|
resolve: WrapperResolver;
|
||||||
|
/** `true` only for wrappers that themselves interpret a following
|
||||||
|
* `FOO=bar` as an environment assignment for the command they launch
|
||||||
|
* (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
|
||||||
|
* grammar for a brand new simple command (`time`). Every other wrapper here
|
||||||
|
* is a plain executable that execs its next argument as the program
|
||||||
|
* outright — an assignment-shaped one names a program literally called
|
||||||
|
* `FOO=1`, not an env var (#199). */
|
||||||
|
acceptsAssignment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRAPPER_RESOLVERS: Record<string, Wrapper> = {
|
||||||
|
env: { resolve: (words, i) => consumeOptions(ENV_OPTS, words, i + 1), acceptsAssignment: true },
|
||||||
|
sudo: {
|
||||||
|
resolve: (words, i) => consumeOptions(SUDO_OPTS, words, i + 1),
|
||||||
|
acceptsAssignment: true,
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
resolve: (words, i) => consumeOptions(COMMAND_BUILTIN_OPTS, words, i + 1),
|
||||||
|
acceptsAssignment: false,
|
||||||
|
},
|
||||||
|
nohup: {
|
||||||
|
resolve: (words, i) => consumeOptions(NO_OPTIONS, words, i + 1),
|
||||||
|
acceptsAssignment: false,
|
||||||
|
},
|
||||||
|
time: { resolve: resolveTime, acceptsAssignment: true },
|
||||||
|
npx: {
|
||||||
|
resolve: (words, i) => consumeOptions(NPX_OPTS, words, i + 1),
|
||||||
|
acceptsAssignment: false,
|
||||||
|
},
|
||||||
|
pnpm: {
|
||||||
|
resolve: (words, i) => resolveDlx(PNPM_OPTS, PNPM_DLX_OPTS, words, i),
|
||||||
|
acceptsAssignment: false,
|
||||||
|
},
|
||||||
|
yarn: {
|
||||||
|
resolve: (words, i) => resolveDlx(YARN_OPTS, YARN_DLX_OPTS, words, i),
|
||||||
|
acceptsAssignment: false,
|
||||||
|
},
|
||||||
|
bun: { resolve: resolveBun, acceptsAssignment: false },
|
||||||
|
bunx: {
|
||||||
|
resolve: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
|
||||||
|
acceptsAssignment: false,
|
||||||
|
},
|
||||||
|
uvx: { resolve: (_words, i) => i + 1, acceptsAssignment: false },
|
||||||
|
};
|
||||||
|
|
||||||
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
||||||
function burst(): string {
|
function burst(): string {
|
||||||
const rays = Array.from({ length: 8 }, (_, k) => {
|
const rays = Array.from({ length: 8 }, (_, k) => {
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ export interface CreateSessionRequest {
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
worktree?: WorktreeSpec;
|
worktree?: WorktreeSpec;
|
||||||
|
view?: SessionView;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CatalogEvent =
|
export type CatalogEvent =
|
||||||
|
|||||||
Reference in New Issue
Block a user