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 | ||
|
|
25344abe4f | ||
|
|
b52a9085e9 | ||
|
|
8bd943a8ca | ||
|
|
b86335ddf1 | ||
|
|
c348381c5f | ||
|
|
dabdf9fcba | ||
|
|
b76dc06a09 | ||
|
|
23fbfdc5b8 | ||
|
|
ff34727e3c | ||
|
|
e9ede49256 | ||
|
|
8c97ef0616 | ||
|
|
9533672e09 | ||
|
|
b11784e8e3 | ||
|
|
42ffc1a352 | ||
|
|
a50f098c8a | ||
|
|
32d4058c96 | ||
|
|
74a423567f | ||
|
|
b225fbdd69 | ||
|
|
244e8d2927 | ||
|
|
4a1a10c49a | ||
|
|
d607740fea | ||
|
|
0b34d1608f | ||
|
|
075dea509a | ||
|
|
c067f48a24 | ||
|
|
51f96cfb98 | ||
|
|
8f5d96a95b | ||
|
|
3e98b3ab24 | ||
|
|
24ed1ac002 | ||
|
|
64741a027e | ||
|
|
cdd4213ff5 | ||
|
|
f8973b3802 | ||
|
|
1e2c0f0a38 | ||
|
|
6fa7dc63e4 | ||
|
|
5c0a4c4640 | ||
|
|
8773671afd | ||
|
|
d9815d86c6 | ||
|
|
13009d0f0e |
@@ -92,6 +92,12 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
|
|||||||
the two matchers must agree on, and the fixture table they are tested against.
|
the two matchers must agree on, and the fixture table they are tested against.
|
||||||
Read it before changing either bar or either matcher.
|
Read it before changing either bar or either matcher.
|
||||||
|
|
||||||
|
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
|
||||||
|
run as a daemon child over ACP instead of in a tmux pane) — the eight
|
||||||
|
transcript row kinds, the two projections and their cursor/replay semantics,
|
||||||
|
the handshake and declined capabilities, and the approval nonce rules. Read
|
||||||
|
it before changing anything that renders a structured session.
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
Generated
+1
@@ -1644,6 +1644,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.21.0",
|
"tokio-tungstenite 0.21.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"unicode-width",
|
||||||
"uuid",
|
"uuid",
|
||||||
"vt100",
|
"vt100",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ tokio-util = { version = "0.7", features = ["compat"] }
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
unicode-width = "0.1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
ratatui = "0.28"
|
ratatui = "0.28"
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
|
|||||||
+100
-11
@@ -113,6 +113,15 @@ impl SpawnConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A blocked ACP permission request delivered to the daemon.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PermissionRequest {
|
||||||
|
/// The prompt turn that was active when ACP delivered this callback.
|
||||||
|
pub turn: u64,
|
||||||
|
pub request: RequestPermissionRequest,
|
||||||
|
pub response: oneshot::Sender<RequestPermissionOutcome>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AcpError {
|
pub enum AcpError {
|
||||||
/// The adapter is not on `PATH`. The message names the install command
|
/// The adapter is not on `PATH`. The message names the install command
|
||||||
@@ -156,6 +165,7 @@ enum Command {
|
|||||||
Cancel,
|
Cancel,
|
||||||
Prompt {
|
Prompt {
|
||||||
text: String,
|
text: String,
|
||||||
|
turn: u64,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<Result<(), String>>,
|
||||||
},
|
},
|
||||||
Close,
|
Close,
|
||||||
@@ -166,6 +176,31 @@ enum ChildCommand {
|
|||||||
Reap(oneshot::Sender<bool>),
|
Reap(oneshot::Sender<bool>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct PermissionTurnState {
|
||||||
|
active: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionTurnState {
|
||||||
|
fn begin(&mut self, turn: u64) {
|
||||||
|
self.active = Some(turn);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_active(&mut self) {
|
||||||
|
self.active = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, turn: u64) {
|
||||||
|
if self.active == Some(turn) {
|
||||||
|
self.active = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn callback_turn(&self) -> Option<u64> {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Why the handshake did not produce a session id.
|
/// Why the handshake did not produce a session id.
|
||||||
enum ReadyError {
|
enum ReadyError {
|
||||||
/// The adapter answered with an error.
|
/// The adapter answered with an error.
|
||||||
@@ -192,6 +227,7 @@ pub struct AcpSession {
|
|||||||
commands: mpsc::UnboundedSender<Command>,
|
commands: mpsc::UnboundedSender<Command>,
|
||||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||||
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
|
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
|
||||||
|
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||||
exit: watch::Receiver<Option<ExitStatus>>,
|
exit: watch::Receiver<Option<ExitStatus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +271,13 @@ impl AcpSession {
|
|||||||
self.updates.lock().expect("updates mutex").take()
|
self.updates.lock().expect("updates mutex").take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn take_permission_requests(&self) -> Option<mpsc::UnboundedReceiver<PermissionRequest>> {
|
||||||
|
self.permission_requests
|
||||||
|
.lock()
|
||||||
|
.expect("permission requests")
|
||||||
|
.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
|
||||||
/// child is already gone, which is the outcome cancel was after.
|
/// child is already gone, which is the outcome cancel was after.
|
||||||
pub fn cancel(&self) {
|
pub fn cancel(&self) {
|
||||||
@@ -242,11 +285,16 @@ impl AcpSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send a user prompt and receive completion once the agent ends the turn.
|
/// Send a user prompt and receive completion once the agent ends the turn.
|
||||||
pub fn prompt(&self, text: String) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
pub fn prompt(
|
||||||
|
&self,
|
||||||
|
text: String,
|
||||||
|
turn: u64,
|
||||||
|
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
||||||
let (done_tx, done_rx) = oneshot::channel();
|
let (done_tx, done_rx) = oneshot::channel();
|
||||||
self.commands
|
self.commands
|
||||||
.send(Command::Prompt {
|
.send(Command::Prompt {
|
||||||
text,
|
text,
|
||||||
|
turn,
|
||||||
done: done_tx,
|
done: done_tx,
|
||||||
})
|
})
|
||||||
.map_err(|_| "ACP connection is closed".to_string())?;
|
.map_err(|_| "ACP connection is closed".to_string())?;
|
||||||
@@ -335,6 +383,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
|
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
|
||||||
|
|
||||||
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
||||||
|
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
||||||
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
|
||||||
let (ready_tx, ready_rx) = oneshot::channel();
|
let (ready_tx, ready_rx) = oneshot::channel();
|
||||||
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||||
@@ -343,6 +392,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
config.cwd.clone(),
|
config.cwd.clone(),
|
||||||
config.load_session.clone(),
|
config.load_session.clone(),
|
||||||
updates_tx,
|
updates_tx,
|
||||||
|
permission_tx,
|
||||||
commands_rx,
|
commands_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
));
|
));
|
||||||
@@ -356,6 +406,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
|||||||
commands: commands_tx,
|
commands: commands_tx,
|
||||||
child_commands: child_commands_tx,
|
child_commands: child_commands_tx,
|
||||||
updates: Mutex::new(Some(updates_rx)),
|
updates: Mutex::new(Some(updates_rx)),
|
||||||
|
permission_requests: Mutex::new(Some(permission_rx)),
|
||||||
exit: exit_rx,
|
exit: exit_rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -467,6 +518,7 @@ async fn run_connection<T>(
|
|||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
load_session: Option<String>,
|
load_session: Option<String>,
|
||||||
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
||||||
|
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||||
) where
|
) where
|
||||||
@@ -478,6 +530,10 @@ async fn run_connection<T>(
|
|||||||
// history the event log already holds (#135).
|
// history the event log already holds (#135).
|
||||||
let replaying = Arc::new(AtomicBool::new(false));
|
let replaying = Arc::new(AtomicBool::new(false));
|
||||||
let replaying_seen = replaying.clone();
|
let replaying_seen = replaying.clone();
|
||||||
|
// ACP callbacks have no turn id. The daemon never queues a later prompt
|
||||||
|
// until a cancelled prompt settles, so this active turn is unambiguous.
|
||||||
|
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
|
||||||
|
let permission_turn_seen = permission_turn.clone();
|
||||||
let result = Client
|
let result = Client
|
||||||
.builder()
|
.builder()
|
||||||
.name("maestro")
|
.name("maestro")
|
||||||
@@ -495,15 +551,30 @@ async fn run_connection<T>(
|
|||||||
)
|
)
|
||||||
.on_receive_request(
|
.on_receive_request(
|
||||||
async move |request: RequestPermissionRequest, responder, _cx| {
|
async move |request: RequestPermissionRequest, responder, _cx| {
|
||||||
// Stub until #139: decline rather than grant something the
|
let turn = permission_turn_seen
|
||||||
// operator never saw.
|
.lock()
|
||||||
tracing::warn!(
|
.expect("permission turn state")
|
||||||
session = %request.session_id,
|
.callback_turn();
|
||||||
"session/request_permission declined: not implemented yet (#139)"
|
let Some(turn) = turn else {
|
||||||
);
|
return responder.respond(RequestPermissionResponse::new(
|
||||||
responder.respond(RequestPermissionResponse::new(
|
RequestPermissionOutcome::Cancelled,
|
||||||
RequestPermissionOutcome::Cancelled,
|
));
|
||||||
))
|
};
|
||||||
|
let (response, received) = oneshot::channel();
|
||||||
|
if permission_requests
|
||||||
|
.send(PermissionRequest {
|
||||||
|
turn,
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return responder.respond(RequestPermissionResponse::new(
|
||||||
|
RequestPermissionOutcome::Cancelled,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let outcome = received.await.unwrap_or(RequestPermissionOutcome::Cancelled);
|
||||||
|
responder.respond(RequestPermissionResponse::new(outcome))
|
||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_request!(),
|
agent_client_protocol::on_receive_request!(),
|
||||||
)
|
)
|
||||||
@@ -575,26 +646,44 @@ async fn run_connection<T>(
|
|||||||
tokio::select! {
|
tokio::select! {
|
||||||
cmd = commands_rx.recv() => match cmd {
|
cmd = commands_rx.recv() => match cmd {
|
||||||
Some(Command::Cancel) => {
|
Some(Command::Cancel) => {
|
||||||
|
permission_turn
|
||||||
|
.lock()
|
||||||
|
.expect("permission turn state")
|
||||||
|
.cancel_active();
|
||||||
let _ = cx.send_notification(CancelNotification::new(
|
let _ = cx.send_notification(CancelNotification::new(
|
||||||
SessionId::new(session_id.as_str()),
|
SessionId::new(session_id.as_str()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some(Command::Prompt { text, done }) => {
|
Some(Command::Prompt { text, turn, done }) => {
|
||||||
|
permission_turn
|
||||||
|
.lock()
|
||||||
|
.expect("permission turn state")
|
||||||
|
.begin(turn);
|
||||||
let request = PromptRequest::new(
|
let request = PromptRequest::new(
|
||||||
SessionId::new(session_id.as_str()),
|
SessionId::new(session_id.as_str()),
|
||||||
vec![ContentBlock::from(text)],
|
vec![ContentBlock::from(text)],
|
||||||
);
|
);
|
||||||
let done = Arc::new(Mutex::new(Some(done)));
|
let done = Arc::new(Mutex::new(Some(done)));
|
||||||
let callback_done = done.clone();
|
let callback_done = done.clone();
|
||||||
|
let callback_turn = permission_turn.clone();
|
||||||
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
||||||
let callback_done = callback_done.clone();
|
let callback_done = callback_done.clone();
|
||||||
|
let callback_turn = callback_turn.clone();
|
||||||
async move {
|
async move {
|
||||||
|
callback_turn
|
||||||
|
.lock()
|
||||||
|
.expect("permission turn state")
|
||||||
|
.finish(turn);
|
||||||
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
||||||
let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string()));
|
let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string()));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
|
permission_turn
|
||||||
|
.lock()
|
||||||
|
.expect("permission turn state")
|
||||||
|
.finish(turn);
|
||||||
if let Some(done) = done.lock().expect("prompt sender").take() {
|
if let Some(done) = done.lock().expect("prompt sender").take() {
|
||||||
let _ = done.send(Err(error.to_string()));
|
let _ = done.send(Err(error.to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ 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)
|
||||||
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
|
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
|
||||||
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
|
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
|
||||||
|
FAKE_ACP_CANCEL_SETTLES=1 complete the outstanding prompt on cancel
|
||||||
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
|
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
|
||||||
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
|
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
|
||||||
notifications ("replayed <i>") before answering,
|
notifications ("replayed <i>") before answering,
|
||||||
@@ -33,6 +36,8 @@ Environment knobs, all optional:
|
|||||||
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
|
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
|
||||||
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
|
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
|
||||||
JSON-RPC error
|
JSON-RPC error
|
||||||
|
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
|
||||||
|
callback until a later prompt
|
||||||
|
|
||||||
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
|
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
|
||||||
counting from 1) and logs `emit tick <n>`, so a test can make the agent
|
counting from 1) and logs `emit tick <n>`, so a test can make the agent
|
||||||
@@ -70,6 +75,9 @@ signal.signal(signal.SIGTERM, on_sigterm)
|
|||||||
session_id = f"fake-{os.getpid()}"
|
session_id = f"fake-{os.getpid()}"
|
||||||
ticks = 0
|
ticks = 0
|
||||||
prompt_failures = 0
|
prompt_failures = 0
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
|
delayed_permission = False
|
||||||
|
|
||||||
|
|
||||||
def chunk(text):
|
def chunk(text):
|
||||||
@@ -102,6 +110,12 @@ for raw in sys.stdin:
|
|||||||
method = msg.get("method")
|
method = msg.get("method")
|
||||||
if method is None:
|
if method is None:
|
||||||
# A response to something we sent; nothing to do.
|
# A response to something we sent; nothing to do.
|
||||||
|
if msg.get("id") == pending_permission_id:
|
||||||
|
log("permission response " + json.dumps(msg.get("result"), sort_keys=True))
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
||||||
|
"result": {"stopReason": "end_turn"}})
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
continue
|
continue
|
||||||
log(f"recv {method}")
|
log(f"recv {method}")
|
||||||
rid = msg.get("id")
|
rid = msg.get("id")
|
||||||
@@ -121,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"))
|
||||||
@@ -140,9 +157,47 @@ for raw in sys.stdin:
|
|||||||
elif method == "session/cancel":
|
elif method == "session/cancel":
|
||||||
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
||||||
send(chunk("final on cancel"))
|
send(chunk("final on cancel"))
|
||||||
|
if (os.environ.get("FAKE_ACP_CANCEL_SETTLES") == "1"
|
||||||
|
and pending_prompt_id is not None):
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
||||||
|
"result": {"stopReason": "cancelled"}})
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
|
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif method == "session/prompt":
|
elif method == "session/prompt":
|
||||||
|
if os.environ.get("FAKE_ACP_PERMISSION_AFTER_CANCEL") == "1":
|
||||||
|
if pending_prompt_id is None:
|
||||||
|
pending_prompt_id = rid
|
||||||
|
delayed_permission = True
|
||||||
|
continue
|
||||||
|
if delayed_permission:
|
||||||
|
delayed_permission = False
|
||||||
|
pending_permission_id = "permission-1"
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
||||||
|
"method": "session/request_permission", "params": {
|
||||||
|
"sessionId": session_id,
|
||||||
|
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
||||||
|
"options": [
|
||||||
|
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
||||||
|
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
||||||
|
]
|
||||||
|
}})
|
||||||
|
continue
|
||||||
|
if os.environ.get("FAKE_ACP_REQUEST_PERMISSION") == "1":
|
||||||
|
pending_prompt_id = rid
|
||||||
|
pending_permission_id = "permission-1"
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
||||||
|
"method": "session/request_permission", "params": {
|
||||||
|
"sessionId": session_id,
|
||||||
|
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
||||||
|
"options": [
|
||||||
|
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
||||||
|
{"optionId": "session", "name": "Allow session", "kind": "allow_always"},
|
||||||
|
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
||||||
|
]
|
||||||
|
}})
|
||||||
|
continue
|
||||||
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
|
||||||
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
|
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
|
||||||
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
|
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a structured session's sidebar state from its ACP projection.
|
||||||
|
pub fn decide_structured(
|
||||||
|
turn_active: bool,
|
||||||
|
pending_approval: bool,
|
||||||
|
turn_failed: bool,
|
||||||
|
) -> ActivityState {
|
||||||
|
if turn_failed {
|
||||||
|
ActivityState::Error
|
||||||
|
} else if pending_approval {
|
||||||
|
ActivityState::AwaitingInput
|
||||||
|
} else if turn_active {
|
||||||
|
ActivityState::Active
|
||||||
|
} else {
|
||||||
|
ActivityState::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How long a session's pane must be continuously focused (a client's pane,
|
/// How long a session's pane must be continuously focused (a client's pane,
|
||||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||||
/// operator has looked, so the rail stops asking for attention.
|
/// operator has looked, so the rail stops asking for attention.
|
||||||
@@ -136,6 +153,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
||||||
|
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
||||||
|
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(true, true, false),
|
||||||
|
ActivityState::AwaitingInput
|
||||||
|
);
|
||||||
|
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn report_is_in_force_until_exactly_its_expiry() {
|
fn report_is_in_force_until_exactly_its_expiry() {
|
||||||
let ttl = Duration::from_secs(300);
|
let ttl = Duration::from_secs(300);
|
||||||
|
|||||||
@@ -21,13 +21,12 @@ use maestro_config::SpawnPreset;
|
|||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||||
StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
|
StructuredApprovalDecision, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::projection::TurnLifecycle;
|
use crate::session::{QueueAcpPrompt, SessionError, SessionManager};
|
||||||
use crate::session::SessionManager;
|
|
||||||
use crate::sidecar::EventCursor;
|
use crate::sidecar::EventCursor;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -70,7 +69,11 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
||||||
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel));
|
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_APPROVAL,
|
||||||
|
post(structured_approval),
|
||||||
|
);
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
{
|
{
|
||||||
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
||||||
@@ -176,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,
|
||||||
}
|
}
|
||||||
@@ -304,11 +308,10 @@ async fn rename_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal
|
||||||
/// The report holds above the daemon's inference for a TTL, then inference
|
/// session's state. Structured sessions acknowledge and ignore these reports
|
||||||
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
/// because their activity comes from ACP. Unknown session is 404; an unknown
|
||||||
/// forever. Unknown session is 404; an unknown state value is rejected by the
|
/// state value is rejected by the JSON decoder (422). Answers `204 No Content`.
|
||||||
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
|
|
||||||
async fn report_activity(
|
async fn report_activity(
|
||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
@@ -432,45 +435,24 @@ async fn structured_prompt(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<StructuredPromptRequest>,
|
Json(body): Json<StructuredPromptRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let (acp, turn) = match s.manager.begin_acp_turn(id).await {
|
match s.manager.queue_acp_prompt(id, body.prompt).await {
|
||||||
Ok(crate::session::BeginAcpTurn::Started(acp, turn)) => (acp, turn),
|
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Ok(crate::session::BeginAcpTurn::InFlight) => {
|
Ok(QueueAcpPrompt::InFlight) => error_response(
|
||||||
return error_response(
|
StatusCode::CONFLICT,
|
||||||
StatusCode::CONFLICT,
|
"turn_in_flight",
|
||||||
"turn_in_flight",
|
"the structured session already has an active turn",
|
||||||
"the structured session already has an active turn",
|
),
|
||||||
)
|
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
||||||
}
|
StatusCode::CONFLICT,
|
||||||
Ok(crate::session::BeginAcpTurn::Unavailable) => {
|
"not_structured",
|
||||||
return error_response(
|
"session is not a live structured session",
|
||||||
StatusCode::CONFLICT,
|
),
|
||||||
"not_structured",
|
Ok(QueueAcpPrompt::Failed(message)) => {
|
||||||
"session is not a live structured session",
|
error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let info = e.to_error_info();
|
let info = e.to_error_info();
|
||||||
return error_response(session_error_status(&info.code), &info.code, &info.message);
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
}
|
|
||||||
};
|
|
||||||
match acp.prompt(body.prompt) {
|
|
||||||
Ok(done) => {
|
|
||||||
let manager = s.manager.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let lifecycle = match done.await {
|
|
||||||
Ok(Ok(())) => TurnLifecycle::Finish,
|
|
||||||
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
|
|
||||||
};
|
|
||||||
let _ = manager.apply_acp_turn_lifecycle(id, lifecycle, turn).await;
|
|
||||||
});
|
|
||||||
StatusCode::NO_CONTENT.into_response()
|
|
||||||
}
|
|
||||||
Err(message) => {
|
|
||||||
let _ = s
|
|
||||||
.manager
|
|
||||||
.apply_acp_turn_lifecycle(id, TurnLifecycle::Failure, turn)
|
|
||||||
.await;
|
|
||||||
error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,6 +472,32 @@ async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn structured_approval(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path((id, nonce)): Path<(Uuid, String)>,
|
||||||
|
Json(decision): Json<StructuredApprovalDecision>,
|
||||||
|
) -> Response {
|
||||||
|
let decision = match decision {
|
||||||
|
StructuredApprovalDecision::AllowOnce => crate::session::ApprovalDecision::AllowOnce,
|
||||||
|
StructuredApprovalDecision::AllowForSession => {
|
||||||
|
crate::session::ApprovalDecision::AllowForSession
|
||||||
|
}
|
||||||
|
StructuredApprovalDecision::Deny => crate::session::ApprovalDecision::Deny,
|
||||||
|
};
|
||||||
|
match s.manager.resolve_acp_approval(id, &nonce, decision).await {
|
||||||
|
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||||
/// scanner starts with its cache cold; wire
|
/// scanner starts with its cache cold; wire
|
||||||
@@ -527,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 {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use std::collections::{HashMap, HashSet};
|
|||||||
|
|
||||||
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
|
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, TranscriptSnapshot,
|
ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind,
|
||||||
|
TranscriptSnapshot,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ pub struct Projection {
|
|||||||
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
|
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
|
||||||
state: ReducedState,
|
state: ReducedState,
|
||||||
active_turn: Option<u64>,
|
active_turn: Option<u64>,
|
||||||
|
turn_failed: bool,
|
||||||
next_turn: u64,
|
next_turn: u64,
|
||||||
turn_tool_calls: HashSet<String>,
|
turn_tool_calls: HashSet<String>,
|
||||||
}
|
}
|
||||||
@@ -109,11 +111,21 @@ impl Projection {
|
|||||||
self.active_turn.is_some()
|
self.active_turn.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sidebar activity derived from the live turn projection.
|
||||||
|
pub fn activity(&self) -> ActivityState {
|
||||||
|
crate::activity::decide_structured(
|
||||||
|
self.active_turn.is_some(),
|
||||||
|
self.state.pending_approval.is_some(),
|
||||||
|
self.turn_failed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Start a prompt turn and return its monotonically increasing token.
|
/// Start a prompt turn and return its monotonically increasing token.
|
||||||
pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) {
|
pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) {
|
||||||
self.next_turn += 1;
|
self.next_turn += 1;
|
||||||
let turn = self.next_turn;
|
let turn = self.next_turn;
|
||||||
self.active_turn = Some(turn);
|
self.active_turn = Some(turn);
|
||||||
|
self.turn_failed = false;
|
||||||
self.state.turn.responding = true;
|
self.state.turn.responding = true;
|
||||||
self.state.turn.thinking = true;
|
self.state.turn.thinking = true;
|
||||||
self.turn_tool_calls.clear();
|
self.turn_tool_calls.clear();
|
||||||
@@ -141,6 +153,7 @@ impl Projection {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.active_turn = None;
|
self.active_turn = None;
|
||||||
|
self.turn_failed = lifecycle == TurnLifecycle::Failure;
|
||||||
self.state.turn.responding = false;
|
self.state.turn.responding = false;
|
||||||
self.state.turn.thinking = false;
|
self.state.turn.thinking = false;
|
||||||
self.turn_tool_calls.clear();
|
self.turn_tool_calls.clear();
|
||||||
@@ -155,6 +168,76 @@ impl Projection {
|
|||||||
self.active_turn
|
self.active_turn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a daemon-originated permission row. The nonce belongs only in the
|
||||||
|
/// reduced state, which is the client control surface, never in ACP data.
|
||||||
|
pub fn request_approval(&mut self, nonce: String, request: Value) -> ProjectionUpdate {
|
||||||
|
let row = self.append(
|
||||||
|
format!("approval:{}", uuid::Uuid::new_v4()),
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
request.clone(),
|
||||||
|
);
|
||||||
|
self.state.pending_approval = Some(serde_json::json!({
|
||||||
|
"nonce": nonce,
|
||||||
|
"request": request,
|
||||||
|
}));
|
||||||
|
ProjectionUpdate {
|
||||||
|
transcript: Some(row),
|
||||||
|
state: self.state(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_approval(&mut self, row: TranscriptRow) -> ProjectionUpdate {
|
||||||
|
let transcript = if !self.row_index.contains_key(&row.id) {
|
||||||
|
self.row_index.insert(row.id.clone(), self.rows.len());
|
||||||
|
self.rows.push(row.clone());
|
||||||
|
Some(TranscriptDelta::Append { row })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
ProjectionUpdate {
|
||||||
|
transcript,
|
||||||
|
state: self.state(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear an approval only when it is still the one the client answered.
|
||||||
|
pub fn resolve_approval(&mut self, nonce: &str) -> Option<ProjectionUpdate> {
|
||||||
|
let pending = self.state.pending_approval.as_ref()?;
|
||||||
|
if pending.get("nonce").and_then(Value::as_str) != Some(nonce) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.state.pending_approval = None;
|
||||||
|
Some(ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: self.state(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal turn paths must not leave a recovered snapshot permanently
|
||||||
|
/// blocked on an ACP request whose transport no longer exists.
|
||||||
|
pub fn clear_approval(&mut self) -> Option<ProjectionUpdate> {
|
||||||
|
self.state.pending_approval.take()?;
|
||||||
|
Some(ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: self.state(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo an `append`ed row that a caller decides never happened — used
|
||||||
|
/// when persisting the row's own event fails (#184), so a snapshot taken
|
||||||
|
/// after the failure cannot show a row durable replay would never
|
||||||
|
/// reproduce. Only ever called for a row that was just appended and
|
||||||
|
/// never patched, so removing it by id fully reverses `append`.
|
||||||
|
pub fn rollback_row(&mut self, id: &str) {
|
||||||
|
let Some(index) = self.row_index.remove(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.rows.remove(index);
|
||||||
|
for (shifted_index, row) in self.rows.iter().enumerate().skip(index) {
|
||||||
|
self.row_index.insert(row.id.clone(), shifted_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn content(
|
fn content(
|
||||||
&mut self,
|
&mut self,
|
||||||
seq: u64,
|
seq: u64,
|
||||||
@@ -355,6 +438,16 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_session_update_tag_the_acp_dependency_does_not_know_fails_to_deserialize() {
|
||||||
|
// `SessionUpdate` has no unknown-variant fallback, so this never reaches `Projection::apply`.
|
||||||
|
let result: Result<SessionNotification, _> = serde_json::from_value(serde_json::json!({
|
||||||
|
"sessionId": "acp-1",
|
||||||
|
"update": {"sessionUpdate": "some_future_update_variant"},
|
||||||
|
}));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn folds_content_tool_diff_plan_and_notice_rows() {
|
fn folds_content_tool_diff_plan_and_notice_rows() {
|
||||||
let mut projection = Projection::default();
|
let mut projection = Projection::default();
|
||||||
@@ -632,4 +725,136 @@ mod tests {
|
|||||||
fn failed_turn_does_not_reactivate_unfinished_tools() {
|
fn failed_turn_does_not_reactivate_unfinished_tools() {
|
||||||
terminal_turn_drops_unfinished_tools(TurnLifecycle::Failure, "failed-1", "next-1");
|
terminal_turn_drops_unfinished_tools(TurnLifecycle::Failure, "failed-1", "next-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_history_survives_pending_cleanup() {
|
||||||
|
let mut projection = Projection::default();
|
||||||
|
let update =
|
||||||
|
projection.request_approval("secret".into(), serde_json::json!({"tool":"bash"}));
|
||||||
|
let TranscriptDelta::Append { row } = update.transcript.unwrap() else {
|
||||||
|
panic!("approval row")
|
||||||
|
};
|
||||||
|
assert_eq!(row.kind, TranscriptRowKind::Approval);
|
||||||
|
assert!(row.data.get("nonce").is_none());
|
||||||
|
assert!(!row.id.contains("secret"));
|
||||||
|
let persisted =
|
||||||
|
serde_json::to_string(&crate::sidecar::PersistedEvent::Approval { row: row.clone() })
|
||||||
|
.unwrap();
|
||||||
|
assert!(!persisted.contains("secret"));
|
||||||
|
assert_eq!(
|
||||||
|
projection.state().pending_approval.unwrap()["nonce"],
|
||||||
|
"secret"
|
||||||
|
);
|
||||||
|
projection.clear_approval();
|
||||||
|
assert!(projection.state().pending_approval.is_none());
|
||||||
|
assert_eq!(projection.snapshot(0).rows, vec![row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollback_row_removes_only_that_row_and_keeps_others_addressable() {
|
||||||
|
let mut projection = Projection::default();
|
||||||
|
projection.apply(
|
||||||
|
1,
|
||||||
|
&event(serde_json::json!({
|
||||||
|
"sessionUpdate": "user_message_chunk",
|
||||||
|
"messageId": "m1",
|
||||||
|
"content": {"type": "text", "text": "hi"},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let TranscriptDelta::Append { row } = projection
|
||||||
|
.request_approval("nonce".into(), serde_json::json!({"tool": "bash"}))
|
||||||
|
.transcript
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("approval row")
|
||||||
|
};
|
||||||
|
// A row after the one being rolled back, so removing the approval
|
||||||
|
// (index 1) shifts this one from index 2 down to index 1 — proving
|
||||||
|
// `row_index` gets reindexed, not just `rows`.
|
||||||
|
projection.apply(
|
||||||
|
2,
|
||||||
|
&event(serde_json::json!({
|
||||||
|
"sessionUpdate": "user_message_chunk",
|
||||||
|
"messageId": "m2",
|
||||||
|
"content": {"type": "text", "text": "hey"},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
assert_eq!(projection.snapshot(0).rows.len(), 3);
|
||||||
|
|
||||||
|
projection.rollback_row(&row.id);
|
||||||
|
|
||||||
|
assert_eq!(projection.snapshot(0).rows.len(), 2);
|
||||||
|
assert_eq!(projection.snapshot(0).rows[0].kind, TranscriptRowKind::User);
|
||||||
|
assert_eq!(projection.snapshot(0).rows[1].kind, TranscriptRowKind::User);
|
||||||
|
|
||||||
|
// The shifted row must still be independently patchable at its new
|
||||||
|
// index.
|
||||||
|
let update = projection.apply(
|
||||||
|
3,
|
||||||
|
&event(serde_json::json!({
|
||||||
|
"sessionUpdate": "user_message_chunk",
|
||||||
|
"messageId": "m2",
|
||||||
|
"content": {"type": "text", "text": " there"},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let TranscriptDelta::Patch { row } = update.transcript.unwrap() else {
|
||||||
|
panic!("patched row")
|
||||||
|
};
|
||||||
|
assert_eq!(row.data.pointer("/content/text").unwrap(), "hey there");
|
||||||
|
assert_eq!(projection.snapshot(0).rows.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollback_row_is_a_no_op_for_an_unknown_id() {
|
||||||
|
let mut projection = Projection::default();
|
||||||
|
projection.apply(
|
||||||
|
1,
|
||||||
|
&event(serde_json::json!({
|
||||||
|
"sessionUpdate": "user_message_chunk",
|
||||||
|
"content": {"type": "text", "text": "hi"},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
projection.rollback_row("approval:does-not-exist");
|
||||||
|
assert_eq!(projection.snapshot(0).rows.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovered_approval_is_historical_not_pending() {
|
||||||
|
let mut live = Projection::default();
|
||||||
|
let TranscriptDelta::Append { row } = live
|
||||||
|
.request_approval("daemon-nonce".into(), serde_json::json!({"tool":"bash"}))
|
||||||
|
.transcript
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("approval row")
|
||||||
|
};
|
||||||
|
let mut recovered = Projection::default();
|
||||||
|
let update = recovered.restore_approval(row.clone());
|
||||||
|
assert_eq!(
|
||||||
|
update.transcript,
|
||||||
|
Some(TranscriptDelta::Append { row: row.clone() })
|
||||||
|
);
|
||||||
|
assert!(update.state.pending_approval.is_none());
|
||||||
|
assert_eq!(recovered.snapshot(0).rows, vec![row]);
|
||||||
|
let replay = serde_json::to_string(&recovered.snapshot(1).rows).unwrap();
|
||||||
|
assert!(!replay.contains("daemon-nonce"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_terminal_turn_cannot_clear_newer_approval() {
|
||||||
|
let mut projection = Projection::default();
|
||||||
|
let (first, _) = projection.begin_turn();
|
||||||
|
projection.turn_lifecycle(TurnLifecycle::Cancel, first);
|
||||||
|
let (second, _) = projection.begin_turn();
|
||||||
|
projection.request_approval("second-nonce".into(), serde_json::json!({"tool":"bash"}));
|
||||||
|
|
||||||
|
assert!(projection
|
||||||
|
.turn_lifecycle(TurnLifecycle::Finish, first)
|
||||||
|
.is_none());
|
||||||
|
assert_eq!(projection.active_turn(), Some(second));
|
||||||
|
assert_eq!(
|
||||||
|
projection.state().pending_approval.unwrap()["nonce"],
|
||||||
|
"second-nonce"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1366
-105
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
|||||||
//! advertised but with empty metadata (handled by the caller, not here).
|
//! advertised but with empty metadata (handled by the caller, not here).
|
||||||
//!
|
//!
|
||||||
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
|
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
|
||||||
//! sidecar: every `session/update` notification its adapter sent, one JSON
|
//! sidecar: every `session/update` notification and daemon approval row is one
|
||||||
//! object per line, appended before it is broadcast. The log is the source of
|
//! JSON object, appended before it is broadcast. The log is the source of
|
||||||
//! truth for the transcript; `seq` is the line's 1-based position, so a
|
//! truth for the transcript; `seq` is the line's 1-based position, so a
|
||||||
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
|
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
|
||||||
//! that does not parse — including the partial line a hard kill leaves at
|
//! that does not parse — including the partial line a hard kill leaves at
|
||||||
@@ -21,7 +21,7 @@ use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use maestro_acp::schema::SessionNotification;
|
use maestro_acp::schema::SessionNotification;
|
||||||
use maestro_protocol::Session;
|
use maestro_protocol::{Session, TranscriptRow};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -112,7 +112,7 @@ impl SidecarStore {
|
|||||||
}
|
}
|
||||||
file.seek(SeekFrom::End(0))?;
|
file.seek(SeekFrom::End(0))?;
|
||||||
Ok(EventLog {
|
Ok(EventLog {
|
||||||
file,
|
file: Box::new(file),
|
||||||
last_seq: lines,
|
last_seq: lines,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -150,10 +150,10 @@ impl SidecarStore {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(notification) = serde_json::from_slice::<SessionNotification>(&line) else {
|
let Ok(event) = PersistedEvent::decode(&line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
out.push_back(LoggedEvent { seq, notification });
|
out.push_back(LoggedEvent { seq, event });
|
||||||
match cursor {
|
match cursor {
|
||||||
EventCursor::Since(_) if out.len() >= limit => break,
|
EventCursor::Since(_) if out.len() >= limit => break,
|
||||||
EventCursor::Before(_) if out.len() > limit => {
|
EventCursor::Before(_) if out.len() > limit => {
|
||||||
@@ -190,6 +190,7 @@ impl SidecarStore {
|
|||||||
/// whichever does not exist.
|
/// whichever does not exist.
|
||||||
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
||||||
remove_if_present(&self.events_path_for(id))?;
|
remove_if_present(&self.events_path_for(id))?;
|
||||||
|
remove_if_present(&self.dir.join(format!("{id}.approvals.jsonl")))?;
|
||||||
remove_if_present(&self.recovery_path_for(id))?;
|
remove_if_present(&self.recovery_path_for(id))?;
|
||||||
remove_if_present(&self.path_for(id))
|
remove_if_present(&self.path_for(id))
|
||||||
}
|
}
|
||||||
@@ -247,22 +248,130 @@ pub enum EventCursor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One line of a session's event log.
|
/// One line of a session's event log.
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct LoggedEvent {
|
pub struct LoggedEvent {
|
||||||
/// 1-based line position in the log; the cursor for [`EventCursor`].
|
/// 1-based line position in the log; the cursor for [`EventCursor`].
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub notification: SessionNotification,
|
pub event: PersistedEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single entry in the structured event history.
|
||||||
|
///
|
||||||
|
/// ACP notifications retain their original JSON representation, so existing
|
||||||
|
/// event logs remain readable. Daemon-generated rows use an envelope so they
|
||||||
|
/// can share the same cursor sequence without pretending to be ACP traffic.
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
|
||||||
|
pub enum PersistedEvent {
|
||||||
|
Approval {
|
||||||
|
row: TranscriptRow,
|
||||||
|
},
|
||||||
|
#[serde(skip)]
|
||||||
|
Notification(Box<SessionNotification>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistedEvent {
|
||||||
|
fn decode(line: &[u8]) -> Result<Self, serde_json::Error> {
|
||||||
|
serde_json::from_slice::<SessionNotification>(line)
|
||||||
|
.map(|notification| Self::Notification(Box::new(notification)))
|
||||||
|
.or_else(|_| serde_json::from_slice(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SessionNotification> for PersistedEvent {
|
||||||
|
fn from(notification: SessionNotification) -> Self {
|
||||||
|
Self::Notification(Box::new(notification))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&SessionNotification> for PersistedEvent {
|
||||||
|
fn from(notification: &SessionNotification) -> Self {
|
||||||
|
Self::Notification(Box::new(notification.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&PersistedEvent> for PersistedEvent {
|
||||||
|
fn from(event: &PersistedEvent) -> Self {
|
||||||
|
event.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an [`EventLog`] appends onto: something seekable by length and
|
||||||
|
/// truncatable back to one, so a failed append can be rolled off the end
|
||||||
|
/// again. A real [`File`] in production; an in-memory fault double in tests
|
||||||
|
/// (#184) — the same [`EventLog::append`] rollback logic runs over both, so
|
||||||
|
/// the behavior under test is the production behavior, not a stand-in for
|
||||||
|
/// it.
|
||||||
|
pub(crate) trait LogSink: Write + Send {
|
||||||
|
fn len(&mut self) -> std::io::Result<u64>;
|
||||||
|
fn truncate(&mut self, len: u64) -> std::io::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogSink for File {
|
||||||
|
fn len(&mut self) -> std::io::Result<u64> {
|
||||||
|
Ok(self.metadata()?.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
|
||||||
|
self.set_len(len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a failed [`EventLog::append`].
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppendError {
|
||||||
|
/// The write or flush failed, but the log was rolled back to exactly the
|
||||||
|
/// byte it was at before the call: `last_seq` and the file agree, and
|
||||||
|
/// this same [`EventLog`] is safe to keep appending to. The event itself
|
||||||
|
/// was never persisted or broadcast.
|
||||||
|
#[error("event append failed, rolled back cleanly: {0}")]
|
||||||
|
Failed(#[source] SidecarError),
|
||||||
|
/// The append failed *and* the rollback did not restore the log to its
|
||||||
|
/// prior length, so the file's line count and `last_seq` may now
|
||||||
|
/// disagree. The caller must not append to this [`EventLog`] again —
|
||||||
|
/// treat the session as dead rather than promise consistency it cannot
|
||||||
|
/// keep.
|
||||||
|
#[error("event append failed ({cause}) and rollback did not restore consistency ({rollback})")]
|
||||||
|
Fatal {
|
||||||
|
cause: SidecarError,
|
||||||
|
rollback: SidecarError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SidecarError> for AppendError {
|
||||||
|
fn from(error: SidecarError) -> Self {
|
||||||
|
AppendError::Failed(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An open, append-only handle on one session's event log. Obtained from
|
/// An open, append-only handle on one session's event log. Obtained from
|
||||||
/// [`SidecarStore::open_events`]; held for the session's lifetime.
|
/// [`SidecarStore::open_events`]; held for the session's lifetime.
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct EventLog {
|
pub struct EventLog {
|
||||||
file: File,
|
file: Box<dyn LogSink>,
|
||||||
last_seq: u64,
|
last_seq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for EventLog {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("EventLog")
|
||||||
|
.field("last_seq", &self.last_seq)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl EventLog {
|
impl EventLog {
|
||||||
|
/// Wrap an arbitrary sink as an [`EventLog`], for tests that need to
|
||||||
|
/// inject a deterministic append failure (#184) — a partial write, a
|
||||||
|
/// flush that fails after the bytes are already down, or a rollback that
|
||||||
|
/// cannot itself complete.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn for_test(file: impl LogSink + 'static, last_seq: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
file: Box::new(file),
|
||||||
|
last_seq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The seq of the last line, `0` for an empty log.
|
/// The seq of the last line, `0` for an empty log.
|
||||||
pub fn last_seq(&self) -> u64 {
|
pub fn last_seq(&self) -> u64 {
|
||||||
self.last_seq
|
self.last_seq
|
||||||
@@ -272,12 +381,262 @@ impl EventLog {
|
|||||||
/// handed to the kernel in a single write before this returns, so a
|
/// handed to the kernel in a single write before this returns, so a
|
||||||
/// caller that broadcasts afterwards never announces an event the log
|
/// caller that broadcasts afterwards never announces an event the log
|
||||||
/// would not replay — short of the machine losing power.
|
/// would not replay — short of the machine losing power.
|
||||||
pub fn append(&mut self, notification: &SessionNotification) -> Result<u64, SidecarError> {
|
///
|
||||||
let mut line = serde_json::to_vec(notification)?;
|
/// A `write_all` that errors partway can leave an unterminated prefix on
|
||||||
|
/// disk; a `flush` that errors after a *complete* `write_all` leaves
|
||||||
|
/// ambiguity about whether that full, well-formed line should count —
|
||||||
|
/// counting it would silently shift every later seq by one, since
|
||||||
|
/// `last_seq` was never bumped for it. Both cases are rolled back to the
|
||||||
|
/// exact pre-append length before returning, so `last_seq` and the file
|
||||||
|
/// never disagree and the same [`EventLog`] can be appended to again
|
||||||
|
/// immediately. If the rollback itself fails, the log's consistency can
|
||||||
|
/// no longer be promised and [`AppendError::Fatal`] is returned instead.
|
||||||
|
pub fn append(&mut self, event: impl Into<PersistedEvent>) -> Result<u64, AppendError> {
|
||||||
|
let event = event.into();
|
||||||
|
let mut line = match &event {
|
||||||
|
PersistedEvent::Notification(notification) => serde_json::to_vec(notification)
|
||||||
|
.map_err(|e| AppendError::from(SidecarError::from(e)))?,
|
||||||
|
_ => {
|
||||||
|
serde_json::to_vec(&event).map_err(|e| AppendError::from(SidecarError::from(e)))?
|
||||||
|
}
|
||||||
|
};
|
||||||
line.push(b'\n');
|
line.push(b'\n');
|
||||||
self.file.write_all(&line)?;
|
|
||||||
self.file.flush()?;
|
let pre_len = self
|
||||||
|
.file
|
||||||
|
.len()
|
||||||
|
.map_err(|e| AppendError::from(SidecarError::from(e)))?;
|
||||||
|
|
||||||
|
if let Err(cause) = self.file.write_all(&line) {
|
||||||
|
return Err(self.rollback_after(pre_len, cause));
|
||||||
|
}
|
||||||
|
if let Err(cause) = self.file.flush() {
|
||||||
|
return Err(self.rollback_after(pre_len, cause));
|
||||||
|
}
|
||||||
|
|
||||||
self.last_seq += 1;
|
self.last_seq += 1;
|
||||||
Ok(self.last_seq)
|
Ok(self.last_seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rollback_after(&mut self, pre_len: u64, cause: std::io::Error) -> AppendError {
|
||||||
|
match self.file.truncate(pre_len) {
|
||||||
|
Ok(()) => AppendError::Failed(SidecarError::Io(cause)),
|
||||||
|
Err(rollback) => AppendError::Fatal {
|
||||||
|
cause: SidecarError::Io(cause),
|
||||||
|
rollback: SidecarError::Io(rollback),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes shared between a [`FaultyLogSink`] moved into an [`EventLog`] and
|
||||||
|
/// the test that planted it, so the test can inspect what actually ended up
|
||||||
|
/// on the "disk" after a fault without reaching into `EventLog`'s private
|
||||||
|
/// `file` field.
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(crate) struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl SharedBuf {
|
||||||
|
pub(crate) fn snapshot(&self) -> Vec<u8> {
|
||||||
|
self.0.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-memory [`LogSink`] double that injects one deterministic fault — a
|
||||||
|
/// short write, a flush that fails after a full write, or a rollback that
|
||||||
|
/// cannot itself complete — then behaves like a healthy sink from then on.
|
||||||
|
/// Lets a test prove the *same* [`EventLog`] recovers, rather than one with a
|
||||||
|
/// handle swapped in from a healthy disk, which production never does
|
||||||
|
/// (#184).
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct FaultyLogSink {
|
||||||
|
pub(crate) buf: SharedBuf,
|
||||||
|
/// Accept this many bytes on the next `write` call, then fail the
|
||||||
|
/// *following* call — a short write followed by an error, exactly what
|
||||||
|
/// `write_all` sees from a real fd that dies mid-line. `Write::write`
|
||||||
|
/// must not report an error on a call that made partial progress, so
|
||||||
|
/// the fault is delivered one call later, via `pending_write_error`.
|
||||||
|
pub(crate) fail_write_after: Option<usize>,
|
||||||
|
pub(crate) pending_write_error: bool,
|
||||||
|
pub(crate) fail_flush_once: bool,
|
||||||
|
pub(crate) fail_truncate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl Write for FaultyLogSink {
|
||||||
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||||
|
if self.pending_write_error {
|
||||||
|
self.pending_write_error = false;
|
||||||
|
return Err(std::io::Error::other("fault: write failed mid-line"));
|
||||||
|
}
|
||||||
|
if let Some(allowed) = self.fail_write_after.take() {
|
||||||
|
let allowed = allowed.min(data.len());
|
||||||
|
if allowed == 0 {
|
||||||
|
// No progress was made, so it's fine to report the error
|
||||||
|
// from this same call.
|
||||||
|
return Err(std::io::Error::other("fault: write failed mid-line"));
|
||||||
|
}
|
||||||
|
self.buf
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.extend_from_slice(&data[..allowed]);
|
||||||
|
self.pending_write_error = true;
|
||||||
|
return Ok(allowed);
|
||||||
|
}
|
||||||
|
self.buf.0.lock().unwrap().extend_from_slice(data);
|
||||||
|
Ok(data.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
if self.fail_flush_once {
|
||||||
|
self.fail_flush_once = false;
|
||||||
|
return Err(std::io::Error::other(
|
||||||
|
"fault: flush failed after a full write",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl LogSink for FaultyLogSink {
|
||||||
|
fn len(&mut self) -> std::io::Result<u64> {
|
||||||
|
Ok(self.buf.0.lock().unwrap().len() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
|
||||||
|
if self.fail_truncate {
|
||||||
|
return Err(std::io::Error::other("fault: rollback truncate failed"));
|
||||||
|
}
|
||||||
|
self.buf.0.lock().unwrap().truncate(len as usize);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_event(id: &str) -> PersistedEvent {
|
||||||
|
PersistedEvent::Approval {
|
||||||
|
row: TranscriptRow {
|
||||||
|
id: id.to_owned(),
|
||||||
|
kind: maestro_protocol::TranscriptRowKind::Approval,
|
||||||
|
data: serde_json::json!({"tool": "bash"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays `bytes` as a real on-disk log via the production read path,
|
||||||
|
/// so "live and durable agree" is checked with the same code a
|
||||||
|
/// reconnecting client would hit, not a hand-rolled line splitter.
|
||||||
|
fn replay(bytes: &[u8]) -> Vec<LoggedEvent> {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = SidecarStore::new(dir.path());
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
std::fs::create_dir_all(store.dir()).unwrap();
|
||||||
|
std::fs::write(store.events_path_for(id), bytes).unwrap();
|
||||||
|
store.read_events(id, EventCursor::Since(0), 100).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_rolls_back_a_partial_write_and_the_same_instance_recovers() {
|
||||||
|
let buf = SharedBuf::default();
|
||||||
|
let writer = FaultyLogSink {
|
||||||
|
buf: buf.clone(),
|
||||||
|
fail_write_after: Some(5),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut log = EventLog::for_test(writer, 0);
|
||||||
|
|
||||||
|
let err = log.append(sample_event("r1")).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, AppendError::Failed(_)),
|
||||||
|
"a repairable rollback must not be reported fatal"
|
||||||
|
);
|
||||||
|
assert_eq!(log.last_seq(), 0, "cursor must stay put on a failed append");
|
||||||
|
assert!(
|
||||||
|
buf.snapshot().is_empty(),
|
||||||
|
"the partial prefix a failed write_all leaves behind must be truncated away"
|
||||||
|
);
|
||||||
|
|
||||||
|
let seq = log
|
||||||
|
.append(sample_event("r1"))
|
||||||
|
.expect("the same instance must be able to append again after a clean rollback");
|
||||||
|
assert_eq!(seq, 1);
|
||||||
|
assert_eq!(log.last_seq(), 1);
|
||||||
|
|
||||||
|
let bytes = buf.snapshot();
|
||||||
|
assert_eq!(
|
||||||
|
bytes.iter().filter(|&&b| b == b'\n').count(),
|
||||||
|
1,
|
||||||
|
"exactly one well-formed line, not a partial prefix glued to the recovered append"
|
||||||
|
);
|
||||||
|
|
||||||
|
let replayed = replay(&bytes);
|
||||||
|
assert_eq!(replayed.len(), 1);
|
||||||
|
assert_eq!(replayed[0].seq, log.last_seq());
|
||||||
|
let PersistedEvent::Approval { row } = &replayed[0].event else {
|
||||||
|
panic!("approval event");
|
||||||
|
};
|
||||||
|
assert_eq!(row.id, "r1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_rolls_back_when_flush_fails_after_a_full_write() {
|
||||||
|
let buf = SharedBuf::default();
|
||||||
|
let writer = FaultyLogSink {
|
||||||
|
buf: buf.clone(),
|
||||||
|
fail_flush_once: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut log = EventLog::for_test(writer, 0);
|
||||||
|
|
||||||
|
let err = log.append(sample_event("r1")).unwrap_err();
|
||||||
|
assert!(matches!(err, AppendError::Failed(_)));
|
||||||
|
assert_eq!(log.last_seq(), 0);
|
||||||
|
assert!(
|
||||||
|
buf.snapshot().is_empty(),
|
||||||
|
"a full-but-unflushed line must not survive uncounted by last_seq — it would \
|
||||||
|
silently shift every later seq by one"
|
||||||
|
);
|
||||||
|
|
||||||
|
let seq = log
|
||||||
|
.append(sample_event("r1"))
|
||||||
|
.expect("the same instance must recover once flush stops failing");
|
||||||
|
assert_eq!(seq, 1);
|
||||||
|
|
||||||
|
let bytes = buf.snapshot();
|
||||||
|
let replayed = replay(&bytes);
|
||||||
|
assert_eq!(replayed.len(), 1);
|
||||||
|
assert_eq!(replayed[0].seq, log.last_seq());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_reports_fatal_when_rollback_cannot_restore_consistency() {
|
||||||
|
let buf = SharedBuf::default();
|
||||||
|
let writer = FaultyLogSink {
|
||||||
|
buf,
|
||||||
|
fail_write_after: Some(3),
|
||||||
|
fail_truncate: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut log = EventLog::for_test(writer, 0);
|
||||||
|
|
||||||
|
let err = log.append(sample_event("r1")).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, AppendError::Fatal { .. }),
|
||||||
|
"an unrepairable rollback must be surfaced as fatal, never silently treated as \
|
||||||
|
a clean recoverable failure"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log.last_seq(),
|
||||||
|
0,
|
||||||
|
"a fatal append must not advance the cursor either"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ impl Fake {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||||
match &event.notification.update {
|
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
|
||||||
|
panic!("unexpected daemon event")
|
||||||
|
};
|
||||||
|
match ¬ification.update {
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||||
ContentBlock::Text(text) => &text.text,
|
ContentBlock::Text(text) => &text.text,
|
||||||
other => panic!("unexpected content {other:?}"),
|
other => panic!("unexpected content {other:?}"),
|
||||||
@@ -133,6 +136,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
|
|
||||||
assert_eq!(session.view, SessionView::Structured);
|
assert_eq!(session.view, SessionView::Structured);
|
||||||
assert_eq!(session.status, SessionStatus::Running);
|
assert_eq!(session.status, SessionStatus::Running);
|
||||||
|
assert_eq!(session.activity, ActivityState::Idle);
|
||||||
let acp_id = session.acp_session_id.clone().expect("acp session id");
|
let acp_id = session.acp_session_id.clone().expect("acp session id");
|
||||||
assert!(acp_id.starts_with("fake-"), "{acp_id}");
|
assert!(acp_id.starts_with("fake-"), "{acp_id}");
|
||||||
|
|
||||||
@@ -201,6 +205,77 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
|||||||
.exists());
|
.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_activity_tracks_turn_completion_and_failure() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "100".into());
|
||||||
|
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
|
||||||
|
let session = fx
|
||||||
|
.manager
|
||||||
|
.create(structured(cwd.path(), env))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
fx.manager
|
||||||
|
.queue_acp_prompt(session.id, "fail".into())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
fx.manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.id == session.id)
|
||||||
|
.unwrap()
|
||||||
|
.activity,
|
||||||
|
ActivityState::Active
|
||||||
|
);
|
||||||
|
wait_for_async(Duration::from_secs(2), || async {
|
||||||
|
fx.manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.id == session.id)
|
||||||
|
.is_some_and(|candidate| candidate.activity == ActivityState::Error)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
fx.manager
|
||||||
|
.queue_acp_prompt(session.id, "succeed".into())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
fx.manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.id == session.id)
|
||||||
|
.unwrap()
|
||||||
|
.activity,
|
||||||
|
ActivityState::Active
|
||||||
|
);
|
||||||
|
wait_for_async(Duration::from_secs(2), || async {
|
||||||
|
fx.manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.id == session.id)
|
||||||
|
.is_some_and(|candidate| candidate.activity == ActivityState::Idle)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
fx.manager.kill(session.id, false).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn projection_snapshot_and_live_stream_share_one_fold() {
|
async fn projection_snapshot_and_live_stream_share_one_fold() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -321,6 +396,107 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
|
|||||||
fx.manager.kill(session.id, false).await.unwrap();
|
fx.manager.kill(session.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelled_prompt_that_never_settles_fails_the_session() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_PERMISSION_AFTER_CANCEL".into(), "1".into());
|
||||||
|
let session = fx
|
||||||
|
.manager
|
||||||
|
.create(structured(cwd.path(), env))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
fx.manager
|
||||||
|
.queue_acp_prompt(session.id, "first".into())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
|
));
|
||||||
|
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
||||||
|
let second = fx
|
||||||
|
.manager
|
||||||
|
.queue_acp_prompt(session.id, "second".into())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(second, maestro_daemon::session::QueueAcpPrompt::Unavailable),
|
||||||
|
"{second:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fx.manager.status(session.id).await,
|
||||||
|
Some(SessionStatus::Dead)
|
||||||
|
);
|
||||||
|
assert!(!fake.log().contains("permission response"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancellation_settlement_allows_the_next_prompt_permission() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
|
||||||
|
env.insert("FAKE_ACP_CANCEL_SETTLES".into(), "1".into());
|
||||||
|
let session = fx
|
||||||
|
.manager
|
||||||
|
.create(structured(cwd.path(), env))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
fx.manager
|
||||||
|
.queue_acp_prompt(session.id, "first".into())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
|
));
|
||||||
|
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
||||||
|
assert!(matches!(
|
||||||
|
fx.manager
|
||||||
|
.queue_acp_prompt(session.id, "second".into())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||||
|
));
|
||||||
|
|
||||||
|
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
let (_, state, _) = fx
|
||||||
|
.manager
|
||||||
|
.subscribe_acp_projection(session.id, EventCursor::Since(0))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
if let Some(pending) = state.pending_approval {
|
||||||
|
break pending["nonce"].as_str().unwrap().to_owned();
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(fx
|
||||||
|
.manager
|
||||||
|
.resolve_acp_approval(
|
||||||
|
session.id,
|
||||||
|
&nonce,
|
||||||
|
maestro_daemon::session::ApprovalDecision::AllowOnce,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_some());
|
||||||
|
wait_for_async(Duration::from_secs(2), || {
|
||||||
|
let log = fake.log();
|
||||||
|
async move { log.contains("permission response") }
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
fx.manager.kill(session.id, false).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -372,6 +548,16 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
revived.status(session.id).await,
|
revived.status(session.id).await,
|
||||||
Some(SessionStatus::Running)
|
Some(SessionStatus::Running)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
revived
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.id == session.id)
|
||||||
|
.unwrap()
|
||||||
|
.activity,
|
||||||
|
ActivityState::Idle
|
||||||
|
);
|
||||||
let log = fake.log();
|
let log = fake.log();
|
||||||
assert!(
|
assert!(
|
||||||
log.contains(&format!("recv session/load sessionId={acp_id}")),
|
log.contains(&format!("recv session/load sessionId={acp_id}")),
|
||||||
@@ -764,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();
|
||||||
@@ -821,6 +1192,16 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
|
|||||||
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
|
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
fx.manager
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.id == session.id)
|
||||||
|
.unwrap()
|
||||||
|
.activity,
|
||||||
|
ActivityState::Error
|
||||||
|
);
|
||||||
assert!(fx.manager.has(session.id).await);
|
assert!(fx.manager.has(session.id).await);
|
||||||
assert!(
|
assert!(
|
||||||
!fx.sidecar_dir()
|
!fx.sidecar_dir()
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use common::DaemonFixture;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_daemon::local_api;
|
use maestro_daemon::local_api;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ApiError, CreateSessionRequest, Session, SessionStatus, SessionView, WorktreeSpec,
|
ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView,
|
||||||
|
WorktreeSpec,
|
||||||
};
|
};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -142,6 +143,10 @@ impl FakeAcp {
|
|||||||
.next_back()
|
.next_back()
|
||||||
.expect("fake ACP started")
|
.expect("fake ACP started")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log(&self) -> String {
|
||||||
|
std::fs::read_to_string(self.dir.path().join("agent.log")).unwrap_or_default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
||||||
@@ -163,6 +168,29 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
|
|||||||
.id
|
.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if sessions
|
||||||
|
.iter()
|
||||||
|
.find(|session| session.id == id)
|
||||||
|
.is_some_and(|session| session.activity == expected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("session {id} did not reach {expected:?}"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn structured_ws_sends_snapshot_then_live_projection() {
|
async fn structured_ws_sends_snapshot_then_live_projection() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -256,6 +284,137 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
|
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if fake.log().matches("recv session/prompt").count() == 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("both prompt commands reach ACP");
|
||||||
|
let log = fake.log();
|
||||||
|
let first_prompt = log.find("recv session/prompt").unwrap();
|
||||||
|
let cancel = log.find("recv session/cancel").unwrap();
|
||||||
|
let second_prompt = log.rfind("recv session/prompt").unwrap();
|
||||||
|
assert!(first_prompt < cancel && cancel < second_prompt, "{log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_approval_is_secret_single_use_and_race_safe() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
|
||||||
|
let id = structured_session_with_env(&h, env).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ws.next().await.unwrap().unwrap();
|
||||||
|
ws.next().await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt")
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt":"needs permission"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status(),
|
||||||
|
reqwest::StatusCode::NO_CONTENT
|
||||||
|
);
|
||||||
|
|
||||||
|
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
let event: serde_json::Value =
|
||||||
|
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
if let Some(nonce) = event["state"]["pending_approval"]["nonce"].as_str() {
|
||||||
|
assert_eq!(event["transcript"]["row"]["kind"], "approval");
|
||||||
|
assert!(event["transcript"]["row"]["data"].get("nonce").is_none());
|
||||||
|
break nonce.to_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_for_session_activity(&h, id, ActivityState::AwaitingInput).await;
|
||||||
|
assert!(
|
||||||
|
!fake.log().contains(&nonce),
|
||||||
|
"daemon nonce leaked to ACP agent"
|
||||||
|
);
|
||||||
|
|
||||||
|
let path = format!("/api/sessions/{id}/structured/approvals/{nonce}");
|
||||||
|
let (a, b) = tokio::join!(
|
||||||
|
client
|
||||||
|
.post(url(h.addr, &path))
|
||||||
|
.json(&serde_json::json!("allow_once"))
|
||||||
|
.send(),
|
||||||
|
client
|
||||||
|
.post(url(h.addr, &path))
|
||||||
|
.json(&serde_json::json!("deny"))
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
assert_eq!(a.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
assert_eq!(b.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.post(url(h.addr, &path))
|
||||||
|
.json(&serde_json::json!("deny"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status(),
|
||||||
|
reqwest::StatusCode::NO_CONTENT
|
||||||
|
);
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if fake.log().contains("permission response") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let log = fake.log();
|
||||||
|
assert!(
|
||||||
|
log.contains("\"optionId\": \"once\"") || log.contains("\"optionId\": \"deny\""),
|
||||||
|
"{log}"
|
||||||
|
);
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
let event: serde_json::Value =
|
||||||
|
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
if event["state"]["pending_approval"].is_null() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("resolved approval clears the reduced state");
|
||||||
|
wait_for_session_activity(&h, id, ActivityState::Idle).await;
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!(
|
||||||
|
"/api/sessions/{}/structured/approvals/{nonce}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!("deny"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status(),
|
||||||
|
reqwest::StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -296,6 +455,7 @@ async fn structured_prompt_failure_clears_the_turn() {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("failed turn clears promptly");
|
.expect("failed turn clears promptly");
|
||||||
|
wait_for_session_activity(&h, id, ActivityState::Error).await;
|
||||||
let second = client
|
let second = client
|
||||||
.post(url(h.addr, &path))
|
.post(url(h.addr, &path))
|
||||||
.json(&serde_json::json!({"prompt":"available"}))
|
.json(&serde_json::json!({"prompt":"available"}))
|
||||||
@@ -1151,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;
|
||||||
@@ -1217,6 +1410,73 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
||||||
|
let h = spawn("konishi").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let mut env = fake.env();
|
||||||
|
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "500".into());
|
||||||
|
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
|
||||||
|
.await
|
||||||
|
.expect("events ws");
|
||||||
|
assert!(matches!(
|
||||||
|
next_event(&mut ws).await,
|
||||||
|
maestro_protocol::CatalogEvent::Snapshot { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
let id = structured_session_with_env(&h, env).await;
|
||||||
|
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
||||||
|
panic!("expected structured SessionAdded")
|
||||||
|
};
|
||||||
|
assert_eq!(session.activity, ActivityState::Idle);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let prompt = client
|
||||||
|
.post(url(
|
||||||
|
h.addr,
|
||||||
|
&format!("/api/sessions/{id}/structured/prompt"),
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({"prompt": "hello"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(prompt.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
let report = client
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||||
|
.json(&serde_json::json!({"state": "Error"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(report.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
wait_for_session_activity(&h, id, ActivityState::Active).await;
|
||||||
|
|
||||||
|
let mut activities = Vec::new();
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if let maestro_protocol::CatalogEvent::SessionUpdated { session } =
|
||||||
|
next_event(&mut ws).await
|
||||||
|
{
|
||||||
|
activities.push(session.activity);
|
||||||
|
if session.activity == ActivityState::Idle {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("structured turn returns to idle");
|
||||||
|
assert!(
|
||||||
|
activities.contains(&ActivityState::Active),
|
||||||
|
"{activities:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!activities.contains(&ActivityState::Error),
|
||||||
|
"{activities:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
h.manager.kill(id, false).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
||||||
/// two clients connected at different times both get their own Snapshot.
|
/// two clients connected at different times both get their own Snapshot.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use std::collections::HashMap;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_daemon::sidecar::SidecarStore;
|
use maestro_daemon::sidecar::{PersistedEvent, SidecarStore};
|
||||||
use maestro_protocol::{Session, SessionStatus, SessionView};
|
use maestro_protocol::{Session, SessionStatus, SessionView, TranscriptRow, TranscriptRowKind};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -186,6 +186,13 @@ fn text_of(n: &SessionNotification) -> &str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||||
|
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
|
||||||
|
panic!("expected ACP notification")
|
||||||
|
};
|
||||||
|
text_of(notification)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn append_assigns_line_position_as_seq_and_reads_back() {
|
fn append_assigns_line_position_as_seq_and_reads_back() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -194,9 +201,9 @@ fn append_assigns_line_position_as_seq_and_reads_back() {
|
|||||||
|
|
||||||
let mut log = store.open_events(id).expect("open");
|
let mut log = store.open_events(id).expect("open");
|
||||||
assert_eq!(log.last_seq(), 0);
|
assert_eq!(log.last_seq(), 0);
|
||||||
assert_eq!(log.append(¬e("a")).unwrap(), 1);
|
assert_eq!(log.append(note("a")).unwrap(), 1);
|
||||||
assert_eq!(log.append(¬e("b")).unwrap(), 2);
|
assert_eq!(log.append(note("b")).unwrap(), 2);
|
||||||
assert_eq!(log.append(¬e("c")).unwrap(), 3);
|
assert_eq!(log.append(note("c")).unwrap(), 3);
|
||||||
assert_eq!(log.last_seq(), 3);
|
assert_eq!(log.last_seq(), 3);
|
||||||
|
|
||||||
let path = store.events_path_for(id);
|
let path = store.events_path_for(id);
|
||||||
@@ -211,9 +218,7 @@ fn append_assigns_line_position_as_seq_and_reads_back() {
|
|||||||
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
|
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
|
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
all.iter()
|
all.iter().map(event_text).collect::<Vec<_>>(),
|
||||||
.map(|e| text_of(&e.notification))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec!["a", "b", "c"]
|
vec!["a", "b", "c"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,7 +230,7 @@ fn since_reads_forward_and_before_reads_backward_with_limit() {
|
|||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
for i in 1..=6 {
|
for i in 1..=6 {
|
||||||
log.append(¬e(&format!("n{i}"))).unwrap();
|
log.append(note(&format!("n{i}"))).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// `since` is exclusive: what a client that already holds seq 2 needs.
|
// `since` is exclusive: what a client that already holds seq 2 needs.
|
||||||
@@ -278,7 +283,7 @@ fn malformed_lines_are_skipped_but_keep_their_seq() {
|
|||||||
let store = SidecarStore::new(dir.path());
|
let store = SidecarStore::new(dir.path());
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
log.append(¬e("first")).unwrap();
|
log.append(note("first")).unwrap();
|
||||||
drop(log);
|
drop(log);
|
||||||
|
|
||||||
// Garbage in the middle and a line that is JSON but not a notification.
|
// Garbage in the middle and a line that is JSON but not a notification.
|
||||||
@@ -289,11 +294,11 @@ fn malformed_lines_are_skipped_but_keep_their_seq() {
|
|||||||
std::fs::write(&path, raw).unwrap();
|
std::fs::write(&path, raw).unwrap();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
assert_eq!(log.last_seq(), 3);
|
assert_eq!(log.last_seq(), 3);
|
||||||
assert_eq!(log.append(¬e("fourth")).unwrap(), 4);
|
assert_eq!(log.append(note("fourth")).unwrap(), 4);
|
||||||
|
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
|
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
|
||||||
assert_eq!(text_of(&all[1].notification), "fourth");
|
assert_eq!(event_text(&all[1]), "fourth");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -303,7 +308,7 @@ fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
|
|||||||
let store = SidecarStore::new(dir.path());
|
let store = SidecarStore::new(dir.path());
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
log.append(¬e("whole")).unwrap();
|
log.append(note("whole")).unwrap();
|
||||||
drop(log);
|
drop(log);
|
||||||
|
|
||||||
let path = store.events_path_for(id);
|
let path = store.events_path_for(id);
|
||||||
@@ -319,10 +324,10 @@ fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
|
|||||||
// glue onto it; the partial keeps a seq of its own.
|
// glue onto it; the partial keeps a seq of its own.
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
assert_eq!(log.last_seq(), 2);
|
assert_eq!(log.last_seq(), 2);
|
||||||
assert_eq!(log.append(¬e("after")).unwrap(), 3);
|
assert_eq!(log.append(note("after")).unwrap(), 3);
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
|
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
|
||||||
assert_eq!(text_of(&all[1].notification), "after");
|
assert_eq!(event_text(&all[1]), "after");
|
||||||
let raw = std::fs::read_to_string(&path).unwrap();
|
let raw = std::fs::read_to_string(&path).unwrap();
|
||||||
assert_eq!(raw.lines().count(), 3, "{raw}");
|
assert_eq!(raw.lines().count(), 3, "{raw}");
|
||||||
}
|
}
|
||||||
@@ -334,8 +339,10 @@ fn delete_removes_the_event_log_and_load_all_ignores_it() {
|
|||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
store.write(&sample(id)).unwrap();
|
store.write(&sample(id)).unwrap();
|
||||||
let mut log = store.open_events(id).unwrap();
|
let mut log = store.open_events(id).unwrap();
|
||||||
log.append(¬e("x")).unwrap();
|
log.append(note("x")).unwrap();
|
||||||
drop(log);
|
drop(log);
|
||||||
|
let legacy_approvals = dir.path().join(format!("{id}.approvals.jsonl"));
|
||||||
|
std::fs::write(&legacy_approvals, b"obsolete").unwrap();
|
||||||
assert!(store.events_path_for(id).exists());
|
assert!(store.events_path_for(id).exists());
|
||||||
|
|
||||||
let loaded = store.load_all().unwrap();
|
let loaded = store.load_all().unwrap();
|
||||||
@@ -344,6 +351,47 @@ fn delete_removes_the_event_log_and_load_all_ignores_it() {
|
|||||||
store.delete(id).unwrap();
|
store.delete(id).unwrap();
|
||||||
assert!(!store.path_for(id).exists());
|
assert!(!store.path_for(id).exists());
|
||||||
assert!(!store.events_path_for(id).exists());
|
assert!(!store.events_path_for(id).exists());
|
||||||
|
assert!(!legacy_approvals.exists());
|
||||||
// Idempotent with the log already gone.
|
// Idempotent with the log already gone.
|
||||||
store.delete(id).unwrap();
|
store.delete(id).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_rows_share_event_cursors_with_legacy_acp_lines() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = SidecarStore::new(dir.path());
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let legacy = serde_json::to_vec(¬e("before")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path()).unwrap();
|
||||||
|
std::fs::write(store.events_path_for(id), [legacy, b"\n".to_vec()].concat()).unwrap();
|
||||||
|
|
||||||
|
let mut log = store.open_events(id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
log.append(PersistedEvent::Approval {
|
||||||
|
row: TranscriptRow {
|
||||||
|
id: "approval:secret".into(),
|
||||||
|
kind: TranscriptRowKind::Approval,
|
||||||
|
data: serde_json::json!({"tool": "bash"}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(log.append(note("after")).unwrap(), 3);
|
||||||
|
|
||||||
|
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
all.iter().map(|event| event.seq).collect::<Vec<_>>(),
|
||||||
|
[1, 2, 3]
|
||||||
|
);
|
||||||
|
assert!(matches!(all[1].event, PersistedEvent::Approval { .. }));
|
||||||
|
assert_eq!(event_text(&all[0]), "before");
|
||||||
|
assert_eq!(event_text(&all[2]), "after");
|
||||||
|
|
||||||
|
let since = store.read_events(id, EventCursor::Since(1), 1).unwrap();
|
||||||
|
assert_eq!(since[0].seq, 2);
|
||||||
|
let before = store
|
||||||
|
.read_events(id, EventCursor::Before(Some(3)), 1)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(before[0].seq, 2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crate::routes::{self, UrlError};
|
|||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||||
SetFocusRequest,
|
SetFocusRequest, StructuredApprovalDecision, StructuredPromptRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -105,6 +105,11 @@ impl DaemonClient {
|
|||||||
Ok(routes::attach_url(&self.base, id)?)
|
Ok(routes::attach_url(&self.base, id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `WS /api/structured/:id` URL for this daemon.
|
||||||
|
pub fn structured_url(&self, id: Uuid) -> Result<String, ClientError> {
|
||||||
|
Ok(routes::structured_url(&self.base, id)?)
|
||||||
|
}
|
||||||
|
|
||||||
// -- typed calls --------------------------------------------------------
|
// -- typed calls --------------------------------------------------------
|
||||||
|
|
||||||
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
||||||
@@ -179,6 +184,57 @@ impl DaemonClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send one prompt to a structured session. The daemon rejects a second
|
||||||
|
/// prompt while the preceding ACP turn is still active.
|
||||||
|
pub async fn send_structured_prompt(&self, id: Uuid, prompt: &str) -> Result<(), ClientError> {
|
||||||
|
let req = StructuredPromptRequest {
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
};
|
||||||
|
let resp = self
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
&routes::structured_prompt(id),
|
||||||
|
&[],
|
||||||
|
Some(&req),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the structured session's active ACP turn.
|
||||||
|
pub async fn cancel_structured_turn(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
&routes::structured_cancel(id),
|
||||||
|
&[],
|
||||||
|
None::<&()>,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the currently pending structured-session permission request.
|
||||||
|
pub async fn resolve_structured_approval(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
nonce: &str,
|
||||||
|
decision: StructuredApprovalDecision,
|
||||||
|
) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
&routes::structured_approval(id, nonce),
|
||||||
|
&[],
|
||||||
|
Some(&decision),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||||
self.get(routes::INFO, &[]).await
|
self.get(routes::INFO, &[]).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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,9 +156,9 @@ pub enum SessionStatus {
|
|||||||
Dead,
|
Dead,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
/// Session activity, orthogonal to [`SessionStatus`] (NAP-340). Terminal
|
||||||
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
/// sessions use pane/process inference and agent reports; structured sessions
|
||||||
/// inspection — never by parsing the terminal byte stream.
|
/// use their ACP turn lifecycle (docs/SIDEBAR.md).
|
||||||
///
|
///
|
||||||
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
||||||
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
||||||
@@ -166,15 +166,14 @@ pub enum SessionStatus {
|
|||||||
/// forgiven.
|
/// forgiven.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||||
pub enum ActivityState {
|
pub enum ActivityState {
|
||||||
/// Producing output, or the foreground process is busy.
|
/// Producing output, the foreground process is busy, or an ACP turn is active.
|
||||||
Active,
|
Active,
|
||||||
/// Foreground process alive but quiescent. The forward-compatible default.
|
/// Foreground process alive but quiescent. The forward-compatible default.
|
||||||
#[default]
|
#[default]
|
||||||
Idle,
|
Idle,
|
||||||
/// Blocked on a `read()` from the pty — waiting on the user.
|
/// Blocked on terminal input or waiting for an ACP approval.
|
||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
/// The agent running in the session reported a failure (#102). Never
|
/// An agent reported a failure, or a structured session/turn failed.
|
||||||
/// inferred by the daemon; only set when an agent reports it (#105).
|
|
||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,6 +871,15 @@ pub struct StructuredPromptRequest {
|
|||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operator decision for a pending ACP permission request.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StructuredApprovalDecision {
|
||||||
|
AllowOnce,
|
||||||
|
AllowForSession,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Client ↔ Daemon control messages (text frames on the attach WS)
|
// Client ↔ Daemon control messages (text frames on the attach WS)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ pub fn structured_cancel(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/structured/cancel")
|
format!("{SESSIONS}/{id}/structured/cancel")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission.
|
||||||
|
pub fn structured_approval(id: Uuid, nonce: &str) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Axum-style patterns for the two parameterised routes, so the server
|
/// Axum-style patterns for the two parameterised routes, so the server
|
||||||
/// registers the same paths [`session`] and [`attach`] build.
|
/// registers the same paths [`session`] and [`attach`] build.
|
||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
@@ -104,6 +109,7 @@ pub mod patterns {
|
|||||||
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
||||||
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
||||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||||
|
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||||
@@ -153,6 +159,11 @@ pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
|||||||
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
|
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full `WS /api/structured/:id` URL for a daemon reachable at `http_base`.
|
||||||
|
pub fn structured_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
||||||
|
Ok(format!("{}{}", ws_base(http_base)?, structured(id)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -202,6 +213,10 @@ mod tests {
|
|||||||
events_url("http://host:7070/").unwrap(),
|
events_url("http://host:7070/").unwrap(),
|
||||||
"ws://host:7070/api/events"
|
"ws://host:7070/api/events"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
structured_url("http://host:7070/", id()).unwrap(),
|
||||||
|
"ws://host:7070/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ thiserror.workspace = true
|
|||||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
unicode-width.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
vt100.workspace = true
|
vt100.workspace = true
|
||||||
|
|
||||||
|
|||||||
+388
-13
@@ -17,7 +17,10 @@ use std::time::Duration;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
use maestro_protocol::{
|
||||||
|
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, SessionView,
|
||||||
|
StructuredApprovalDecision,
|
||||||
|
};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -38,6 +41,8 @@ use crate::render::{draw, RegionMap, RenderInputs};
|
|||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
|
use crate::structured_pane::StructuredPane;
|
||||||
|
use crate::structured_task::{StructuredHandle, StructuredTask};
|
||||||
use crate::text_input::TextInput;
|
use crate::text_input::TextInput;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
@@ -54,6 +59,7 @@ pub struct App {
|
|||||||
home_dir: String,
|
home_dir: String,
|
||||||
catalog: CatalogStore,
|
catalog: CatalogStore,
|
||||||
emulators: SessionEmulators,
|
emulators: SessionEmulators,
|
||||||
|
structured: HashMap<Uuid, StructuredHandle>,
|
||||||
state: AppState,
|
state: AppState,
|
||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
@@ -163,6 +169,18 @@ pub enum Effect {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
StructuredPrompt {
|
||||||
|
id: Uuid,
|
||||||
|
prompt: String,
|
||||||
|
},
|
||||||
|
StructuredCancel {
|
||||||
|
id: Uuid,
|
||||||
|
},
|
||||||
|
StructuredApproval {
|
||||||
|
id: Uuid,
|
||||||
|
nonce: String,
|
||||||
|
decision: StructuredApprovalDecision,
|
||||||
|
},
|
||||||
/// Fetch presets / projects / names for a freshly opened form (#64).
|
/// Fetch presets / projects / names for a freshly opened form (#64).
|
||||||
LoadDraft,
|
LoadDraft,
|
||||||
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
||||||
@@ -214,6 +232,7 @@ impl App {
|
|||||||
home_dir: std::env::var("HOME").unwrap_or_default(),
|
home_dir: std::env::var("HOME").unwrap_or_default(),
|
||||||
catalog,
|
catalog,
|
||||||
emulators: SessionEmulators::default(),
|
emulators: SessionEmulators::default(),
|
||||||
|
structured: HashMap::new(),
|
||||||
state: AppState::new(),
|
state: AppState::new(),
|
||||||
draft: None,
|
draft: None,
|
||||||
help,
|
help,
|
||||||
@@ -321,6 +340,11 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(effects) = self.handle_structured_key(&key) {
|
||||||
|
self.perform(effects).await;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(effect) = self.handle_pane_scroll(&key) {
|
if let Some(effect) = self.handle_pane_scroll(&key) {
|
||||||
self.perform(effect.into_iter().collect()).await;
|
self.perform(effect.into_iter().collect()).await;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -419,6 +443,44 @@ impl App {
|
|||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Effect::StructuredPrompt { id, prompt } => {
|
||||||
|
if let Err(error) = self.daemon.send_structured_prompt(id, &prompt).await {
|
||||||
|
if let Some(handle) = self.structured.get(&id) {
|
||||||
|
handle
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.prompt_failed(prompt, error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::StructuredCancel { id } => {
|
||||||
|
if let Err(error) = self.daemon.cancel_structured_turn(id).await {
|
||||||
|
if let Some(handle) = self.structured.get(&id) {
|
||||||
|
handle.state.lock().expect("structured state mutex").error =
|
||||||
|
Some(error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effect::StructuredApproval {
|
||||||
|
id,
|
||||||
|
nonce,
|
||||||
|
decision,
|
||||||
|
} => {
|
||||||
|
if let Err(error) = self
|
||||||
|
.daemon
|
||||||
|
.resolve_structured_approval(id, &nonce, decision)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if let Some(handle) = self.structured.get(&id) {
|
||||||
|
handle
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.approval_failed(&nonce, error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,13 +625,13 @@ impl App {
|
|||||||
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
||||||
// Escape hatch (#156): the literal byte, not the bar.
|
// Escape hatch (#156): the literal byte, not the bar.
|
||||||
Action::SendLiteralCtrlK => {
|
Action::SendLiteralCtrlK => {
|
||||||
effects.extend(self.forward_to_session(&InputKey::ctrl_char('k')));
|
effects.extend(self.forward_literal_to_terminal(&InputKey::ctrl_char('k')));
|
||||||
}
|
}
|
||||||
// Escape hatch (#158): the leader's own byte, built from the
|
// Escape hatch (#158): the leader's own byte, built from the
|
||||||
// configured leader rather than a hardcoded key.
|
// configured leader rather than a hardcoded key.
|
||||||
Action::SendLiteralLeader => {
|
Action::SendLiteralLeader => {
|
||||||
let leader = self.cfg.leader.clone();
|
let leader = self.cfg.leader.clone();
|
||||||
effects.extend(self.forward_to_session(&leader));
|
effects.extend(self.forward_literal_to_terminal(&leader));
|
||||||
}
|
}
|
||||||
Action::OpenSpawnModal => {
|
Action::OpenSpawnModal => {
|
||||||
self.open_spawn_modal();
|
self.open_spawn_modal();
|
||||||
@@ -587,7 +649,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
// A previewed session is already attached: committing
|
// A previewed session is already attached: committing
|
||||||
// to it keeps that stream rather than reopening one.
|
// to it keeps that stream rather than reopening one.
|
||||||
if self.emulators.get(sid).is_none() {
|
if !self.is_attached(sid) {
|
||||||
effects.push(Effect::Attach(sid));
|
effects.push(Effect::Attach(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,6 +657,9 @@ impl App {
|
|||||||
// preview budget.
|
// preview budget.
|
||||||
self.preview_lru.retain(|s| *s != sid);
|
self.preview_lru.retain(|s| *s != sid);
|
||||||
self.state.attach_to_focused(sid);
|
self.state.attach_to_focused(sid);
|
||||||
|
if self.session_view(sid) == SessionView::Structured {
|
||||||
|
self.state.mode = Mode::Normal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::KillHighlighted => {
|
Action::KillHighlighted => {
|
||||||
@@ -663,7 +728,7 @@ impl App {
|
|||||||
self.state.preview = Some(sid);
|
self.state.preview = Some(sid);
|
||||||
// Set outside the input path, so nothing else marks the frame stale.
|
// Set outside the input path, so nothing else marks the frame stale.
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
let fresh = self.emulators.get(sid).is_none();
|
let fresh = !self.is_attached(sid);
|
||||||
if fresh {
|
if fresh {
|
||||||
self.preview_lru.retain(|s| *s != sid);
|
self.preview_lru.retain(|s| *s != sid);
|
||||||
self.preview_lru.push(sid);
|
self.preview_lru.push(sid);
|
||||||
@@ -732,15 +797,27 @@ impl App {
|
|||||||
|
|
||||||
/// Open a stream for `sid` if one isn't already running.
|
/// Open a stream for `sid` if one isn't already running.
|
||||||
fn attach(&mut self, sid: Uuid) {
|
fn attach(&mut self, sid: Uuid) {
|
||||||
if self.emulators.get(sid).is_some() {
|
self.attach_with_view(sid, self.session_view(sid));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_with_view(&mut self, sid: Uuid, view: SessionView) {
|
||||||
|
if self.is_attached(sid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match self.daemon.attach_url(sid) {
|
match view {
|
||||||
Ok(ws_url) => {
|
SessionView::Terminal => match self.daemon.attach_url(sid) {
|
||||||
let handle = SessionTask::spawn(ws_url, 24, 80);
|
Ok(ws_url) => {
|
||||||
self.emulators.insert(sid, handle);
|
let handle = SessionTask::spawn(ws_url, 24, 80);
|
||||||
}
|
self.emulators.insert(sid, handle);
|
||||||
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
}
|
||||||
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||||
|
},
|
||||||
|
SessionView::Structured => match self.daemon.structured_url(sid) {
|
||||||
|
Ok(ws_url) => {
|
||||||
|
self.structured.insert(sid, StructuredTask::spawn(ws_url));
|
||||||
|
}
|
||||||
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,9 +825,26 @@ impl App {
|
|||||||
/// dropping its vt100 parser.
|
/// dropping its vt100 parser.
|
||||||
fn release_session(&mut self, sid: Uuid) {
|
fn release_session(&mut self, sid: Uuid) {
|
||||||
self.emulators.remove(sid);
|
self.emulators.remove(sid);
|
||||||
|
if let Some(handle) = self.structured.remove(&sid) {
|
||||||
|
handle.end();
|
||||||
|
}
|
||||||
self.last_sent_size.remove(&sid);
|
self.last_sent_size.remove(&sid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_view(&self, sid: Uuid) -> SessionView {
|
||||||
|
self.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|session| session.id == sid)
|
||||||
|
.map(|session| session.view)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_attached(&self, sid: Uuid) -> bool {
|
||||||
|
self.emulators.get(sid).is_some() || self.structured.contains_key(&sid)
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the session under the sidebar highlight, falling back to the
|
/// Return the session under the sidebar highlight, falling back to the
|
||||||
/// first listed session so the user can attach without arrowing first.
|
/// first listed session so the user can attach without arrowing first.
|
||||||
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
||||||
@@ -817,6 +911,16 @@ impl App {
|
|||||||
/// attached to send it to.
|
/// attached to send it to.
|
||||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
let sid = self.state.active_session()?;
|
let sid = self.state.active_session()?;
|
||||||
|
if let Some(handle) = self.structured.get(&sid) {
|
||||||
|
let mut state = handle.state.lock().expect("structured state mutex");
|
||||||
|
if matches!(key, InputKey::Enter) {
|
||||||
|
return state
|
||||||
|
.begin_prompt()
|
||||||
|
.map(|prompt| Effect::StructuredPrompt { id: sid, prompt });
|
||||||
|
}
|
||||||
|
state.edit_composer(key);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let handle = self.emulators.get(sid)?;
|
let handle = self.emulators.get(sid)?;
|
||||||
let bytes = encode_key(key)?;
|
let bytes = encode_key(key)?;
|
||||||
handle
|
handle
|
||||||
@@ -827,6 +931,86 @@ impl App {
|
|||||||
Some(Effect::Forward { id: sid, bytes })
|
Some(Effect::Forward { id: sid, bytes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn forward_literal_to_terminal(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
|
let sid = self.state.active_session()?;
|
||||||
|
if self.session_view(sid) == SessionView::Structured {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.forward_to_session(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_structured_key(&mut self, key: &InputKey) -> Option<Vec<Effect>> {
|
||||||
|
if self.state.focus != Focus::Pane || self.state.preview.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sid = self.state.active_session()?;
|
||||||
|
let handle = self.structured.get(&sid)?;
|
||||||
|
let mut state = handle.state.lock().expect("structured state mutex");
|
||||||
|
if self.state.mode == Mode::Insert {
|
||||||
|
if matches!(key, InputKey::Esc) && state.turn_active() {
|
||||||
|
return Some(vec![Effect::StructuredCancel { id: sid }]);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let effects = match key {
|
||||||
|
InputKey::Char {
|
||||||
|
c: 'j',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| InputKey::Down => {
|
||||||
|
state.select(1);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
InputKey::Char {
|
||||||
|
c: 'k',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| InputKey::Up => {
|
||||||
|
state.select(-1);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
InputKey::Enter => {
|
||||||
|
state.toggle_selected();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
InputKey::Esc => {
|
||||||
|
if state.turn_active() {
|
||||||
|
vec![Effect::StructuredCancel { id: sid }]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputKey::Char {
|
||||||
|
c: key @ ('a' | 'A' | 'd'),
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
} => state
|
||||||
|
.approval_decision(*key)
|
||||||
|
.map(|(nonce, decision)| Effect::StructuredApproval {
|
||||||
|
id: sid,
|
||||||
|
nonce,
|
||||||
|
decision,
|
||||||
|
})
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
InputKey::PageUp => {
|
||||||
|
state.scroll_by(self.pane_page_rows() as isize);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
InputKey::PageDown => {
|
||||||
|
state.scroll_by(-(self.pane_page_rows() as isize));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(effects)
|
||||||
|
}
|
||||||
|
|
||||||
fn pane_page_rows(&self) -> usize {
|
fn pane_page_rows(&self) -> usize {
|
||||||
self.region
|
self.region
|
||||||
.pane
|
.pane
|
||||||
@@ -839,6 +1023,14 @@ impl App {
|
|||||||
let Some(sid) = self.state.active_session() else {
|
let Some(sid) = self.state.active_session() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if let Some(handle) = self.structured.get(&sid) {
|
||||||
|
handle
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.scroll_by(rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(handle) = self.emulators.get(sid) else {
|
let Some(handle) = self.emulators.get(sid) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -872,6 +1064,14 @@ impl App {
|
|||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let sid = self.state.active_session()?;
|
let sid = self.state.active_session()?;
|
||||||
|
if let Some(handle) = self.structured.get(&sid) {
|
||||||
|
handle
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.scroll_by(lines);
|
||||||
|
return Some(None);
|
||||||
|
}
|
||||||
let handle = self.emulators.get(sid)?;
|
let handle = self.emulators.get(sid)?;
|
||||||
let pane = self.region.pane?;
|
let pane = self.region.pane?;
|
||||||
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||||
@@ -1294,7 +1494,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.draft = None;
|
self.draft = None;
|
||||||
self.state.complete_spawn(sid);
|
self.state.complete_spawn(sid);
|
||||||
self.attach(sid);
|
if session.view == SessionView::Structured {
|
||||||
|
self.state.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
self.attach_with_view(sid, session.view);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// A failed POST belongs in the form it came from (#64), not a
|
// A failed POST belongs in the form it came from (#64), not a
|
||||||
@@ -1355,6 +1558,11 @@ impl App {
|
|||||||
self.emulators
|
self.emulators
|
||||||
.get(sid)
|
.get(sid)
|
||||||
.map(|h| (sid, h.revision(), h.state()))
|
.map(|h| (sid, h.revision(), h.state()))
|
||||||
|
.or_else(|| {
|
||||||
|
self.structured
|
||||||
|
.get(&sid)
|
||||||
|
.map(|h| (sid, h.revision(), h.connection()))
|
||||||
|
})
|
||||||
});
|
});
|
||||||
FrameKey {
|
FrameKey {
|
||||||
catalog: self.catalog.version(),
|
catalog: self.catalog.version(),
|
||||||
@@ -1416,6 +1624,17 @@ impl App {
|
|||||||
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let structured_pane = shown_sid.and_then(|sid| {
|
||||||
|
let handle = self.structured.get(&sid)?;
|
||||||
|
let session = snap.sessions.iter().find(|session| session.id == sid)?;
|
||||||
|
Some(StructuredPane {
|
||||||
|
handle: handle.clone(),
|
||||||
|
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||||
|
mode: self.state.mode,
|
||||||
|
session_status: session.status,
|
||||||
|
activity_detail: session.activity_detail.clone(),
|
||||||
|
})
|
||||||
|
});
|
||||||
let pane_title = match shown_sid {
|
let pane_title = match shown_sid {
|
||||||
Some(sid) => {
|
Some(sid) => {
|
||||||
let name = snap
|
let name = snap
|
||||||
@@ -1439,6 +1658,7 @@ impl App {
|
|||||||
home_dir: &self.home_dir,
|
home_dir: &self.home_dir,
|
||||||
pane_title: &pane_title,
|
pane_title: &pane_title,
|
||||||
pane: pane.as_ref(),
|
pane: pane.as_ref(),
|
||||||
|
structured_pane: structured_pane.as_ref(),
|
||||||
state: &self.state,
|
state: &self.state,
|
||||||
draft: self.draft.as_ref(),
|
draft: self.draft.as_ref(),
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
@@ -2805,6 +3025,53 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_response_selects_transport_before_catalog_event() {
|
||||||
|
use axum::routing::post;
|
||||||
|
use axum::Json;
|
||||||
|
|
||||||
|
async fn spawn_session(
|
||||||
|
Json(req): Json<CreateSessionRequest>,
|
||||||
|
) -> Json<maestro_protocol::Session> {
|
||||||
|
let id = match req.view {
|
||||||
|
SessionView::Terminal => Uuid::from_u128(187),
|
||||||
|
SessionView::Structured => Uuid::from_u128(188),
|
||||||
|
};
|
||||||
|
let mut session = test_session(id, req.cwd.to_str().unwrap());
|
||||||
|
session.view = req.view;
|
||||||
|
session.command = req.command;
|
||||||
|
Json(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
let router =
|
||||||
|
axum::Router::new().route(maestro_protocol::routes::SESSIONS, post(spawn_session));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
|
||||||
|
|
||||||
|
let mut app = make_app_at(&base);
|
||||||
|
let request = |view| CreateSessionRequest {
|
||||||
|
view,
|
||||||
|
cwd: std::path::PathBuf::from("/srv/work"),
|
||||||
|
command: "claude".into(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata: Default::default(),
|
||||||
|
worktree: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let structured_id = Uuid::from_u128(188);
|
||||||
|
app.submit_spawn(request(SessionView::Structured)).await;
|
||||||
|
assert!(app.catalog.snapshot().sessions.is_empty());
|
||||||
|
assert!(app.structured.contains_key(&structured_id));
|
||||||
|
assert!(app.emulators.get(structured_id).is_none());
|
||||||
|
|
||||||
|
let terminal_id = Uuid::from_u128(187);
|
||||||
|
app.submit_spawn(request(SessionView::Terminal)).await;
|
||||||
|
assert!(app.catalog.snapshot().sessions.is_empty());
|
||||||
|
assert!(app.emulators.get(terminal_id).is_some());
|
||||||
|
assert!(!app.structured.contains_key(&terminal_id));
|
||||||
|
}
|
||||||
|
|
||||||
/// The full data load against a live daemon stub: presets replace the
|
/// The full data load against a live daemon stub: presets replace the
|
||||||
/// built-ins, projects land sorted, and the first generated name seeds
|
/// built-ins, projects land sorted, and the first generated name seeds
|
||||||
/// the field.
|
/// the field.
|
||||||
@@ -3697,4 +3964,112 @@ mod tests {
|
|||||||
assert!(app.command_bar.is_none());
|
assert!(app.command_bar.is_none());
|
||||||
assert!(app.state.overlay().is_none());
|
assert!(app.state.overlay().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn structured_app(sid: Uuid) -> App {
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
let mut session = app.catalog.snapshot().sessions[0].clone();
|
||||||
|
session.view = SessionView::Structured;
|
||||||
|
app.catalog
|
||||||
|
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
||||||
|
app.structured.insert(
|
||||||
|
sid,
|
||||||
|
StructuredHandle::test_handle(crate::structured_task::StructuredState::default()),
|
||||||
|
);
|
||||||
|
app.state.attach_to_focused(sid);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_view_selects_the_structured_connection_and_cleanup_stops_it() {
|
||||||
|
let sid = Uuid::from_u128(141);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
let mut session = app.catalog.snapshot().sessions[0].clone();
|
||||||
|
session.view = SessionView::Structured;
|
||||||
|
app.catalog
|
||||||
|
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
||||||
|
|
||||||
|
app.attach(sid);
|
||||||
|
assert!(app.structured.contains_key(&sid));
|
||||||
|
assert!(app.emulators.get(sid).is_none());
|
||||||
|
let handle = app.structured.get(&sid).unwrap().clone();
|
||||||
|
app.release_session(sid);
|
||||||
|
assert_eq!(
|
||||||
|
handle.connection(),
|
||||||
|
crate::session_task::SessionState::Ended
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_normal_mode_routes_navigation_expansion_and_approval_by_row() {
|
||||||
|
use maestro_protocol::{ReducedState, TranscriptRow, TranscriptRowKind};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(141);
|
||||||
|
let mut app = structured_app(sid);
|
||||||
|
app.dispatch(Action::AttachHighlighted);
|
||||||
|
assert_eq!(app.state.focus, Focus::Pane);
|
||||||
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
|
let handle = app.structured.get(&sid).unwrap().clone();
|
||||||
|
{
|
||||||
|
let mut state = handle.state.lock().unwrap();
|
||||||
|
state.rows = vec![
|
||||||
|
TranscriptRow {
|
||||||
|
id: "text".into(),
|
||||||
|
kind: TranscriptRowKind::AgentText,
|
||||||
|
data: json!({"content":{"type":"text","text":"hello"}}),
|
||||||
|
},
|
||||||
|
TranscriptRow {
|
||||||
|
id: "approval".into(),
|
||||||
|
kind: TranscriptRowKind::Approval,
|
||||||
|
data: json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.selected = Some("text".into());
|
||||||
|
state.reduced = ReducedState {
|
||||||
|
pending_approval: Some(json!({"nonce":"n"})),
|
||||||
|
..ReducedState::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.handle_structured_key(&InputKey::char('j')),
|
||||||
|
Some(vec![])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.handle_structured_key(&InputKey::char('a')),
|
||||||
|
Some(vec![Effect::StructuredApproval {
|
||||||
|
id: sid,
|
||||||
|
nonce: "n".into(),
|
||||||
|
decision: StructuredApprovalDecision::AllowOnce,
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
handle.state.lock().unwrap().selected.as_deref(),
|
||||||
|
Some("approval")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_insert_mode_edits_submits_and_cancels_without_pty_bytes() {
|
||||||
|
let sid = Uuid::from_u128(141);
|
||||||
|
let mut app = structured_app(sid);
|
||||||
|
assert!(app.emulators.get(sid).is_none());
|
||||||
|
app.forward_to_session(&InputKey::char('h'));
|
||||||
|
app.forward_to_session(&InputKey::char('i'));
|
||||||
|
assert_eq!(
|
||||||
|
app.forward_to_session(&InputKey::Enter),
|
||||||
|
Some(Effect::StructuredPrompt {
|
||||||
|
id: sid,
|
||||||
|
prompt: "hi".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.handle_structured_key(&InputKey::Esc),
|
||||||
|
Some(vec![Effect::StructuredCancel { id: sid }])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.forward_literal_to_terminal(&app.cfg.leader.clone()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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 {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ pub mod render;
|
|||||||
pub mod session_task;
|
pub mod session_task;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod structured_pane;
|
||||||
|
pub mod structured_task;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod text_input;
|
pub mod text_input;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::palette;
|
|||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
|
use crate::structured_pane::StructuredPane;
|
||||||
|
|
||||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
/// coordinate to one of these via [`RegionMap::region_at`].
|
||||||
@@ -80,6 +81,7 @@ pub struct RenderInputs<'a> {
|
|||||||
pub home_dir: &'a str,
|
pub home_dir: &'a str,
|
||||||
pub pane_title: &'a str,
|
pub pane_title: &'a str,
|
||||||
pub pane: Option<&'a Pane>,
|
pub pane: Option<&'a Pane>,
|
||||||
|
pub structured_pane: Option<&'a StructuredPane>,
|
||||||
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
||||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
@@ -147,14 +149,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||||
if let Some(p) = inputs.pane {
|
if let Some(p) = inputs.pane {
|
||||||
p.render(f, pane_area);
|
p.render(f, pane_area);
|
||||||
|
} else if let Some(p) = inputs.structured_pane {
|
||||||
|
p.render(f, pane_area);
|
||||||
}
|
}
|
||||||
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||||
// chrome; every remaining cell belongs to the emulator.
|
// chrome; every remaining cell belongs to the active session pane.
|
||||||
map.pane = Some(pane_area);
|
map.pane = Some(pane_area);
|
||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
// always see the current mode + key hints.
|
// always see the current mode + key hints.
|
||||||
render_status_bar(f, status_area, inputs.state, inputs.link);
|
render_status_bar(
|
||||||
|
f,
|
||||||
|
status_area,
|
||||||
|
inputs.state,
|
||||||
|
inputs.link,
|
||||||
|
inputs.structured_pane.is_some(),
|
||||||
|
);
|
||||||
|
|
||||||
match inputs.state.overlay() {
|
match inputs.state.overlay() {
|
||||||
Some(OverlayKind::Spawn) => {
|
Some(OverlayKind::Spawn) => {
|
||||||
@@ -253,7 +263,13 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
|||||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||||
/// mode + focused panel + the most useful key hints so the user is never
|
/// mode + focused panel + the most useful key hints so the user is never
|
||||||
/// guessing what state they're in.
|
/// guessing what state they're in.
|
||||||
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) {
|
fn render_status_bar(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
state: &AppState,
|
||||||
|
link: &LinkStatus,
|
||||||
|
structured: bool,
|
||||||
|
) {
|
||||||
// The daemon indicator is pinned right and sized to its own text, so a
|
// The daemon indicator is pinned right and sized to its own text, so a
|
||||||
// narrow terminal truncates the key hints rather than the release.
|
// narrow terminal truncates the key hints rather than the release.
|
||||||
let label = link_label(link);
|
let label = link_label(link);
|
||||||
@@ -271,9 +287,13 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkSta
|
|||||||
Focus::Sidebar => "sidebar",
|
Focus::Sidebar => "sidebar",
|
||||||
Focus::Pane => "pane",
|
Focus::Pane => "pane",
|
||||||
};
|
};
|
||||||
let hint = match state.mode {
|
let hint = match (structured && state.focus == Focus::Pane, state.mode) {
|
||||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
(true, Mode::Normal) => "j/k: rows • enter: expand • i: compose • esc: cancel",
|
||||||
Mode::Insert => "^a: return to sidebar",
|
(true, Mode::Insert) => "enter: send • esc: cancel • ^a: sidebar",
|
||||||
|
(false, Mode::Normal) => {
|
||||||
|
"j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit"
|
||||||
|
}
|
||||||
|
(false, Mode::Insert) => "^a: return to sidebar",
|
||||||
};
|
};
|
||||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||||
let line = ratatui::text::Line::from(vec![
|
let line = ratatui::text::Line::from(vec![
|
||||||
@@ -435,6 +455,7 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
|
structured_pane: None,
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -532,6 +553,7 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
|
structured_pane: None,
|
||||||
draft: Some(&draft),
|
draft: Some(&draft),
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -587,6 +609,7 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
|
structured_pane: None,
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -629,6 +652,7 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
|
structured_pane: None,
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ impl SessionTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jitter(base: Duration) -> Duration {
|
pub(crate) fn jitter(base: Duration) -> Duration {
|
||||||
let ms = base.as_millis() as u64;
|
let ms = base.as_millis() as u64;
|
||||||
let now_nanos = std::time::SystemTime::now()
|
let now_nanos = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,742 @@
|
|||||||
|
//! Live projection state for one structured session.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use maestro_protocol::{
|
||||||
|
ReducedState, StructuredApprovalDecision, StructuredEvent, TranscriptDelta, TranscriptRow,
|
||||||
|
TranscriptRowKind,
|
||||||
|
};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tokio_tungstenite::tungstenite::{Error as WsError, Message};
|
||||||
|
|
||||||
|
use crate::input::InputKey;
|
||||||
|
use crate::session_task::SessionState;
|
||||||
|
use crate::text_input::TextInput;
|
||||||
|
|
||||||
|
/// Renderer and input state built directly from the daemon's two projections.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StructuredState {
|
||||||
|
pub rows: Vec<TranscriptRow>,
|
||||||
|
pub reduced: ReducedState,
|
||||||
|
pub selected: Option<String>,
|
||||||
|
pub expanded: HashSet<String>,
|
||||||
|
pub scroll: usize,
|
||||||
|
/// Row id per rendered line from the last render pass, parallel to that
|
||||||
|
/// pass's `lines`. Lets the next render translate `scroll` — a raw index
|
||||||
|
/// into that now-stale layout — back into a row identity before mapping
|
||||||
|
/// it onto the freshly rebuilt layout, so growth/shrinkage above the
|
||||||
|
/// viewport shifts indices without shifting what's on screen.
|
||||||
|
pub(crate) line_rows: Vec<Option<String>>,
|
||||||
|
pub follow_selection: bool,
|
||||||
|
pub composer: TextInput,
|
||||||
|
pub error: Option<String>,
|
||||||
|
submitting: bool,
|
||||||
|
submission_generation: u64,
|
||||||
|
reconcile_submission_on_snapshot: Option<u64>,
|
||||||
|
approval_submitting: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StructuredState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rows: Vec::new(),
|
||||||
|
reduced: ReducedState::default(),
|
||||||
|
selected: None,
|
||||||
|
expanded: HashSet::new(),
|
||||||
|
scroll: 0,
|
||||||
|
line_rows: Vec::new(),
|
||||||
|
follow_selection: true,
|
||||||
|
composer: TextInput::default(),
|
||||||
|
error: None,
|
||||||
|
submitting: false,
|
||||||
|
submission_generation: 0,
|
||||||
|
reconcile_submission_on_snapshot: None,
|
||||||
|
approval_submitting: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StructuredState {
|
||||||
|
pub fn apply(&mut self, event: StructuredEvent) {
|
||||||
|
match event {
|
||||||
|
StructuredEvent::TranscriptSnapshot { snapshot } => {
|
||||||
|
let selected = self.selected.clone();
|
||||||
|
self.rows = snapshot.rows;
|
||||||
|
self.selected = selected
|
||||||
|
.filter(|id| self.rows.iter().any(|row| &row.id == id))
|
||||||
|
.or_else(|| {
|
||||||
|
self.rows
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|row| row.kind != TranscriptRowKind::Plan)
|
||||||
|
.map(|row| row.id.clone())
|
||||||
|
});
|
||||||
|
self.expanded
|
||||||
|
.retain(|id| self.rows.iter().any(|row| &row.id == id));
|
||||||
|
}
|
||||||
|
StructuredEvent::ReducedState { state } => {
|
||||||
|
let reconcile_submission = self
|
||||||
|
.reconcile_submission_on_snapshot
|
||||||
|
.take()
|
||||||
|
.is_some_and(|generation| {
|
||||||
|
self.submitting && generation == self.submission_generation
|
||||||
|
});
|
||||||
|
self.apply_reduced(state, reconcile_submission);
|
||||||
|
}
|
||||||
|
StructuredEvent::ProjectionUpdate { transcript, state } => {
|
||||||
|
if let Some(delta) = transcript {
|
||||||
|
self.apply_delta(delta);
|
||||||
|
}
|
||||||
|
self.apply_reduced(state, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_delta(&mut self, delta: TranscriptDelta) {
|
||||||
|
match delta {
|
||||||
|
TranscriptDelta::Append { row } => {
|
||||||
|
let followed_tail = self.follow_selection
|
||||||
|
&& self.selected.as_ref().is_some_and(|id| {
|
||||||
|
self.rows
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|old| old.kind != TranscriptRowKind::Plan)
|
||||||
|
.is_some_and(|old| &old.id == id)
|
||||||
|
});
|
||||||
|
if let Some(index) = self.rows.iter().position(|old| old.id == row.id) {
|
||||||
|
self.rows[index] = row.clone();
|
||||||
|
} else {
|
||||||
|
self.rows.push(row.clone());
|
||||||
|
}
|
||||||
|
if row.kind != TranscriptRowKind::Plan && (self.selected.is_none() || followed_tail)
|
||||||
|
{
|
||||||
|
self.selected = Some(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TranscriptDelta::Patch { row } => {
|
||||||
|
if let Some(index) = self.rows.iter().position(|old| old.id == row.id) {
|
||||||
|
self.rows[index] = row.clone();
|
||||||
|
} else {
|
||||||
|
self.rows.push(row.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TranscriptDelta::Remove { id } => {
|
||||||
|
let removed = self.rows.iter().position(|row| row.id == id);
|
||||||
|
self.rows.retain(|row| row.id != id);
|
||||||
|
self.expanded.remove(&id);
|
||||||
|
if self.selected.as_deref() == Some(&id) {
|
||||||
|
self.selected = removed.and_then(|index| {
|
||||||
|
let split = index.min(self.rows.len());
|
||||||
|
self.rows[split..]
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.kind != TranscriptRowKind::Plan)
|
||||||
|
.or_else(|| {
|
||||||
|
self.rows[..split]
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|row| row.kind != TranscriptRowKind::Plan)
|
||||||
|
})
|
||||||
|
.map(|row| row.id.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_reduced(&mut self, state: ReducedState, reconcile_submission: bool) {
|
||||||
|
let previous_nonce = self.pending_nonce().map(str::to_owned);
|
||||||
|
if reconcile_submission || state.turn.responding || state.turn.thinking {
|
||||||
|
self.submitting = false;
|
||||||
|
}
|
||||||
|
self.reduced = state;
|
||||||
|
let next_nonce = self.pending_nonce().map(str::to_owned);
|
||||||
|
if next_nonce != previous_nonce {
|
||||||
|
self.approval_submitting = false;
|
||||||
|
if next_nonce.is_some() {
|
||||||
|
self.selected = self
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|row| row.kind == TranscriptRowKind::Approval)
|
||||||
|
.map(|row| row.id.clone());
|
||||||
|
self.follow_selection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_snapshot_reconciliation(&mut self) {
|
||||||
|
self.reconcile_submission_on_snapshot =
|
||||||
|
self.submitting.then_some(self.submission_generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, delta: isize) {
|
||||||
|
let selectable: Vec<&TranscriptRow> = self
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.kind != TranscriptRowKind::Plan)
|
||||||
|
.collect();
|
||||||
|
if selectable.is_empty() {
|
||||||
|
self.selected = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current = self
|
||||||
|
.selected
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|id| selectable.iter().position(|row| &row.id == id))
|
||||||
|
.unwrap_or(selectable.len() - 1);
|
||||||
|
let next = if delta >= 0 {
|
||||||
|
current
|
||||||
|
.saturating_add(delta as usize)
|
||||||
|
.min(selectable.len() - 1)
|
||||||
|
} else {
|
||||||
|
current.saturating_sub(delta.unsigned_abs())
|
||||||
|
};
|
||||||
|
self.selected = Some(selectable[next].id.clone());
|
||||||
|
self.follow_selection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_selected(&mut self) {
|
||||||
|
let Some(id) = self.selected.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let expandable = self.rows.iter().any(|row| {
|
||||||
|
row.id == id
|
||||||
|
&& matches!(
|
||||||
|
row.kind,
|
||||||
|
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if !expandable {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.expanded.insert(id.clone()) {
|
||||||
|
self.expanded.remove(&id);
|
||||||
|
}
|
||||||
|
self.follow_selection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_by(&mut self, lines: isize) {
|
||||||
|
self.scroll = if lines >= 0 {
|
||||||
|
self.scroll.saturating_sub(lines as usize)
|
||||||
|
} else {
|
||||||
|
self.scroll.saturating_add(lines.unsigned_abs())
|
||||||
|
};
|
||||||
|
self.follow_selection = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_composer(&mut self, key: &InputKey) {
|
||||||
|
if !self.turn_active() {
|
||||||
|
self.composer.handle_key(key);
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin_prompt(&mut self) -> Option<String> {
|
||||||
|
if self.turn_active() || self.composer.value().trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let prompt = self.composer.value().to_string();
|
||||||
|
self.composer = TextInput::default();
|
||||||
|
self.submission_generation = self.submission_generation.wrapping_add(1);
|
||||||
|
self.submitting = true;
|
||||||
|
self.error = None;
|
||||||
|
Some(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_failed(&mut self, prompt: String, error: String) {
|
||||||
|
if self.composer.value().is_empty() {
|
||||||
|
self.composer = TextInput::with_value(prompt);
|
||||||
|
}
|
||||||
|
self.submitting = false;
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn turn_active(&self) -> bool {
|
||||||
|
self.submitting || self.reduced.turn.responding || self.reduced.turn.thinking
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_nonce(&self) -> Option<&str> {
|
||||||
|
self.reduced
|
||||||
|
.pending_approval
|
||||||
|
.as_ref()?
|
||||||
|
.get("nonce")?
|
||||||
|
.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn approval_decision(&mut self, key: char) -> Option<(String, StructuredApprovalDecision)> {
|
||||||
|
if self.approval_submitting {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let selected = self.selected.as_deref()?;
|
||||||
|
let pending_row = self
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|row| row.kind == TranscriptRowKind::Approval)?;
|
||||||
|
if pending_row.id != selected {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let decision = match key {
|
||||||
|
'a' => StructuredApprovalDecision::AllowOnce,
|
||||||
|
'A' => StructuredApprovalDecision::AllowForSession,
|
||||||
|
'd' => StructuredApprovalDecision::Deny,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let offered = offered_decisions(&pending_row.data);
|
||||||
|
if !offered.contains(&decision) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let nonce = self.pending_nonce()?.to_string();
|
||||||
|
self.approval_submitting = true;
|
||||||
|
self.error = None;
|
||||||
|
Some((nonce, decision))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn approval_failed(&mut self, nonce: &str, error: String) {
|
||||||
|
if self.pending_nonce() != Some(nonce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.approval_submitting = false;
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offered_decisions(data: &serde_json::Value) -> Vec<StructuredApprovalDecision> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for kind in data
|
||||||
|
.get("options")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|option| option.get("kind").and_then(serde_json::Value::as_str))
|
||||||
|
{
|
||||||
|
match kind {
|
||||||
|
"allow_once" if !out.contains(&StructuredApprovalDecision::AllowOnce) => {
|
||||||
|
out.push(StructuredApprovalDecision::AllowOnce)
|
||||||
|
}
|
||||||
|
"allow_always" if !out.contains(&StructuredApprovalDecision::AllowForSession) => {
|
||||||
|
out.push(StructuredApprovalDecision::AllowForSession)
|
||||||
|
}
|
||||||
|
"reject_once" | "reject_always" if !out.contains(&StructuredApprovalDecision::Deny) => {
|
||||||
|
out.push(StructuredApprovalDecision::Deny)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap shared handle for a structured projection connection.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StructuredHandle {
|
||||||
|
pub state: Arc<Mutex<StructuredState>>,
|
||||||
|
connection: Arc<AtomicU8>,
|
||||||
|
revision: Arc<AtomicU64>,
|
||||||
|
stop: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for StructuredHandle {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("StructuredHandle")
|
||||||
|
.field("connection", &self.connection())
|
||||||
|
.field("revision", &self.revision())
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StructuredHandle {
|
||||||
|
pub fn connection(&self) -> SessionState {
|
||||||
|
match self.connection.load(Ordering::Acquire) {
|
||||||
|
0 => SessionState::Connecting,
|
||||||
|
1 => SessionState::Live,
|
||||||
|
2 => SessionState::Reconnecting,
|
||||||
|
_ => SessionState::Ended,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revision(&self) -> u64 {
|
||||||
|
self.revision.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end(&self) {
|
||||||
|
self.connection
|
||||||
|
.store(SessionState::Ended as u8, Ordering::Release);
|
||||||
|
let _ = self.stop.send(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn test_handle(state: StructuredState) -> Self {
|
||||||
|
let (stop, _) = watch::channel(false);
|
||||||
|
Self {
|
||||||
|
state: Arc::new(Mutex::new(state)),
|
||||||
|
connection: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||||
|
revision: Arc::new(AtomicU64::new(0)),
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StructuredTask;
|
||||||
|
|
||||||
|
impl StructuredTask {
|
||||||
|
pub fn spawn(ws_url: String) -> StructuredHandle {
|
||||||
|
let state = Arc::new(Mutex::new(StructuredState::default()));
|
||||||
|
let connection = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
||||||
|
let revision = Arc::new(AtomicU64::new(0));
|
||||||
|
let (stop, mut stopped) = watch::channel(false);
|
||||||
|
let handle = StructuredHandle {
|
||||||
|
state: state.clone(),
|
||||||
|
connection: connection.clone(),
|
||||||
|
revision: revision.clone(),
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut backoff = Duration::from_secs(1);
|
||||||
|
loop {
|
||||||
|
if *stopped.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.lock()
|
||||||
|
.expect("structured state mutex")
|
||||||
|
.prepare_snapshot_reconciliation();
|
||||||
|
connection.store(SessionState::Connecting as u8, Ordering::Release);
|
||||||
|
let connected = tokio::select! {
|
||||||
|
result = tokio_tungstenite::connect_async(&ws_url) => result,
|
||||||
|
_ = stopped.changed() => return,
|
||||||
|
};
|
||||||
|
let (mut ws, _) = match connected {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
if terminal_handshake_error(&error) {
|
||||||
|
state.lock().expect("structured state mutex").error =
|
||||||
|
Some(format!("cannot open structured session: {error}"));
|
||||||
|
connection.store(SessionState::Ended as u8, Ordering::Release);
|
||||||
|
revision.fetch_add(1, Ordering::Release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection.store(SessionState::Reconnecting as u8, Ordering::Release);
|
||||||
|
revision.fetch_add(1, Ordering::Release);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(super::session_task::jitter(backoff)) => {},
|
||||||
|
_ = stopped.changed() => return,
|
||||||
|
}
|
||||||
|
backoff = backoff.saturating_mul(2).min(Duration::from_secs(30));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
backoff = Duration::from_secs(1);
|
||||||
|
connection.store(SessionState::Live as u8, Ordering::Release);
|
||||||
|
revision.fetch_add(1, Ordering::Release);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let frame = tokio::select! {
|
||||||
|
frame = ws.next() => frame,
|
||||||
|
_ = stopped.changed() => return,
|
||||||
|
};
|
||||||
|
match frame {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
if let Ok(event) = serde_json::from_str::<StructuredEvent>(&text) {
|
||||||
|
state.lock().expect("structured state mutex").apply(event);
|
||||||
|
revision.fetch_add(1, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.store(SessionState::Reconnecting as u8, Ordering::Release);
|
||||||
|
revision.fetch_add(1, Ordering::Release);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(super::session_task::jitter(backoff)) => {},
|
||||||
|
_ = stopped.changed() => return,
|
||||||
|
}
|
||||||
|
backoff = backoff.saturating_mul(2).min(Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_handshake_error(error: &WsError) -> bool {
|
||||||
|
matches!(error, WsError::Http(response) if response.status().is_client_error())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use maestro_protocol::{TranscriptSnapshot, TurnState};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn row(id: &str, kind: TranscriptRowKind, data: serde_json::Value) -> TranscriptRow {
|
||||||
|
TranscriptRow {
|
||||||
|
id: id.into(),
|
||||||
|
kind,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshots_replace_and_deltas_address_rows_by_id() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||||
|
snapshot: TranscriptSnapshot {
|
||||||
|
cursor: 2,
|
||||||
|
rows: vec![row("a", TranscriptRowKind::AgentText, json!({}))],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.apply(StructuredEvent::ProjectionUpdate {
|
||||||
|
transcript: Some(TranscriptDelta::Patch {
|
||||||
|
row: row("a", TranscriptRowKind::AgentText, json!({"n": 2})),
|
||||||
|
}),
|
||||||
|
state: ReducedState::default(),
|
||||||
|
});
|
||||||
|
state.apply(StructuredEvent::ProjectionUpdate {
|
||||||
|
transcript: Some(TranscriptDelta::Append {
|
||||||
|
row: row("b", TranscriptRowKind::Notice, json!({})),
|
||||||
|
}),
|
||||||
|
state: ReducedState::default(),
|
||||||
|
});
|
||||||
|
assert_eq!(state.rows.len(), 2);
|
||||||
|
assert_eq!(state.rows[0].data["n"], 2);
|
||||||
|
|
||||||
|
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||||
|
snapshot: TranscriptSnapshot {
|
||||||
|
cursor: 5,
|
||||||
|
rows: vec![row("b", TranscriptRowKind::Notice, json!({"fresh": true}))],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert_eq!(state.rows.len(), 1);
|
||||||
|
assert_eq!(state.rows[0].data["fresh"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_survives_patch_and_moves_after_remove() {
|
||||||
|
let mut state = StructuredState {
|
||||||
|
rows: vec![
|
||||||
|
row("a", TranscriptRowKind::Notice, json!({})),
|
||||||
|
row("b", TranscriptRowKind::ToolCall, json!({})),
|
||||||
|
row("plan", TranscriptRowKind::Plan, json!({})),
|
||||||
|
],
|
||||||
|
selected: Some("a".into()),
|
||||||
|
..StructuredState::default()
|
||||||
|
};
|
||||||
|
state.select(1);
|
||||||
|
assert_eq!(state.selected.as_deref(), Some("b"));
|
||||||
|
state.toggle_selected();
|
||||||
|
assert!(state.expanded.contains("b"));
|
||||||
|
state.apply_delta(TranscriptDelta::Remove { id: "b".into() });
|
||||||
|
assert_eq!(state.selected.as_deref(), Some("a"));
|
||||||
|
|
||||||
|
state.apply_delta(TranscriptDelta::Remove { id: "a".into() });
|
||||||
|
assert_eq!(
|
||||||
|
state.selected, None,
|
||||||
|
"collapsed plan rows are not selectable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_keys_only_offer_server_backed_decisions() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:1",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[
|
||||||
|
{"kind":"allow_once"}, {"kind":"reject_always"}
|
||||||
|
]}),
|
||||||
|
));
|
||||||
|
state.selected = Some("approval:1".into());
|
||||||
|
state.reduced.pending_approval = Some(json!({"nonce":"secret"}));
|
||||||
|
assert_eq!(
|
||||||
|
state.approval_decision('A'),
|
||||||
|
None,
|
||||||
|
"allow-for-session was not offered"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.approval_decision('a'),
|
||||||
|
Some(("secret".into(), StructuredApprovalDecision::AllowOnce))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
||||||
|
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.edit_composer(&InputKey::char('h'));
|
||||||
|
state.edit_composer(&InputKey::char('i'));
|
||||||
|
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
||||||
|
assert!(state.turn_active());
|
||||||
|
state.prompt_failed("hi".into(), "nope".into());
|
||||||
|
assert_eq!(state.composer.value(), "hi");
|
||||||
|
|
||||||
|
state.reduced.turn = TurnState {
|
||||||
|
responding: true,
|
||||||
|
..TurnState::default()
|
||||||
|
};
|
||||||
|
state.edit_composer(&InputKey::char('x'));
|
||||||
|
assert_eq!(state.composer.value(), "hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.edit_composer(&InputKey::char('h'));
|
||||||
|
state.edit_composer(&InputKey::char('i'));
|
||||||
|
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
||||||
|
|
||||||
|
state.apply(StructuredEvent::ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: ReducedState::default(),
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
state.turn_active(),
|
||||||
|
"an ordinary idle update must not reopen the composer before turn begin"
|
||||||
|
);
|
||||||
|
|
||||||
|
state.prepare_snapshot_reconciliation();
|
||||||
|
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||||
|
snapshot: TranscriptSnapshot {
|
||||||
|
cursor: 0,
|
||||||
|
rows: Vec::new(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.apply(StructuredEvent::ReducedState {
|
||||||
|
state: ReducedState::default(),
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!state.turn_active(),
|
||||||
|
"a reconnect snapshot must settle a submission whose whole turn was missed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_handshake_state_does_not_settle_a_new_submission() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.prepare_snapshot_reconciliation();
|
||||||
|
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||||
|
snapshot: TranscriptSnapshot {
|
||||||
|
cursor: 0,
|
||||||
|
rows: Vec::new(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
state.edit_composer(&InputKey::char('h'));
|
||||||
|
state.edit_composer(&InputKey::char('i'));
|
||||||
|
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
||||||
|
state.apply(StructuredEvent::ReducedState {
|
||||||
|
state: ReducedState::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state.turn_active(),
|
||||||
|
"state captured before this submission must not reopen the composer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_submission_reconciliation_does_not_settle_its_successor() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.edit_composer(&InputKey::char('a'));
|
||||||
|
assert_eq!(state.begin_prompt().as_deref(), Some("a"));
|
||||||
|
state.prepare_snapshot_reconciliation();
|
||||||
|
|
||||||
|
state.prompt_failed("a".into(), "nope".into());
|
||||||
|
state.composer = TextInput::with_value("b");
|
||||||
|
assert_eq!(state.begin_prompt().as_deref(), Some("b"));
|
||||||
|
|
||||||
|
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||||
|
snapshot: TranscriptSnapshot {
|
||||||
|
cursor: 0,
|
||||||
|
rows: Vec::new(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.apply(StructuredEvent::ReducedState {
|
||||||
|
state: ReducedState::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state.turn_active(),
|
||||||
|
"a reconnect for the failed submission must not settle its successor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! The TUI's structured actions use the shared typed daemon client; assert the
|
||||||
|
//! exact route and bare approval decision body at the HTTP boundary.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::routing::post;
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use maestro_protocol::{DaemonClient, StructuredApprovalDecision, StructuredPromptRequest};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Seen {
|
||||||
|
prompt: Option<String>,
|
||||||
|
cancelled: bool,
|
||||||
|
approval: Option<(String, StructuredApprovalDecision)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shared = Arc<Mutex<Seen>>;
|
||||||
|
|
||||||
|
async fn prompt(
|
||||||
|
State(seen): State<Shared>,
|
||||||
|
Path(_id): Path<Uuid>,
|
||||||
|
Json(body): Json<StructuredPromptRequest>,
|
||||||
|
) {
|
||||||
|
seen.lock().unwrap().prompt = Some(body.prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel(State(seen): State<Shared>, Path(_id): Path<Uuid>) {
|
||||||
|
seen.lock().unwrap().cancelled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approval(
|
||||||
|
State(seen): State<Shared>,
|
||||||
|
Path((_id, nonce)): Path<(Uuid, String)>,
|
||||||
|
Json(decision): Json<StructuredApprovalDecision>,
|
||||||
|
) {
|
||||||
|
seen.lock().unwrap().approval = Some((nonce, decision));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn prompt_cancel_and_approval_use_structured_api_contract() {
|
||||||
|
let seen = Shared::default();
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/sessions/:id/structured/prompt", post(prompt))
|
||||||
|
.route("/api/sessions/:id/structured/cancel", post(cancel))
|
||||||
|
.route(
|
||||||
|
"/api/sessions/:id/structured/approvals/:nonce",
|
||||||
|
post(approval),
|
||||||
|
)
|
||||||
|
.with_state(seen.clone());
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||||
|
|
||||||
|
let client = DaemonClient::new(format!("http://{addr}"));
|
||||||
|
let id = Uuid::from_u128(141);
|
||||||
|
client.send_structured_prompt(id, "ship it").await.unwrap();
|
||||||
|
client.cancel_structured_turn(id).await.unwrap();
|
||||||
|
client
|
||||||
|
.resolve_structured_approval(
|
||||||
|
id,
|
||||||
|
"one-use-nonce",
|
||||||
|
StructuredApprovalDecision::AllowForSession,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let seen = seen.lock().unwrap();
|
||||||
|
assert_eq!(seen.prompt.as_deref(), Some("ship it"));
|
||||||
|
assert!(seen.cancelled);
|
||||||
|
assert_eq!(
|
||||||
|
seen.approval,
|
||||||
|
Some((
|
||||||
|
"one-use-nonce".into(),
|
||||||
|
StructuredApprovalDecision::AllowForSession
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Projection socket coverage: snapshots replace state on reconnect and live
|
||||||
|
//! deltas patch rows without client-side event folding.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocketUpgrade};
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::routing::any;
|
||||||
|
use axum::Router;
|
||||||
|
use maestro_protocol::{
|
||||||
|
ReducedState, StructuredEvent, TranscriptDelta, TranscriptRow, TranscriptRowKind,
|
||||||
|
TranscriptSnapshot,
|
||||||
|
};
|
||||||
|
use maestro_tui::session_task::SessionState;
|
||||||
|
use maestro_tui::structured_task::StructuredTask;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
async fn structured_handler(
|
||||||
|
State(connects): State<Arc<AtomicUsize>>,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
) -> Response {
|
||||||
|
ws.on_upgrade(move |mut socket| async move {
|
||||||
|
let attempt = connects.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let snapshot = if attempt == 0 {
|
||||||
|
TranscriptSnapshot {
|
||||||
|
cursor: 1,
|
||||||
|
rows: vec![row("old", json!({"content":{"type":"text","text":"old"}}))],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TranscriptSnapshot {
|
||||||
|
cursor: 3,
|
||||||
|
rows: vec![row("live", json!({"content":{"type":"text","text":"one"}}))],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
send(
|
||||||
|
&mut socket,
|
||||||
|
StructuredEvent::TranscriptSnapshot { snapshot },
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if attempt == 0 {
|
||||||
|
let _ = socket.close().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(
|
||||||
|
&mut socket,
|
||||||
|
StructuredEvent::ProjectionUpdate {
|
||||||
|
transcript: Some(TranscriptDelta::Patch {
|
||||||
|
row: row("live", json!({"content":{"type":"text","text":"two"}})),
|
||||||
|
}),
|
||||||
|
state: ReducedState {
|
||||||
|
model: Some("test-model".into()),
|
||||||
|
..ReducedState::default()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(id: &str, data: serde_json::Value) -> TranscriptRow {
|
||||||
|
TranscriptRow {
|
||||||
|
id: id.into(),
|
||||||
|
kind: TranscriptRowKind::AgentText,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(socket: &mut axum::extract::ws::WebSocket, event: StructuredEvent) {
|
||||||
|
socket
|
||||||
|
.send(Message::Text(serde_json::to_string(&event).unwrap()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn reconnect_snapshot_replaces_then_delta_patches_projection() {
|
||||||
|
let connects = Arc::new(AtomicUsize::new(0));
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/structured/x", any(structured_handler))
|
||||||
|
.with_state(connects.clone());
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||||
|
|
||||||
|
let handle = StructuredTask::spawn(format!("ws://{addr}/api/structured/x"));
|
||||||
|
for _ in 0..250 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
let state = handle.state.lock().unwrap().clone();
|
||||||
|
if state.reduced.model.as_deref() == Some("test-model") {
|
||||||
|
assert_eq!(state.rows.len(), 1);
|
||||||
|
assert_eq!(state.rows[0].id, "live");
|
||||||
|
assert_eq!(state.rows[0].data["content"]["text"], "two");
|
||||||
|
assert!(connects.load(Ordering::SeqCst) >= 2);
|
||||||
|
handle.end();
|
||||||
|
assert_eq!(handle.connection(), SessionState::Ended);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!(
|
||||||
|
"structured projection never reached reconnect delta; connection={:?}",
|
||||||
|
handle.connection()
|
||||||
|
);
|
||||||
|
}
|
||||||
+15
-5
@@ -18,10 +18,14 @@ wizard, and cleans up — only the unified `maestro` binary ends up persisted in
|
|||||||
|
|
||||||
**Updating is the same command.** Re-running `./install.sh` detects an existing
|
**Updating is the same command.** Re-running `./install.sh` detects an existing
|
||||||
systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead
|
systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead
|
||||||
of `start`. No `--update` flag needed. **The restart keeps your sessions:** the unit sets
|
of `start`. No `--update` flag needed. **The restart keeps your terminal sessions:** the unit sets
|
||||||
`KillMode=process`, so systemd signals only the daemon — the tmux server (and
|
`KillMode=process`, so systemd signals only the daemon — the tmux server (and
|
||||||
every session) survives, and the new daemon re-adopts them on start. A unit
|
every tmux-backed session) survives, and the new daemon re-adopts them on
|
||||||
written before this default lacks the line; re-run `./install.sh` to refresh it.
|
start. Structured sessions (an agent run as a daemon child over ACP) are not
|
||||||
|
tmux panes, so shutdown ends their active turn; the transcript survives and
|
||||||
|
the next daemon attempts one resume (`docs/RESTART.md#structured-sessions`). A
|
||||||
|
unit written before this default lacks the line; re-run `./install.sh` to
|
||||||
|
refresh it.
|
||||||
|
|
||||||
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
|
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
|
||||||
uses your already-configured git credentials for this host (HTTPS helper or
|
uses your already-configured git credentials for this host (HTTPS helper or
|
||||||
@@ -94,7 +98,10 @@ running a local daemon, for a host that only talks to another machine's.
|
|||||||
|
|
||||||
The daemon unit carries the same `KillMode=process` as the wizard-written one,
|
The daemon unit carries the same `KillMode=process` as the wizard-written one,
|
||||||
so `systemctl --user restart maestro-daemon` leaves the tmux server and every
|
so `systemctl --user restart maestro-daemon` leaves the tmux server and every
|
||||||
session alive.
|
terminal session alive. Structured sessions end their active turn on
|
||||||
|
shutdown and resume once on the next daemon: ACP state comes back via
|
||||||
|
`session/load`, while the event log only replays Maestro's own transcript
|
||||||
|
projection (`docs/RESTART.md#structured-sessions`).
|
||||||
|
|
||||||
## Thin builds for single-role hosts
|
## Thin builds for single-role hosts
|
||||||
|
|
||||||
@@ -211,7 +218,10 @@ what to run. The adapter is a child of the daemon: it ends with a `DELETE
|
|||||||
itself. A daemon restart ends an in-flight structured turn, but preserves its
|
itself. A daemon restart ends an in-flight structured turn, but preserves its
|
||||||
worktree, session record, and event-log transcript. The replacement daemon
|
worktree, session record, and event-log transcript. The replacement daemon
|
||||||
starts a new adapter, resumes it with `session/load`, and replays the
|
starts a new adapter, resumes it with `session/load`, and replays the
|
||||||
transcript. Terminal sessions keep surviving restarts through tmux.
|
transcript. Terminal sessions keep surviving restarts through tmux. See
|
||||||
|
`docs/STRUCTURED-VIEW.md` for the full contract — row kinds, projections, the
|
||||||
|
handshake, and the approval and recovery rules — and `docs/RESTART.md` for
|
||||||
|
what a client observes across the restart.
|
||||||
|
|
||||||
## The browser UI
|
## The browser UI
|
||||||
|
|
||||||
|
|||||||
+39
-4
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
Maestro is upgraded in place: the operator reinstalls the binary and systemd
|
Maestro is upgraded in place: the operator reinstalls the binary and systemd
|
||||||
restarts the daemon under every browser tab and TUI that is attached to it.
|
restarts the daemon under every browser tab and TUI that is attached to it.
|
||||||
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
|
tmux keeps **terminal** sessions alive across that (see `docs/INSTALL.md`),
|
||||||
lose their sockets. This document is the contract for what a client does about
|
but clients lose their sockets either way. This document is the contract for
|
||||||
it — what the daemon publishes, how a client tells a restart from a blip, and
|
what a client does about it — what the daemon publishes, how a client tells a
|
||||||
what the user sees while it happens.
|
restart from a blip, and what the user sees while it happens.
|
||||||
|
|
||||||
|
A **structured** session (`docs/STRUCTURED-VIEW.md`) has no tmux pane behind
|
||||||
|
it to survive on: its agent is a child of the daemon process itself, so a
|
||||||
|
restart takes it down with the daemon. See
|
||||||
|
[Structured sessions](#structured-sessions) below for exactly what that
|
||||||
|
costs and what it doesn't.
|
||||||
|
|
||||||
## What the daemon publishes
|
## What the daemon publishes
|
||||||
|
|
||||||
@@ -68,6 +74,35 @@ resolves, so a blocked client is never a trapped one. It clears itself the
|
|||||||
moment a probe succeeds — there is no "retry" button, because the client is
|
moment a probe succeeds — there is no "retry" button, because the client is
|
||||||
already retrying.
|
already retrying.
|
||||||
|
|
||||||
|
## Structured sessions
|
||||||
|
|
||||||
|
Everything above describes a **client's** view of a restart, and it holds for
|
||||||
|
a structured session exactly as written — the same three verdicts, the same
|
||||||
|
status indicator, the same overlay. What differs is what survives on the
|
||||||
|
**daemon** side, because a structured session's agent is a daemon child, not
|
||||||
|
a tmux pane:
|
||||||
|
|
||||||
|
- A restart **ends the active turn**. There is no adapter process left to
|
||||||
|
finish it, and no tmux pane keeping it alive underneath.
|
||||||
|
- The **transcript survives**, in the per-session event log
|
||||||
|
(`docs/STRUCTURED-VIEW.md#persistence-recovery-and-dead-sessions`) — a
|
||||||
|
graceful shutdown marks the session for one resume attempt. Recovery is
|
||||||
|
two separate operations, not one: the next daemon calls `session/load` so
|
||||||
|
the adapter restores its own ACP session, but whatever history the
|
||||||
|
adapter emits during that call is discarded, not replayed into anything —
|
||||||
|
the daemon already has that history on disk. Separately, the daemon
|
||||||
|
rebuilds its own projection by folding the persisted event log back
|
||||||
|
through it. An ungraceful death (a crash, `kill -9`) leaves no resume
|
||||||
|
marker, and the session stays dead permanently.
|
||||||
|
- **Pending approvals and their nonces do not survive.** Both live only in
|
||||||
|
the old daemon's memory; any ACP permission request still mid-flight when
|
||||||
|
the daemon exits is answered `Cancelled` on the way down, and a resumed
|
||||||
|
session starts with no pending approval at all.
|
||||||
|
|
||||||
|
`docs/STRUCTURED-VIEW.md` is the full contract for all of this — the event
|
||||||
|
log's shape, the recovery marker, and exactly which kind of session death is
|
||||||
|
resumable.
|
||||||
|
|
||||||
## Timing
|
## Timing
|
||||||
|
|
||||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||||
|
|||||||
+22
-5
@@ -231,7 +231,24 @@ cutover points land within one tick.
|
|||||||
|
|
||||||
## Precedence
|
## Precedence
|
||||||
|
|
||||||
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
|
### Activity source by session kind
|
||||||
|
|
||||||
|
The daemon supplies one `activity` field to both clients, but its source depends
|
||||||
|
on the session kind:
|
||||||
|
|
||||||
|
| Session kind | Source |
|
||||||
|
|---|---|
|
||||||
|
| Terminal | The existing agent-report override and five-minute TTL, falling back to PTY quiescence, foreground process and `/proc` input-block inference. |
|
||||||
|
| Structured | ACP state directly: a pending approval is `AwaitingInput`; otherwise an in-flight turn is `Active`; no turn is `Idle`. A failed turn or dead structured session is `Error`, and the next turn clears a failed-turn error. |
|
||||||
|
|
||||||
|
Structured sessions never enter the terminal inference path and ignore activity
|
||||||
|
reports inherited through agent hooks. They have no PTY whose output or blocked
|
||||||
|
read could describe their state. This is a second source, not a replacement for
|
||||||
|
the terminal source.
|
||||||
|
|
||||||
|
### Terminal report override
|
||||||
|
|
||||||
|
For terminal sessions, an agent-reported `ActivityState` (#105) overrides the daemon's inference for
|
||||||
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
|
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
|
||||||
When the TTL expires, inference resumes and the session's reported detail —
|
When the TTL expires, inference resumes and the session's reported detail —
|
||||||
if any — clears with the report. Clients never blend the two: whichever source
|
if any — clears with the report. Clients never blend the two: whichever source
|
||||||
@@ -281,10 +298,10 @@ session is genuinely `Active` again — new work, not merely a refocus. Only
|
|||||||
after that fresh `Active` cycle does the *next* `AwaitingInput` glow again.
|
after that fresh `Active` cycle does the *next* `AwaitingInput` glow again.
|
||||||
Unfocusing and refocusing alone never re-arms it.
|
Unfocusing and refocusing alone never re-arms it.
|
||||||
|
|
||||||
This applies uniformly to `AwaitingInput` from either source — the daemon's
|
This applies uniformly to `AwaitingInput` from terminal inference, an agent
|
||||||
own inference or an agent's report (#105) — since the rail draws them
|
report (#105), or a structured session's pending approval, since the rail draws
|
||||||
identically. It does not touch `Error`; that state's own TTL (below) is
|
them identically. It does not touch `Error`; terminal reports keep their TTL,
|
||||||
unrelated and unaffected by focus.
|
while a structured failed-turn error clears when the next turn starts.
|
||||||
|
|
||||||
Each client reports its own pane's focus with `POST /api/sessions/:id/focus`
|
Each client reports its own pane's focus with `POST /api/sessions/:id/focus`
|
||||||
(`{"focused": bool}`) whenever the pane it is showing changes — on attach, on
|
(`{"focused": bool}`) whenever the pane it is showing changes — on attach, on
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
# STRUCTURED-VIEW.md — the structured session contract
|
||||||
|
|
||||||
|
The contract for a structured session (`view: Structured`, #133): an agent run
|
||||||
|
as a daemon child over the Agent Client Protocol (ACP) instead of in a tmux
|
||||||
|
pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
|
||||||
|
read this before changing anything that renders a structured session — both
|
||||||
|
the web and TUI renderers are written against it.
|
||||||
|
|
||||||
|
It covers the eight transcript row kinds, what the daemon decides versus what
|
||||||
|
a client decides, the two projections and their delta/cursor semantics, the
|
||||||
|
ACP handshake and the capabilities it declines, the adapter's install
|
||||||
|
command, and the approval nonce rules. **Non-goal: the detached runner.** It
|
||||||
|
does not exist yet, and nothing here describes it.
|
||||||
|
|
||||||
|
## What a structured session is
|
||||||
|
|
||||||
|
A terminal session's agent runs inside a tmux pane; a structured session's
|
||||||
|
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
|
||||||
|
daemon is the ACP **client**; the adapter process, `claude-code-acp`, is the
|
||||||
|
ACP **agent** — it wraps Claude Code's own SDK and translates the model's
|
||||||
|
tool calls into ACP `session/update` notifications instead of drawing them to
|
||||||
|
a pane. `crates/maestro-acp` owns the daemon's side of that connection;
|
||||||
|
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two
|
||||||
|
projections below.
|
||||||
|
|
||||||
|
The child is a real child of the daemon process: same process group, and on
|
||||||
|
Linux marked with `PR_SET_PDEATHSIG` so it dies with the daemon even if the
|
||||||
|
daemon exits without cleaning up; other platforms have no such signal and
|
||||||
|
depend on an orderly shutdown/drop to terminate it. The ordinary path on
|
||||||
|
every platform is `AcpSession::terminate` (`session/cancel`, a short grace,
|
||||||
|
`SIGTERM`, then `SIGKILL`), called from both `DELETE /api/sessions/:id` and
|
||||||
|
daemon shutdown.
|
||||||
|
`docs/WORKTREES.md`'s kill-guard rule — an unforced kill refused while the
|
||||||
|
session's worktree holds unsaved work — applies to a structured session
|
||||||
|
exactly as it does to a terminal one; only what gets terminated differs (an
|
||||||
|
ACP handle instead of a tmux pane).
|
||||||
|
|
||||||
|
## The adapter
|
||||||
|
|
||||||
|
`ADAPTER_BIN` (`claude-code-acp`) is resolved by walking `PATH` — the
|
||||||
|
session's own `PATH` env var if the spawn request set one, the daemon's
|
||||||
|
otherwise — and taking the first executable file named `claude-code-acp` on
|
||||||
|
it. Install it once per host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
|
||||||
|
```
|
||||||
|
|
||||||
|
That is `maestro_acp::INSTALL_COMMAND`, and it is not just documentation: it
|
||||||
|
is baked into `AcpError::AdapterMissing`'s message. When the adapter cannot
|
||||||
|
be found, spawn still creates the session — **Dead**, with that install
|
||||||
|
command as its `activity_detail` — so the sidebar names the fix instead of
|
||||||
|
the session silently never appearing (see `docs/INSTALL.md` for the full
|
||||||
|
install writeup, including the systemd `PATH` caveat). The adapter also needs
|
||||||
|
a logged-in Claude Code (`claude /login`); a missing login surfaces the same
|
||||||
|
way, via the adapter's own stderr tail in the failure message.
|
||||||
|
|
||||||
|
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
|
||||||
|
slow machine to boot a node process and the Claude Code SDK behind it, never
|
||||||
|
long enough to hang a spawn indefinitely. Any handshake failure — missing
|
||||||
|
adapter, early exit, a protocol error, or the timeout — leaves no orphaned
|
||||||
|
process: `spawn` closes and reaps the child itself before returning the
|
||||||
|
error.
|
||||||
|
|
||||||
|
## The ACP handshake
|
||||||
|
|
||||||
|
`initialize` is where the daemon states what kind of client it is. It
|
||||||
|
declares, deliberately, almost nothing:
|
||||||
|
|
||||||
|
| Capability | Declared | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `fs.readTextFile` | `false` | The adapter uses Claude Code's own Read tool instead of asking the daemon to read a file on its behalf — the same tool a terminal-view Claude Code session uses. |
|
||||||
|
| `fs.writeTextFile` | `false` | Same reasoning, for Write. |
|
||||||
|
| `terminal` | `false` | The adapter runs Claude Code's own Bash tool rather than asking the daemon to open a terminal for it. |
|
||||||
|
|
||||||
|
Declining all three keeps the daemon out of the file/terminal surface
|
||||||
|
entirely — the adapter already has direct access to the session's `cwd`, and
|
||||||
|
routing every read, write, and shell command through the daemon would only
|
||||||
|
add a hop with no new capability. Every other capability namespace ACP
|
||||||
|
defines (session extensions, auth, elicitation) is simply left at its
|
||||||
|
default; the daemon does not advertise support because `claude-code-acp` has
|
||||||
|
never asked for it, not because it was weighed and refused.
|
||||||
|
|
||||||
|
The one client method that *is* implemented for real is
|
||||||
|
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
||||||
|
Before #139 it was a stub that declined every request; that stub is gone now
|
||||||
|
that a live implementation exists.
|
||||||
|
|
||||||
|
`session/new` opens a fresh ACP session on every ordinary spawn. A respawn
|
||||||
|
after a daemon restart instead sets `SpawnConfig::load_session(id)` and runs
|
||||||
|
`session/load` with the ACP session id the original handshake returned
|
||||||
|
(`Session::acp_session_id` on the wire) — the adapter must advertise
|
||||||
|
`agent_capabilities.load_session`, or the handshake fails outright naming
|
||||||
|
that. Whatever history the adapter streams back while `session/load` is in
|
||||||
|
flight is dropped on arrival, deliberately: the daemon's own event log
|
||||||
|
already holds that history, and forwarding it too would duplicate every row
|
||||||
|
in the projections below.
|
||||||
|
|
||||||
|
## The eight transcript row kinds
|
||||||
|
|
||||||
|
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
||||||
|
transcript is written in. Every row has a stable `id` (so later deltas can
|
||||||
|
address it), a `kind`, and a `data: Value` that preserves the underlying ACP
|
||||||
|
payload — clients depend on ACP's *shape* without depending on ACP as a
|
||||||
|
crate.
|
||||||
|
|
||||||
|
| Kind | Carries | Row identity |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `User` | An echoed `user_message_chunk` — the prompt text as ACP saw it. | `user:<message_id>`, or `user:<seq>` when the chunk carries no id. |
|
||||||
|
| `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>` / `agent:<seq>`. Later chunks for the same id are appended into `data.content.text` in place, not appended as new rows. |
|
||||||
|
| `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / `thought:<seq>`, coalesced the same way as `AgentText`. |
|
||||||
|
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
|
||||||
|
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
||||||
|
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
||||||
|
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
||||||
|
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. | `notice:<seq>`. |
|
||||||
|
|
||||||
|
`Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the
|
||||||
|
`agent-client-protocol` dependency's schema already deserializes (it
|
||||||
|
currently folds `available_commands_update` and `session_info_update`
|
||||||
|
there) but the projection's `match` doesn't fold into one of the other
|
||||||
|
seven kinds. It is not a wire-level forward-compatibility seam:
|
||||||
|
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
||||||
|
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
||||||
|
dependency has never heard of — including a variant gated behind an
|
||||||
|
unstable ACP feature this build doesn't enable, which is compiled out of
|
||||||
|
the enum entirely — fails to deserialize before the projection ever runs,
|
||||||
|
rather than becoming a `Notice`. A genuinely new ACP variant needs the
|
||||||
|
`agent-client-protocol` dependency upgraded (and, typically, the projection's
|
||||||
|
`match` taught about it) before it can appear on this transcript at all — see
|
||||||
|
`projection.rs`'s test asserting that an unrecognised `sessionUpdate` tag is a
|
||||||
|
deserialization error, not a `Notice`. A client that does not know what to do
|
||||||
|
with a `Notice` can still render it as an opaque, collapsed line — that
|
||||||
|
remains a reasonable floor, not a client bug.
|
||||||
|
|
||||||
|
Rendering is entirely a client concern. The projection's job stops at
|
||||||
|
folding ACP into these eight shapes; markdown formatting, diff rendering,
|
||||||
|
tool-card treatment, and how a `Notice` is presented are all renderer
|
||||||
|
choices, not daemon ones.
|
||||||
|
|
||||||
|
## Daemon decisions vs. client decisions
|
||||||
|
|
||||||
|
**The daemon decides:**
|
||||||
|
|
||||||
|
- Which ACP `session/update` becomes which row kind, and under what id — the
|
||||||
|
fold in `projection.rs` is the one place this logic lives, so the web and
|
||||||
|
TUI renderers can never disagree about it.
|
||||||
|
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
|
||||||
|
or a failure ends it (`TurnState.responding` / `.thinking` /
|
||||||
|
`.active_tool_calls` are derived, not renderer-computed).
|
||||||
|
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
||||||
|
below) — a client never sees an ACP permission request before the daemon
|
||||||
|
has already turned it into an `Approval` row and a nonce.
|
||||||
|
- What happens to an ACP update variant the dependency decodes but nobody has
|
||||||
|
taught the projection to special-case (falls back to `Notice` rather than
|
||||||
|
erroring). A variant the dependency itself can't decode is a deserialization
|
||||||
|
error, not a `Notice` — see [The eight transcript row
|
||||||
|
kinds](#the-eight-transcript-row-kinds).
|
||||||
|
- Persistence and replay: what is written to the event log, in what order,
|
||||||
|
and how a restart resumes it.
|
||||||
|
|
||||||
|
**A client decides:**
|
||||||
|
|
||||||
|
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
||||||
|
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
|
||||||
|
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
||||||
|
from the transcript 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 the operator is asked to resolve an `Approval` row — the daemon only
|
||||||
|
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||||
|
never the UI that solicits it.
|
||||||
|
- Scrolling, collapsing, and every other presentation-only behaviour.
|
||||||
|
|
||||||
|
## The two projections
|
||||||
|
|
||||||
|
`Projection` (daemon-only) is folded once from the durable ACP stream and
|
||||||
|
exposes two independent views, both defined in `maestro-protocol`:
|
||||||
|
|
||||||
|
- **`TranscriptSnapshot { cursor, rows }`** — the complete ordered list of
|
||||||
|
transcript rows built so far, plus the log position (`cursor`) it reflects.
|
||||||
|
It is intentionally always the *whole* transcript, not a windowed one: a
|
||||||
|
tool row patched much later must still find its original entry.
|
||||||
|
- **`ReducedState`** — one whole-session snapshot folded over every event
|
||||||
|
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
|
||||||
|
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
||||||
|
always full-history, never a delta — a client that only wants "is a turn
|
||||||
|
active right now" never has to replay anything to answer that.
|
||||||
|
|
||||||
|
Changes to the transcript travel as `TranscriptDelta`, tagged `type`:
|
||||||
|
`Append { row }` (a new row), `Patch { row }` (an existing row, addressed by
|
||||||
|
`id`, replaced wholesale), and `Remove { id }`. `Remove` is defined on the
|
||||||
|
wire for forward compatibility; nothing in the current projection emits it —
|
||||||
|
every row, once appended, is only ever patched or left alone.
|
||||||
|
|
||||||
|
### Cursor and replay semantics
|
||||||
|
|
||||||
|
The event log's own line position is the cursor: 1-based, shared by ACP
|
||||||
|
notifications and daemon-generated rows alike (`sidecar.rs`'s
|
||||||
|
`PersistedEvent` envelope is what lets an `Approval` row share a cursor
|
||||||
|
sequence with raw ACP JSON without pretending to be ACP traffic). Two ways to
|
||||||
|
read it:
|
||||||
|
|
||||||
|
- **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and
|
||||||
|
a `ReducedState` covering the whole log as it stands, then streams
|
||||||
|
`ProjectionUpdate { transcript, state }` for everything that happens next.
|
||||||
|
There is no client-supplied resume cursor on this socket: every connection
|
||||||
|
gets the full snapshot, not a gap-filled continuation, so a client never
|
||||||
|
needs the replay endpoint just to obtain the current transcript. Use
|
||||||
|
replay instead for cursor-bounded pagination through the durable log —
|
||||||
|
history from before this connection, fetched a page at a time.
|
||||||
|
- **`GET /api/sessions/:id/structured/replay`** — cursor-bounded pagination
|
||||||
|
over the durable log, returning one `StructuredReplayEvent` (`cursor`,
|
||||||
|
`transcript`, `state`) per matching line. `?since=<n>` returns the first
|
||||||
|
page after `n` (`since=0` selects from the beginning, i.e. the first
|
||||||
|
page); a client pages forward from there using the cursor of the last
|
||||||
|
event returned. `?before=<n>` returns the page immediately before `n`,
|
||||||
|
still in ascending order — passing both is a `400`. `?limit` defaults to
|
||||||
|
100 and is clamped to 500. Each call re-folds
|
||||||
|
the entire log from scratch to compute the state at every returned cursor,
|
||||||
|
so a page reflects exactly the *durable* state at its cursor, not an
|
||||||
|
approximation of it — at the cost of redoing the whole fold per request.
|
||||||
|
That durable state is not always identical to what a live client saw at
|
||||||
|
that moment, though: turn lifecycle transitions are broadcast live without
|
||||||
|
a durable event backing them (see [Prompt and cancel](#prompt-and-cancel-bounded-liveness)),
|
||||||
|
and a restored `Approval` row carries no pending nonce, because the nonce
|
||||||
|
never reached disk (see [Approvals](#approvals)) — replay is exact and
|
||||||
|
reconstructible, not a perfect record of everything the live view showed.
|
||||||
|
A session that is not a live structured session (terminal, or a dead
|
||||||
|
adapter) answers `409 not_structured` from either route rather than a
|
||||||
|
coherent-looking but stale reply.
|
||||||
|
|
||||||
|
## Approvals
|
||||||
|
|
||||||
|
An ACP `session/request_permission` callback becomes an `Approval` row plus a
|
||||||
|
pending decision, entirely on the daemon side:
|
||||||
|
|
||||||
|
- **Generation.** The daemon mints the nonce itself — a fresh
|
||||||
|
`Uuid::new_v4()` string per request — rather than using anything ACP's own
|
||||||
|
request carries. ACP permission callbacks have no request id of their own
|
||||||
|
that would double as one.
|
||||||
|
- **Live-only placement.** The nonce lives in exactly one place: the
|
||||||
|
in-memory `pending_approvals` map keyed by nonce, and its mirror in
|
||||||
|
`ReducedState.pending_approval.nonce` on the live wire. It is never part of
|
||||||
|
the `Approval` row's `data` — the row is `RequestPermissionRequest`
|
||||||
|
serialized *without* the nonce.
|
||||||
|
- **Secrecy from the agent and from durable history.** The adapter itself
|
||||||
|
never learns the nonce — it is a daemon-to-client secret, never sent back
|
||||||
|
over the ACP connection. It is likewise never written to the event log:
|
||||||
|
restoring an `Approval` row from disk (`Projection::restore_approval`)
|
||||||
|
produces a historical row with no `pending_approval` in state, because the
|
||||||
|
nonce that would make it "pending" never made it to disk in the first
|
||||||
|
place. A restart therefore cannot resume a pending approval — see
|
||||||
|
[Persistence](#persistence-recovery-and-dead-sessions).
|
||||||
|
- **One live approval at a time.** A new permission request mints its own
|
||||||
|
nonce, then cancels whatever was already pending (`cancel_pending_approvals`)
|
||||||
|
before installing itself as the new pending approval — ACP serializes
|
||||||
|
requests through one connection, so this only ever fires if an adapter
|
||||||
|
nonetheless overlaps two. Either way, the prior pending request is
|
||||||
|
cancelled/cleared before the new one is installed, so the newest request
|
||||||
|
wins.
|
||||||
|
- **Single-use and duplicate resolution.**
|
||||||
|
`POST /api/sessions/:id/structured/approvals/:nonce` **always answers
|
||||||
|
`204`** for a known, live structured session — whether the nonce is the
|
||||||
|
one currently pending, one already consumed by an earlier answer, or one
|
||||||
|
that never existed. Only an unknown or non-structured *session* answers
|
||||||
|
otherwise (`404` / `409`). This is deliberate: two clients racing to answer the same
|
||||||
|
approval must not turn a harmless duplicate click into a client-visible
|
||||||
|
error. Whichever `POST` reaches the daemon first removes the nonce from
|
||||||
|
`pending_approvals` and is the one whose decision actually reaches the
|
||||||
|
adapter; every later `POST` for that nonce is a no-op `204`.
|
||||||
|
- **Cancellation vs. denial.** A turn cancel or a session shutdown resolves
|
||||||
|
any pending approval with ACP's `Cancelled` outcome, not a `Deny` decision
|
||||||
|
— the adapter should read those as "the operation was withdrawn," not "the
|
||||||
|
operator refused it."
|
||||||
|
- **Decision mapping.** `AllowOnce` and `AllowForSession` select the ACP
|
||||||
|
option of kind `AllowOnce` / `AllowAlways` from the request's own option
|
||||||
|
list. `Deny` prefers a one-shot refusal (`RejectOnce`) and falls back to a
|
||||||
|
permanent one (`RejectAlways`) only when that is the sole rejection option
|
||||||
|
offered; if neither is present, it answers `Cancelled`.
|
||||||
|
|
||||||
|
## Prompt and cancel: bounded liveness
|
||||||
|
|
||||||
|
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
||||||
|
against each other through one per-session `operation` lock
|
||||||
|
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
||||||
|
per-request id — the daemon's own notion of "the active turn" is the only
|
||||||
|
thing a callback can be attributed to, so at most one turn may ever be
|
||||||
|
in flight, and a cancelled turn must fully settle before the next prompt is
|
||||||
|
allowed to claim the daemon's idea of "the active turn."
|
||||||
|
|
||||||
|
- A prompt claims the turn and dispatches it to ACP as one operation. While a
|
||||||
|
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
||||||
|
queuing — one active turn per session, never a hidden queue of prompts.
|
||||||
|
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||||
|
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
||||||
|
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
||||||
|
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
|
||||||
|
prompt claiming "the active turn" while the old one is still outstanding
|
||||||
|
would make the next permission callback ambiguous between the two.
|
||||||
|
- So a cancelled-but-still-dispatched turn must **settle within
|
||||||
|
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
|
||||||
|
resolves) or that grace period runs out. If it settles in time, the next
|
||||||
|
prompt proceeds normally. If it does not, the daemon does not guess: it
|
||||||
|
marks the whole structured session dead (`fail_cancelled_acp_turn`) rather
|
||||||
|
than risk a callback resolving against the wrong turn. This is the bounded
|
||||||
|
half of "bounded liveness" — a structured session's prompt/cancel dance
|
||||||
|
either resolves promptly or the session is declared unusable, never left
|
||||||
|
in an indefinite ambiguous state.
|
||||||
|
|
||||||
|
## Persistence, recovery, and dead sessions
|
||||||
|
|
||||||
|
A structured session's durable state is three files in the sidecar
|
||||||
|
directory: the sidecar itself (`<id>.json`), plus two next to it:
|
||||||
|
|
||||||
|
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
||||||
|
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
||||||
|
readable) or a `{"maestroEvent": "approval", "row": …}` envelope for a
|
||||||
|
daemon-generated row. Both share the same 1-based line-position cursor. A
|
||||||
|
line a hard kill left half-written is terminated before the next append, so
|
||||||
|
it keeps its position and is skipped as malformed rather than corrupting
|
||||||
|
what follows. An append that fails *while the daemon is running* — a
|
||||||
|
`write_all` that errors after only a prefix reached disk, or a `flush` that
|
||||||
|
errors after a complete write — is rolled back to the exact byte length the
|
||||||
|
file was at before the call, so `last_seq` and the file's line count never
|
||||||
|
disagree and the same log handle can be appended to again immediately (#184).
|
||||||
|
If that rollback itself cannot restore the prior length, the append is
|
||||||
|
fatal: the log's consistency can no longer be promised, so the caller kills
|
||||||
|
the session instead of appending to it again.
|
||||||
|
- **`<id>.recover`** — an empty marker file. Its only meaning is "the daemon
|
||||||
|
that wrote this shut down gracefully while this session was running, and
|
||||||
|
the next daemon may attempt to resume it." It is consumed (deleted) the
|
||||||
|
moment a recovery attempt reads it — one recovery attempt per graceful
|
||||||
|
shutdown, never a standing flag.
|
||||||
|
|
||||||
|
Three ways a structured session ends up `Dead`, each with different
|
||||||
|
persistence consequences:
|
||||||
|
|
||||||
|
1. **The adapter exits, or a notification append to the event log fails,
|
||||||
|
while the daemon keeps running.** (An approval-row append failure is
|
||||||
|
narrower: as long as the log rolled back cleanly, it only cancels the
|
||||||
|
pending approval and rolls the unpersisted `Approval` row back out of the
|
||||||
|
live transcript, without marking the session dead — a snapshot taken after
|
||||||
|
the failure must never show a row durable replay could not reproduce
|
||||||
|
(#184). If the rollback itself did not restore the log's prior length,
|
||||||
|
this narrower path does not apply either: the session is marked dead the
|
||||||
|
same as any other append failure, since the log can no longer promise
|
||||||
|
`last_seq` matches its file.)
|
||||||
|
`mark_session_dead` applies the same natural-exit worktree rule
|
||||||
|
as a terminal session's own pane exiting (`docs/WORKTREES.md`): a pristine
|
||||||
|
worktree is removed, one holding unsaved work is left on disk. No recovery
|
||||||
|
marker is written. This death is permanent — a later daemon restart's
|
||||||
|
`recover()` finds the sidecar, sees no marker, and leaves the session
|
||||||
|
`Dead` without attempting to resume it.
|
||||||
|
2. **A cancelled turn fails to settle within `CANCEL_GRACE`.** Same
|
||||||
|
consequence as (1): the session is marked dead through the same path, no
|
||||||
|
marker, no future resumption.
|
||||||
|
3. **Graceful daemon shutdown**, while the session was still `Running`.
|
||||||
|
`mark_session_dead_after_shutdown` marks the session dead **without**
|
||||||
|
applying the worktree exit rule — the tree is kept exactly as it is,
|
||||||
|
pristine or not, because there is nothing "natural" about this exit — and
|
||||||
|
writes the `.recover` marker. The sidecar on disk therefore says `Dead`,
|
||||||
|
but is eligible for resumption.
|
||||||
|
|
||||||
|
Recovery, on the next daemon's `recover()`: for every `Structured` sidecar
|
||||||
|
not already adopted, the marker is consumed. When one was present and the
|
||||||
|
sidecar carries a stored `acp_session_id`, the daemon spawns a fresh adapter
|
||||||
|
with `SpawnConfig::load_session(acp_session_id)`, replays the
|
||||||
|
entire durable event log into a new `Projection` (rebuilding the transcript
|
||||||
|
and reduced state exactly as they were, with any restored `Approval` row
|
||||||
|
landing as history — never as a re-armed pending approval, since its nonce
|
||||||
|
never reached disk), and only then reopens the log for further appends. What
|
||||||
|
the adapter streams back while its own `session/load` is in flight is
|
||||||
|
discarded rather than appended, so the replayed history is not duplicated
|
||||||
|
(see [The ACP handshake](#the-acp-handshake)). Success flips the session back
|
||||||
|
to `Running`/`Idle`; failure (no stored `acp_session_id`, or the handshake
|
||||||
|
itself failing) leaves it `Dead` with the failure reason as
|
||||||
|
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
||||||
|
missing adapter binary.
|
||||||
|
|
||||||
|
Pending approvals and their nonces never survive any of this. They live only
|
||||||
|
in the in-memory `pending_approvals` map and the live `ReducedState`, both
|
||||||
|
gone the instant the daemon that held them exits. Whatever ACP permission
|
||||||
|
request was mid-flight when the old daemon died was already answered
|
||||||
|
`Cancelled` on that daemon's own shutdown path, so a resumed session always
|
||||||
|
starts with no pending approval.
|
||||||
|
|
||||||
|
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
||||||
|
now that structured sessions exist alongside terminal ones.
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
+43
-1
@@ -11,6 +11,8 @@ import type {
|
|||||||
Projects,
|
Projects,
|
||||||
RecentDirs,
|
RecentDirs,
|
||||||
Session,
|
Session,
|
||||||
|
StructuredApprovalDecision,
|
||||||
|
StructuredPromptRequest,
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
@@ -152,4 +154,44 @@ export async function fetchBranches(path: string): Promise<Branches> {
|
|||||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export { eventsWsUrl } from "./routes.js";
|
/** Send a prompt to a structured session's ACP turn (#138). `409
|
||||||
|
* turn_in_flight` when one is already active — the caller reflects that from
|
||||||
|
* `ReducedState.turn` instead of racing this call. */
|
||||||
|
export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<void> {
|
||||||
|
await jsonOrError(
|
||||||
|
await fetch(routes.structuredPrompt(id), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ prompt } satisfies StructuredPromptRequest),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a structured session's active ACP turn (#138). */
|
||||||
|
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
|
||||||
|
await jsonOrError(
|
||||||
|
await fetch(routes.structuredCancel(id), { method: "POST", credentials: "same-origin" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer a pending ACP permission request (#139). Always resolves once the
|
||||||
|
* session itself is a live structured session — a nonce that is unknown or
|
||||||
|
* already consumed still answers `204` (docs/STRUCTURED-VIEW.md), so two
|
||||||
|
* clients racing the same approval never see this reject. */
|
||||||
|
export async function resolveStructuredApproval(
|
||||||
|
id: Uuid,
|
||||||
|
nonce: string,
|
||||||
|
decision: StructuredApprovalDecision,
|
||||||
|
): Promise<void> {
|
||||||
|
await jsonOrError(
|
||||||
|
await fetch(routes.structuredApproval(id, nonce), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(decision),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
||||||
|
|||||||
+8
-44
@@ -4,14 +4,16 @@ import { Terminal } from "@xterm/xterm";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { attachWsUrl } from "./routes.js";
|
import { attachWsUrl } from "./routes.js";
|
||||||
import { escapeHtml } from "./html.js";
|
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||||
import { requestToggleDrawer } from "./drawer.js";
|
import { requestToggleDrawer } from "./drawer.js";
|
||||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||||
|
import type { PaneConnection } from "./pane.js";
|
||||||
import type { ClientControl, Uuid } from "./types.js";
|
import type { ClientControl, Uuid } from "./types.js";
|
||||||
import { WebglPolicy } from "./webglRenderer.js";
|
import { WebglPolicy } from "./webglRenderer.js";
|
||||||
|
|
||||||
|
export type { AttachInfo } from "./paneTitle.js";
|
||||||
|
|
||||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||||
* rationed rather than handed to every connection. */
|
* rationed rather than handed to every connection. */
|
||||||
const webglPolicy = new WebglPolicy();
|
const webglPolicy = new WebglPolicy();
|
||||||
@@ -27,25 +29,7 @@ const webglPolicy = new WebglPolicy();
|
|||||||
* session ending) tears the socket down — so switching away and back is
|
* session ending) tears the socket down — so switching away and back is
|
||||||
* instant, with scrollback already in place.
|
* instant, with scrollback already in place.
|
||||||
*/
|
*/
|
||||||
export interface AttachConnection {
|
export interface AttachConnection extends PaneConnection {}
|
||||||
readonly sessionId: Uuid;
|
|
||||||
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
|
||||||
readonly el: HTMLElement;
|
|
||||||
/** Becomes the visible view: fit to the pane, focus, sync size.
|
|
||||||
* `focus: false` mounts without taking the keyboard — what a sidebar
|
|
||||||
* preview needs, so j/k keeps working while the pane is on screen. */
|
|
||||||
onMount(opts?: { focus?: boolean }): void;
|
|
||||||
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
|
||||||
onUnmount(): void;
|
|
||||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
|
||||||
focusTerminal(): void;
|
|
||||||
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
|
|
||||||
* handling — the leader's literal Ctrl-K escape hatch
|
|
||||||
* (docs/COMMAND-BAR.md). */
|
|
||||||
sendInput(data: string): void;
|
|
||||||
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
|
||||||
dispose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AttachOptions {
|
export interface AttachOptions {
|
||||||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
||||||
@@ -59,23 +43,6 @@ export interface AttachOptions {
|
|||||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the header shows about the session (name / command). Optional — falls
|
|
||||||
* back to a short session id when the session isn't known yet. */
|
|
||||||
export interface AttachInfo {
|
|
||||||
name: string;
|
|
||||||
command: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Header echoes the sidebar: the program becomes its tool icon when we have a
|
|
||||||
* mark for it and stays as its bare name when we do not. Arguments are
|
|
||||||
* dropped, as on the card. */
|
|
||||||
function commandFragment(command: string): string {
|
|
||||||
const { tool, program } = splitCommand(command);
|
|
||||||
const icon = tool ? toolIconSvg(tool) : null;
|
|
||||||
const text = icon ? "" : program;
|
|
||||||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAttachConnection(
|
export function createAttachConnection(
|
||||||
sessionId: Uuid,
|
sessionId: Uuid,
|
||||||
opts: AttachOptions = {},
|
opts: AttachOptions = {},
|
||||||
@@ -85,15 +52,12 @@ export function createAttachConnection(
|
|||||||
el.className = "attach";
|
el.className = "attach";
|
||||||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
// Useful identity over a bare UUID: name · command. The full id stays in the
|
||||||
// tooltip for when you really need it.
|
// tooltip for when you really need it.
|
||||||
const titleHtml = info
|
const titleHtml = paneTitleHtml(sessionId, info);
|
||||||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
const titleAttr = paneTitleAttr(sessionId, info);
|
||||||
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
|
||||||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
|
||||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<header class="attach-header">
|
<header class="attach-header">
|
||||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||||
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
|
||||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+261
-6
@@ -2,28 +2,83 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ConnectionPool } from "./connections.js";
|
import { ConnectionPool } from "./connections.js";
|
||||||
|
import { CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
||||||
// the pool reaches the focused connection with them.
|
// the pool reaches the focused connection with them.
|
||||||
const sent: { id: string; data: string }[] = [];
|
const sent: { id: string; data: string }[] = [];
|
||||||
vi.mock("./attach.js", () => ({
|
const disposed: string[] = [];
|
||||||
createAttachConnection: (id: string) => ({
|
const mounts: { id: string; focus: boolean | undefined }[] = [];
|
||||||
|
function fakeConn(kind: string, id: string) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
|
||||||
|
el.dataset.kind = kind;
|
||||||
|
el.dataset.session = id;
|
||||||
|
return {
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
el: document.createElement("div"),
|
kind,
|
||||||
onMount() {},
|
el,
|
||||||
|
onMount(opts?: { focus?: boolean }) {
|
||||||
|
mounts.push({ id, focus: opts?.focus });
|
||||||
|
},
|
||||||
onUnmount() {},
|
onUnmount() {},
|
||||||
focusTerminal() {},
|
focusTerminal() {},
|
||||||
sendInput(data: string) {
|
sendInput(data: string) {
|
||||||
sent.push({ id, data });
|
sent.push({ id, data });
|
||||||
},
|
},
|
||||||
dispose() {},
|
dispose() {
|
||||||
}),
|
disposed.push(`${kind}:${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
vi.mock("./attach.js", () => ({
|
||||||
|
createAttachConnection: (id: string) => fakeConn("attach", id),
|
||||||
|
}));
|
||||||
|
vi.mock("./structured.js", () => ({
|
||||||
|
createStructuredConnection: (id: string) => fakeConn("structured", id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
sent.length = 0;
|
||||||
|
disposed.length = 0;
|
||||||
|
mounts.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Minimal fixture for the `CatalogState` a `CatalogStore` broadcasts. */
|
||||||
|
function session(id: string, view?: "Terminal" | "Structured"): Session {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
cwd: "/repo",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
view,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `CatalogStore` an id can be pushed into on demand, mirroring
|
||||||
|
* events.test.ts's own injectable-socket pattern. */
|
||||||
|
class FakeCatalogSocket {
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
close(): void {}
|
||||||
|
push(sessions: Session[]): void {
|
||||||
|
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fakeStore(): { store: CatalogStore; socket: FakeCatalogSocket } {
|
||||||
|
const socket = new FakeCatalogSocket();
|
||||||
|
const store = new CatalogStore({ socket: () => socket as unknown as WebSocket });
|
||||||
|
store.start();
|
||||||
|
return { store, socket };
|
||||||
|
}
|
||||||
|
|
||||||
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
|
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
|
||||||
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
|
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
|
||||||
|
|
||||||
@@ -125,3 +180,203 @@ describe("ConnectionPool.sendToFocused", () => {
|
|||||||
expect(sent).toEqual([]);
|
expect(sent).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A deep link or restored layout can `mount()` a session before the catalog
|
||||||
|
// snapshot names its view — the daemon defaults an absent `view` to
|
||||||
|
// `"Terminal"`, but so does an *unknown* session, and only one of those is
|
||||||
|
// actually resolved (#140 review).
|
||||||
|
describe("ConnectionPool: view resolution races", () => {
|
||||||
|
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
|
||||||
|
pool.mount(A, pane); // no info: deep-link-before-snapshot race
|
||||||
|
expect(disposed).toEqual([]);
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(disposed).toEqual(["attach:" + A]);
|
||||||
|
// The fresh connection landed in the same pane nothing re-mounted it into.
|
||||||
|
expect(pane.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
|
||||||
|
pool.mount(A, document.createElement("div"));
|
||||||
|
pool.mount(B, document.createElement("div"));
|
||||||
|
pool.unmount(A);
|
||||||
|
pool.unmount(B);
|
||||||
|
disposed.length = 0;
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(disposed).toEqual(["attach:" + A]);
|
||||||
|
// B's own element is still parked — A's repair didn't wipe its siblings
|
||||||
|
// out of the shared hidden cache.
|
||||||
|
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
|
||||||
|
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replace a connection whose resolved view already matches", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||||
|
disposed.length = 0;
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(disposed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
|
||||||
|
mockFetch();
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||||
|
disposed.length = 0;
|
||||||
|
|
||||||
|
pool.mount(A, pane); // e.g. a re-render before the catalog answers again
|
||||||
|
|
||||||
|
expect(disposed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||||
|
disposed.length = 0;
|
||||||
|
|
||||||
|
// `A` is absent from this snapshot — main.ts's own subscription handles
|
||||||
|
// closing the pane; the pool must not race it by "fixing" the view first.
|
||||||
|
socket.push([session(B, "Terminal")]);
|
||||||
|
|
||||||
|
expect(disposed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
document.body.appendChild(pane);
|
||||||
|
pool.mount(A, pane);
|
||||||
|
pool.setFocus(A);
|
||||||
|
(pane.firstElementChild as HTMLElement).focus();
|
||||||
|
expect(document.activeElement).toBe(pane.firstElementChild);
|
||||||
|
mounts.length = 0;
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(disposed).toEqual(["attach:" + A]);
|
||||||
|
expect(mounts).toEqual([{ id: A, focus: true }]);
|
||||||
|
pane.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
|
||||||
|
// docs/PREVIEW.md: previewing a session already open in the current tab
|
||||||
|
// moves the keyboard into the sidebar without ever calling setFocus, so
|
||||||
|
// `focusedId` keeps naming the pane the operator was in before — but the
|
||||||
|
// DOM disagrees, and the DOM is what must decide.
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
document.body.appendChild(pane);
|
||||||
|
pool.mount(A, pane);
|
||||||
|
pool.setFocus(A);
|
||||||
|
(pane.firstElementChild as HTMLElement).focus();
|
||||||
|
|
||||||
|
const sidebarInput = document.createElement("input");
|
||||||
|
document.body.appendChild(sidebarInput);
|
||||||
|
sidebarInput.focus();
|
||||||
|
expect(document.activeElement).toBe(sidebarInput);
|
||||||
|
mounts.length = 0;
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(disposed).toEqual(["attach:" + A]);
|
||||||
|
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||||
|
pane.remove();
|
||||||
|
sidebarInput.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts focus on a descendant of the pane's element, not just the element itself", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
document.body.appendChild(pane);
|
||||||
|
pool.mount(A, pane);
|
||||||
|
pool.setFocus(A);
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
(pane.firstElementChild as HTMLElement).appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
mounts.length = 0;
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(mounts).toEqual([{ id: A, focus: true }]);
|
||||||
|
pane.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats document.body (nothing meaningfully focused) as no active descendant", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
document.body.appendChild(pane);
|
||||||
|
pool.mount(A, pane);
|
||||||
|
pool.setFocus(A);
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
mounts.length = 0;
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||||
|
pane.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not steal focus for a merely-visible pane that was never actually focused", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const paneA = document.createElement("div");
|
||||||
|
const paneB = document.createElement("div");
|
||||||
|
pool.mount(A, paneA);
|
||||||
|
pool.mount(B, paneB);
|
||||||
|
pool.setFocus(B); // B is the real keyboard focus; A is only visible
|
||||||
|
mounts.length = 0;
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(disposed).toEqual(["attach:" + A]);
|
||||||
|
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not focus a parked (unmounted) connection it repairs", () => {
|
||||||
|
mockFetch();
|
||||||
|
const { store, socket } = fakeStore();
|
||||||
|
const pool = new ConnectionPool({ store });
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
pool.mount(A, pane);
|
||||||
|
pool.setFocus(A);
|
||||||
|
pool.unmount(A); // parked — no longer visible, `focusedId` still names it
|
||||||
|
mounts.length = 0;
|
||||||
|
|
||||||
|
socket.push([session(A, "Structured")]);
|
||||||
|
|
||||||
|
expect(disposed).toEqual(["attach:" + A]);
|
||||||
|
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+110
-18
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import { createAttachConnection } from "./attach.js";
|
||||||
createAttachConnection,
|
import type { AttachInfo } from "./paneTitle.js";
|
||||||
type AttachConnection,
|
import type { PaneConnection } from "./pane.js";
|
||||||
type AttachInfo,
|
import { createStructuredConnection } from "./structured.js";
|
||||||
} from "./attach.js";
|
|
||||||
import { setSessionFocus } from "./api.js";
|
import { setSessionFocus } from "./api.js";
|
||||||
import type { Uuid } from "./types.js";
|
import type { CatalogState, CatalogStore } from "./events.js";
|
||||||
|
import { sessionName, type SessionView, type Uuid } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many *parked* (off-screen but still-connected) sessions to keep warm
|
* How many *parked* (off-screen but still-connected) sessions to keep warm
|
||||||
@@ -16,6 +16,15 @@ import type { Uuid } from "./types.js";
|
|||||||
*/
|
*/
|
||||||
const MAX_PARKED = 6;
|
const MAX_PARKED = 6;
|
||||||
|
|
||||||
|
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
|
||||||
|
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
|
||||||
|
* meaningful once the session itself is known; an altogether-unknown
|
||||||
|
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
|
||||||
|
* state callers must check for separately, not "Terminal". */
|
||||||
|
function resolveView(view: SessionView | undefined): SessionView {
|
||||||
|
return view ?? "Terminal";
|
||||||
|
}
|
||||||
|
|
||||||
export interface PoolOptions {
|
export interface PoolOptions {
|
||||||
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
|
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
|
||||||
onFocusChange?: (id: Uuid | null) => void;
|
onFocusChange?: (id: Uuid | null) => void;
|
||||||
@@ -27,6 +36,9 @@ export interface PoolOptions {
|
|||||||
onClosed?: (id: Uuid) => void;
|
onClosed?: (id: Uuid) => void;
|
||||||
/** Forwarded to each connection: should a dropped socket be retried? */
|
/** Forwarded to each connection: should a dropped socket be retried? */
|
||||||
shouldReconnect?: (id: Uuid, code: number) => boolean;
|
shouldReconnect?: (id: Uuid, code: number) => boolean;
|
||||||
|
/** Threaded through to a structured connection so its rail can read live
|
||||||
|
* worktree/git-status data (#140). Terminal connections don't need it. */
|
||||||
|
store?: CatalogStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,12 +52,19 @@ export interface PoolOptions {
|
|||||||
*/
|
*/
|
||||||
export class ConnectionPool {
|
export class ConnectionPool {
|
||||||
/** All live connections. Insertion order doubles as LRU: oldest first. */
|
/** All live connections. Insertion order doubles as LRU: oldest first. */
|
||||||
private readonly cache = new Map<Uuid, AttachConnection>();
|
private readonly cache = new Map<Uuid, PaneConnection>();
|
||||||
|
/** The view each cached connection was actually created for — a deep link
|
||||||
|
* or a restored layout can `mount()` a session before the catalog
|
||||||
|
* snapshot names its view, defaulting it to `"Terminal"`; this is what
|
||||||
|
* lets a later reconciliation notice the resolved view disagrees (#140
|
||||||
|
* review). */
|
||||||
|
private readonly connViews = new Map<Uuid, SessionView>();
|
||||||
/** Sessions currently mounted into a visible pane (never evicted). */
|
/** Sessions currently mounted into a visible pane (never evicted). */
|
||||||
private readonly mounted = new Set<Uuid>();
|
private readonly mounted = new Set<Uuid>();
|
||||||
private readonly hidden: HTMLElement;
|
private readonly hidden: HTMLElement;
|
||||||
private focusedId: Uuid | null = null;
|
private focusedId: Uuid | null = null;
|
||||||
private readonly opts: PoolOptions;
|
private readonly opts: PoolOptions;
|
||||||
|
private readonly unsubscribeStore?: () => void;
|
||||||
|
|
||||||
constructor(opts: PoolOptions = {}) {
|
constructor(opts: PoolOptions = {}) {
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
@@ -54,6 +73,11 @@ export class ConnectionPool {
|
|||||||
this.hidden.className = "conn-cache";
|
this.hidden.className = "conn-cache";
|
||||||
this.hidden.setAttribute("aria-hidden", "true");
|
this.hidden.setAttribute("aria-hidden", "true");
|
||||||
document.body.appendChild(this.hidden);
|
document.body.appendChild(this.hidden);
|
||||||
|
// A session `mount()`ed before its catalog entry exists gets a
|
||||||
|
// `"Terminal"` connection by default; once the snapshot actually names
|
||||||
|
// the view, fix any connection that guessed wrong — nothing else
|
||||||
|
// re-`mount()`s an already-open pane just because the catalog updated.
|
||||||
|
this.unsubscribeStore = opts.store?.subscribe((state) => this.reconcileViews(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The focused session, or null when nothing is focused (welcome pane). */
|
/** The focused session, or null when nothing is focused (welcome pane). */
|
||||||
@@ -77,18 +101,18 @@ export class ConnectionPool {
|
|||||||
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
|
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
|
||||||
let conn = this.cache.get(id);
|
let conn = this.cache.get(id);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
this.cache.delete(id);
|
// `info === undefined` means "session not known yet", not "Terminal" —
|
||||||
this.cache.set(id, conn);
|
// never downgrade a resolved connection just because a later caller
|
||||||
|
// couldn't resolve the session. Only a *resolved* view that disagrees
|
||||||
|
// with what this connection was built for forces a replace.
|
||||||
|
if (info !== undefined && resolveView(info.view) !== this.connViews.get(id)) {
|
||||||
|
conn = this.replace(id, info);
|
||||||
|
} else {
|
||||||
|
this.cache.delete(id);
|
||||||
|
this.cache.set(id, conn); // LRU refresh
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
conn = createAttachConnection(
|
conn = this.create(id, info);
|
||||||
id,
|
|
||||||
{
|
|
||||||
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
|
||||||
onClosed: (sid) => this.opts.onClosed?.(sid),
|
|
||||||
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
|
||||||
},
|
|
||||||
info,
|
|
||||||
);
|
|
||||||
this.cache.set(id, conn);
|
this.cache.set(id, conn);
|
||||||
}
|
}
|
||||||
if (conn.el.parentElement !== pane) {
|
if (conn.el.parentElement !== pane) {
|
||||||
@@ -101,6 +125,71 @@ export class ConnectionPool {
|
|||||||
this.evict();
|
this.evict();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private create(id: Uuid, info?: AttachInfo): PaneConnection {
|
||||||
|
const view = resolveView(info?.view);
|
||||||
|
this.connViews.set(id, view);
|
||||||
|
const connOpts = {
|
||||||
|
onRequestClose: (sid: Uuid) => this.opts.onRequestClose?.(sid),
|
||||||
|
onClosed: (sid: Uuid) => this.opts.onClosed?.(sid),
|
||||||
|
shouldReconnect: (sid: Uuid, code: number) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
||||||
|
};
|
||||||
|
return view === "Structured"
|
||||||
|
? createStructuredConnection(id, connOpts, info, this.opts.store)
|
||||||
|
: createAttachConnection(id, connOpts, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispose whatever is cached for `id` and cache a freshly created
|
||||||
|
* connection in its place. Does not touch the DOM or `mounted` — callers
|
||||||
|
* place the new element themselves. */
|
||||||
|
private replace(id: Uuid, info?: AttachInfo): PaneConnection {
|
||||||
|
this.cache.get(id)?.dispose();
|
||||||
|
const fresh = this.create(id, info);
|
||||||
|
this.cache.set(id, fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proactively fix any cached connection whose view no longer matches the
|
||||||
|
* catalog — the only path that repairs a pane nothing will `mount()`
|
||||||
|
* again on its own (parked, or mounted but not currently being
|
||||||
|
* re-rendered by anything else). A session absent from `state` (not yet
|
||||||
|
* known, or removed) is left alone — that's `main.ts`'s
|
||||||
|
* `closePane`/eviction concern, not a view mismatch. */
|
||||||
|
private reconcileViews(state: CatalogState): void {
|
||||||
|
for (const id of [...this.cache.keys()]) {
|
||||||
|
const session = state.sessions.get(id);
|
||||||
|
if (session === undefined || resolveView(session.view) === this.connViews.get(id)) continue;
|
||||||
|
const existing = this.cache.get(id)!;
|
||||||
|
const parent = existing.el.parentElement;
|
||||||
|
const wasMounted = this.mounted.has(id);
|
||||||
|
// Capture before anything below touches the DOM: dispose()/el.remove()
|
||||||
|
// detach `existing.el`, and once a focused descendant is detached the
|
||||||
|
// browser moves `document.activeElement` to `<body>` — too late to
|
||||||
|
// tell whether the keyboard was actually in this pane.
|
||||||
|
const hadDomFocus = existing.el.contains(document.activeElement);
|
||||||
|
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
|
||||||
|
const fresh = this.replace(id, info);
|
||||||
|
existing.el.remove(); // don't rely on dispose() to clear the DOM
|
||||||
|
if (parent && parent !== this.hidden) {
|
||||||
|
parent.replaceChildren(fresh.el);
|
||||||
|
} else {
|
||||||
|
// Parked: `this.hidden` holds every parked connection's element as
|
||||||
|
// siblings — append, never replaceChildren, or they'd all vanish.
|
||||||
|
this.hidden.appendChild(fresh.el);
|
||||||
|
}
|
||||||
|
if (wasMounted) {
|
||||||
|
// `focusedId` names the session the pool was last told to focus —
|
||||||
|
// but a same-tab sidebar preview moves the keyboard into the
|
||||||
|
// sidebar without clearing it (multiview.ts skips `setFocus` while
|
||||||
|
// previewing), so `focusedId` alone can point at a pane the
|
||||||
|
// keyboard already left. Only re-focus when the DOM agrees it
|
||||||
|
// never did.
|
||||||
|
fresh.onMount({ focus: this.focusedId === id && hadDomFocus });
|
||||||
|
} else {
|
||||||
|
fresh.onUnmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
|
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
|
||||||
unmount(id: Uuid): void {
|
unmount(id: Uuid): void {
|
||||||
if (!this.mounted.delete(id)) return;
|
if (!this.mounted.delete(id)) return;
|
||||||
@@ -177,13 +266,16 @@ export class ConnectionPool {
|
|||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
this.cache.delete(id);
|
this.cache.delete(id);
|
||||||
|
this.connViews.delete(id);
|
||||||
this.mounted.delete(id);
|
this.mounted.delete(id);
|
||||||
if (this.focusedId === id) this.setFocus(null);
|
if (this.focusedId === id) this.setFocus(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
this.unsubscribeStore?.();
|
||||||
for (const conn of this.cache.values()) conn.dispose();
|
for (const conn of this.cache.values()) conn.dispose();
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
|
this.connViews.clear();
|
||||||
this.mounted.clear();
|
this.mounted.clear();
|
||||||
this.hidden.remove();
|
this.hidden.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -172,6 +172,7 @@ const pool = new ConnectionPool({
|
|||||||
onRequestClose: (id) => closePane(id),
|
onRequestClose: (id) => closePane(id),
|
||||||
onClosed: (id) => closePane(id),
|
onClosed: (id) => closePane(id),
|
||||||
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
|
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
|
||||||
|
store,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session death used to reach the UI on two uncoordinated channels — the row
|
// Session death used to reach the UI on two uncoordinated channels — the row
|
||||||
@@ -209,7 +210,7 @@ function scheduleSave(): void {
|
|||||||
|
|
||||||
function infoFor(sessionId: string) {
|
function infoFor(sessionId: string) {
|
||||||
const s = store.snapshot().sessions.get(sessionId);
|
const s = store.snapshot().sessions.get(sessionId);
|
||||||
return s ? { name: sessionName(s), command: s.command } : undefined;
|
return s ? { name: sessionName(s), command: s.command, view: s.view } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drive the shell's responsive layout off whether any pane is open. */
|
/** Drive the shell's responsive layout off whether any pane is open. */
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Shared shape a mounted pane connection satisfies, whatever session view is
|
||||||
|
// behind it — what ConnectionPool needs to mount/park/dispose a pane without
|
||||||
|
// caring whether it holds an xterm attach (attach.ts) or a structured
|
||||||
|
// transcript (structured.ts).
|
||||||
|
|
||||||
|
import type { Uuid } from "./types.js";
|
||||||
|
|
||||||
|
export interface PaneConnection {
|
||||||
|
readonly sessionId: Uuid;
|
||||||
|
/** The element to mount into a pane. */
|
||||||
|
readonly el: HTMLElement;
|
||||||
|
/** Becomes the visible view: fit/focus/sync as the connection needs.
|
||||||
|
* `focus: false` mounts without taking the keyboard (a sidebar preview). */
|
||||||
|
onMount(opts?: { focus?: boolean }): void;
|
||||||
|
/** Parked into the hidden cache: keep streaming, stop driving focus/sizing. */
|
||||||
|
onUnmount(): void;
|
||||||
|
/** Put the keyboard into this pane's primary input — the terminal for an
|
||||||
|
* attach connection, the composer for a structured one. */
|
||||||
|
focusTerminal(): void;
|
||||||
|
/** Raw bytes into a PTY, bypassing normal key handling (the leader's
|
||||||
|
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
|
||||||
|
* has no PTY, so this is a no-op there. */
|
||||||
|
sendInput(data: string): void;
|
||||||
|
/** Fully disconnect and remove the element. */
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { paneTitleAttr, paneTitleHtml } from "./paneTitle.js";
|
||||||
|
|
||||||
|
describe("paneTitleHtml", () => {
|
||||||
|
it("falls back to a short session id when there is no info yet", () => {
|
||||||
|
expect(paneTitleHtml("abcdef12-3456-7890-abcd-ef1234567890")).toContain("abcdef12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the name alone when command matches it", () => {
|
||||||
|
const html = paneTitleHtml("s1", { name: "claude", command: "claude" });
|
||||||
|
expect(html).toContain("claude");
|
||||||
|
expect(html).not.toContain("at-cmd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes a name that looks like markup", () => {
|
||||||
|
const html = paneTitleHtml("s1", { name: "<img onerror=x>", command: "claude" });
|
||||||
|
expect(html).not.toContain("<img");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("paneTitleAttr", () => {
|
||||||
|
it("escapes the attribute form too", () => {
|
||||||
|
const attr = paneTitleAttr("s1", { name: '"quoted"', command: "claude" });
|
||||||
|
expect(attr).not.toContain('"quoted"');
|
||||||
|
expect(attr).toContain(""quoted"");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Pane header title: name/command echoing the sidebar card, shared by the
|
||||||
|
// xterm attach view (attach.ts) and the structured transcript view
|
||||||
|
// (structured.ts) so both panes read identically.
|
||||||
|
|
||||||
|
import { escapeHtml } from "./html.js";
|
||||||
|
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||||
|
import type { SessionView, Uuid } from "./types.js";
|
||||||
|
|
||||||
|
/** What a pane header shows about its session. Falls back to a short session
|
||||||
|
* id when the session isn't known yet. */
|
||||||
|
export interface AttachInfo {
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
/** Which connection kind to mount (#140). Absent/`"Terminal"` mounts the
|
||||||
|
* xterm attach; `"Structured"` mounts the transcript view. */
|
||||||
|
view?: SessionView;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandFragment(command: string): string {
|
||||||
|
const { tool, program } = splitCommand(command);
|
||||||
|
const icon = tool ? toolIconSvg(tool) : null;
|
||||||
|
const text = icon ? "" : program;
|
||||||
|
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paneTitleHtml(sessionId: Uuid, info?: AttachInfo): string {
|
||||||
|
return info
|
||||||
|
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||||||
|
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||||||
|
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attribute-safe (`title="..."`) form of the same identity. */
|
||||||
|
export function paneTitleAttr(sessionId: Uuid, info?: AttachInfo): string {
|
||||||
|
return escapeHtml(info ? `${info.name} · ${info.command} (${sessionId})` : sessionId);
|
||||||
|
}
|
||||||
@@ -37,6 +37,27 @@ export function sessionFocus(id: Uuid): string {
|
|||||||
return `${session(id)}/focus`;
|
return `${session(id)}/focus`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `WS /api/structured/:id` — transcript/state snapshots and live deltas. */
|
||||||
|
export function structured(id: Uuid): string {
|
||||||
|
return `${API_PREFIX}/structured/${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
|
||||||
|
export function structuredPrompt(id: Uuid): string {
|
||||||
|
return `${session(id)}/structured/prompt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
||||||
|
export function structuredCancel(id: Uuid): string {
|
||||||
|
return `${session(id)}/structured/cancel`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
|
||||||
|
* request. */
|
||||||
|
export function structuredApproval(id: Uuid, nonce: string): string {
|
||||||
|
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||||
export function withPathQuery(route: string, value: string): string {
|
export function withPathQuery(route: string, value: string): string {
|
||||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||||
@@ -55,3 +76,7 @@ export function eventsWsUrl(): string {
|
|||||||
export function attachWsUrl(id: Uuid): string {
|
export function attachWsUrl(id: Uuid): string {
|
||||||
return `${wsBase()}${attach(id)}`;
|
return `${wsBase()}${attach(id)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function structuredWsUrl(id: Uuid): string {
|
||||||
|
return `${wsBase()}${structured(id)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,628 @@
|
|||||||
|
// A single live structured session: a transcript + rail bound to the
|
||||||
|
// daemon's `WS /api/structured/:id` (#140). Mirrors attach.ts's contract —
|
||||||
|
// decoupled from the UI, streaming whether or not mounted, torn down only by
|
||||||
|
// `dispose()` — but the wire is JSON rows, not PTY bytes, and every connect
|
||||||
|
// (fresh or reconnect) resends the *whole* transcript rather than resuming a
|
||||||
|
// cursor (docs/STRUCTURED-VIEW.md), so a snapshot always replaces state
|
||||||
|
// wholesale instead of being merged into what came before.
|
||||||
|
|
||||||
|
import {
|
||||||
|
cancelStructuredTurn,
|
||||||
|
resolveStructuredApproval,
|
||||||
|
sendStructuredPrompt,
|
||||||
|
structuredWsUrl,
|
||||||
|
} from "./api.js";
|
||||||
|
import { requestToggleDrawer } from "./drawer.js";
|
||||||
|
import type { CatalogState, CatalogStore } from "./events.js";
|
||||||
|
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||||
|
import type { PaneConnection } from "./pane.js";
|
||||||
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
|
import { buildTranscriptRow, renderRail } from "./structuredRender.js";
|
||||||
|
import type {
|
||||||
|
AcpPlan,
|
||||||
|
AcpUsage,
|
||||||
|
ReducedState,
|
||||||
|
Session,
|
||||||
|
StructuredApprovalDecision,
|
||||||
|
StructuredEvent,
|
||||||
|
TranscriptDelta,
|
||||||
|
TranscriptRow,
|
||||||
|
TranscriptRowKind,
|
||||||
|
Uuid,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export interface StructuredOptions {
|
||||||
|
onRequestClose?: (sessionId: Uuid) => void;
|
||||||
|
onClosed?: (sessionId: Uuid) => void;
|
||||||
|
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injectable so tests can drive the socket without a daemon — the same seam
|
||||||
|
* `CatalogStore` uses (events.ts). */
|
||||||
|
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
|
||||||
|
|
||||||
|
const idleTurn = (): ReducedState["turn"] => ({
|
||||||
|
responding: false,
|
||||||
|
thinking: false,
|
||||||
|
active_tool_calls: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createStructuredConnection(
|
||||||
|
sessionId: Uuid,
|
||||||
|
opts: StructuredOptions = {},
|
||||||
|
info?: AttachInfo,
|
||||||
|
store?: CatalogStore,
|
||||||
|
socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)),
|
||||||
|
): PaneConnection {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "attach structured";
|
||||||
|
el.innerHTML = `
|
||||||
|
<header class="attach-header">
|
||||||
|
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||||
|
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
|
||||||
|
<span class="st-pills"></span>
|
||||||
|
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||||
|
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||||
|
</header>
|
||||||
|
<div class="structured-body">
|
||||||
|
<div class="structured-scroll">
|
||||||
|
<div class="structured-rows"></div>
|
||||||
|
<form class="structured-composer">
|
||||||
|
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary structured-send">Send</button>
|
||||||
|
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<aside class="structured-rail"></aside>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||||
|
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
||||||
|
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||||
|
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
|
||||||
|
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
||||||
|
const railEl = el.querySelector(".structured-rail") as HTMLElement;
|
||||||
|
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
|
||||||
|
const composerForm = el.querySelector(".structured-composer") as HTMLFormElement;
|
||||||
|
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||||
|
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||||
|
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||||
|
|
||||||
|
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
|
||||||
|
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
|
||||||
|
// view): the session list is an off-canvas drawer and this opens it —
|
||||||
|
// same control attach.ts's terminal header carries.
|
||||||
|
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
||||||
|
requestToggleDrawer(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const setConnState = (text: string, kind: "connecting" | "open" | "closed" | "error") => {
|
||||||
|
stateEl.textContent = text;
|
||||||
|
stateEl.dataset.state = kind;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- transcript state: mirrors the daemon's own Projection shape (id ->
|
||||||
|
// index, patched in place) so a Patch never needs to search the array. ----
|
||||||
|
let rows: TranscriptRow[] = [];
|
||||||
|
const rowIndex = new Map<string, number>();
|
||||||
|
const rowEls = new Map<string, HTMLElement>();
|
||||||
|
const expandedRowIds = new Set<string>();
|
||||||
|
let state: ReducedState = { turn: idleTurn() };
|
||||||
|
let pendingApprovalRowId: string | null = null;
|
||||||
|
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
||||||
|
let submissionGeneration = 0;
|
||||||
|
let awaitingBeginGeneration: number | null = null;
|
||||||
|
let acceptedSubmission: { generation: number; socketGeneration: number } | null = null;
|
||||||
|
let socketGeneration = 0;
|
||||||
|
let stateSocketGeneration = 0;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalStateFor(row: TranscriptRow) {
|
||||||
|
if (row.kind !== "approval") return undefined;
|
||||||
|
return {
|
||||||
|
pending: row.id === pendingApprovalRowId,
|
||||||
|
onDecide: (decision: StructuredApprovalDecision) => decide(row.id, decision),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decide(rowId: string, decision: StructuredApprovalDecision): Promise<void> {
|
||||||
|
const nonce = state.pending_approval?.nonce;
|
||||||
|
// Already resolved elsewhere between the click and here — the row's
|
||||||
|
// buttons should already be gone, but a stale nonce is a no-op, not an
|
||||||
|
// error (docs/STRUCTURED-VIEW.md: any nonce answers 204). Resolve
|
||||||
|
// quietly: there is nothing for the row to restore.
|
||||||
|
if (!nonce || rowId !== pendingApprovalRowId) return;
|
||||||
|
try {
|
||||||
|
await resolveStructuredApproval(sessionId, nonce, decision);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Only propagate the failure — so the row re-enables its buttons — if
|
||||||
|
// this is still the pending approval. If it moved on while the
|
||||||
|
// request was in flight (superseded, cancelled, answered elsewhere),
|
||||||
|
// the state-driven re-render already replaced the row; reviving
|
||||||
|
// buttons on it would be reviving a decision that no longer applies.
|
||||||
|
if (!disposed && rowId === pendingApprovalRowId) {
|
||||||
|
showError(String(e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
row,
|
||||||
|
approvalStateFor(row),
|
||||||
|
expandedRowIds.has(row.id),
|
||||||
|
(expanded) => {
|
||||||
|
if (expanded) expandedRowIds.add(row.id);
|
||||||
|
else expandedRowIds.delete(row.id);
|
||||||
|
finishTranscriptLayout();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!el) return null;
|
||||||
|
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||||
|
expandedRowIds.delete(row.id);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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);
|
||||||
|
rowsEl.insertBefore(el, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountRow(row: TranscriptRow): void {
|
||||||
|
const el = buildRowElement(row);
|
||||||
|
if (el) insertRowElement(row, el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remountRow(row: TranscriptRow): void {
|
||||||
|
const old = rowEls.get(row.id);
|
||||||
|
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||||
|
const restoreFocus = document.activeElement === oldToolHead;
|
||||||
|
const el = buildRowElement(row);
|
||||||
|
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 {
|
||||||
|
const forceBottom = !receivedSnapshot;
|
||||||
|
updateTranscriptLayout(() => {
|
||||||
|
rows = [...snapshot.rows];
|
||||||
|
const expandableIds = new Set(
|
||||||
|
rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id),
|
||||||
|
);
|
||||||
|
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
|
||||||
|
rowIndex.clear();
|
||||||
|
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
||||||
|
rowEls.clear();
|
||||||
|
rowsEl.replaceChildren();
|
||||||
|
for (const row of rows) mountRow(row);
|
||||||
|
}, forceBottom);
|
||||||
|
receivedSnapshot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDelta(delta: TranscriptDelta | null | undefined): void {
|
||||||
|
if (!delta) return;
|
||||||
|
updateTranscriptLayout(() => {
|
||||||
|
if (delta.type === "append") {
|
||||||
|
rowIndex.set(delta.row.id, rows.length);
|
||||||
|
rows.push(delta.row);
|
||||||
|
mountRow(delta.row);
|
||||||
|
} else if (delta.type === "patch") {
|
||||||
|
const i = rowIndex.get(delta.row.id);
|
||||||
|
if (i === undefined) {
|
||||||
|
rowIndex.set(delta.row.id, rows.length);
|
||||||
|
rows.push(delta.row);
|
||||||
|
mountRow(delta.row);
|
||||||
|
} else {
|
||||||
|
rows[i] = delta.row;
|
||||||
|
remountRow(delta.row);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const i = rowIndex.get(delta.id);
|
||||||
|
if (i === undefined) return;
|
||||||
|
rows.splice(i, 1);
|
||||||
|
expandedRowIds.delete(delta.id);
|
||||||
|
rowIndex.clear();
|
||||||
|
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||||
|
rowEls.get(delta.id)?.remove();
|
||||||
|
rowEls.delete(delta.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolRowCount(): number {
|
||||||
|
return rows.reduce((n, r) => n + (r.kind === "tool_call" || r.kind === "tool_diff" ? 1 : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPills(): void {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (state.model) parts.push(state.model);
|
||||||
|
if (state.mode) parts.push(state.mode);
|
||||||
|
pillsEl.textContent = parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRailNow(): void {
|
||||||
|
renderRail(railEl, {
|
||||||
|
plan: (state.plan ?? null) as AcpPlan | null,
|
||||||
|
turn: state.turn,
|
||||||
|
toolRowCount: toolRowCount(),
|
||||||
|
usage: (state.usage ?? null) as AcpUsage | null,
|
||||||
|
session: latestSession,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComposerState(): void {
|
||||||
|
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||||
|
input.disabled = active;
|
||||||
|
sendBtn.disabled = active;
|
||||||
|
cancelBtn.hidden = !state.turn.responding && !state.turn.thinking;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileAwaitingBegin(): void {
|
||||||
|
if (state.turn.responding || state.turn.thinking) {
|
||||||
|
awaitingBeginGeneration = null;
|
||||||
|
} else if (
|
||||||
|
awaitingBeginGeneration !== null
|
||||||
|
&& acceptedSubmission?.generation === awaitingBeginGeneration
|
||||||
|
&& stateSocketGeneration > acceptedSubmission.socketGeneration
|
||||||
|
) {
|
||||||
|
awaitingBeginGeneration = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||||
|
state = next;
|
||||||
|
stateSocketGeneration = connectionGeneration;
|
||||||
|
reconcileAwaitingBegin();
|
||||||
|
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||||
|
if (newPendingId !== pendingApprovalRowId) {
|
||||||
|
updateTranscriptLayout(() => {
|
||||||
|
const prev = pendingApprovalRowId;
|
||||||
|
pendingApprovalRowId = newPendingId;
|
||||||
|
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
|
||||||
|
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderPills();
|
||||||
|
renderRailNow();
|
||||||
|
renderComposerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- error banner ----
|
||||||
|
let errorEl: HTMLElement | null = null;
|
||||||
|
function showError(message: string): void {
|
||||||
|
if (!errorEl) {
|
||||||
|
errorEl = document.createElement("div");
|
||||||
|
errorEl.className = "structured-error";
|
||||||
|
composerForm.insertAdjacentElement("beforebegin", errorEl);
|
||||||
|
}
|
||||||
|
errorEl.textContent = message;
|
||||||
|
}
|
||||||
|
function clearError(): void {
|
||||||
|
errorEl?.remove();
|
||||||
|
errorEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- composer ----
|
||||||
|
function autoResize(): void {
|
||||||
|
updateTranscriptLayout(() => {
|
||||||
|
input.style.height = "auto";
|
||||||
|
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
input.addEventListener("input", autoResize);
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
composerForm.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
composerForm.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = input.value;
|
||||||
|
if (!text.trim() || input.disabled) return;
|
||||||
|
const generation = ++submissionGeneration;
|
||||||
|
const submissionSocketGeneration = socketGeneration;
|
||||||
|
awaitingBeginGeneration = generation;
|
||||||
|
acceptedSubmission = null;
|
||||||
|
input.value = "";
|
||||||
|
autoResize();
|
||||||
|
clearError();
|
||||||
|
renderComposerState();
|
||||||
|
sendStructuredPrompt(sessionId, text).then(
|
||||||
|
() => {
|
||||||
|
if (
|
||||||
|
disposed
|
||||||
|
|| generation !== submissionGeneration
|
||||||
|
|| awaitingBeginGeneration !== generation
|
||||||
|
) return;
|
||||||
|
const acceptanceSocketGeneration = socketGeneration;
|
||||||
|
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
|
||||||
|
// A replacement socket may have captured idle before the POST claimed
|
||||||
|
// the prompt. Reconnect again so its snapshot cannot predate the 204.
|
||||||
|
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
||||||
|
},
|
||||||
|
(e: unknown) => {
|
||||||
|
if (disposed || generation !== submissionGeneration) return;
|
||||||
|
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
||||||
|
if (input.value === "") {
|
||||||
|
input.value = text;
|
||||||
|
autoResize();
|
||||||
|
}
|
||||||
|
showError(String(e));
|
||||||
|
renderComposerState();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
cancelBtn.addEventListener("click", () => {
|
||||||
|
clearError();
|
||||||
|
cancelStructuredTurn(sessionId).catch((e: unknown) => {
|
||||||
|
if (!disposed) showError(String(e));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
|
||||||
|
const NARROW_BREAKPOINT = 560;
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
if (disposed) return;
|
||||||
|
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
|
||||||
|
const narrow = width < NARROW_BREAKPOINT;
|
||||||
|
if (bodyEl.classList.contains("st-narrow") === narrow) return;
|
||||||
|
updateTranscriptLayout(() => bodyEl.classList.toggle("st-narrow", narrow));
|
||||||
|
});
|
||||||
|
resizeObserver.observe(bodyEl);
|
||||||
|
|
||||||
|
// ---- live worktree/git-status rail data ----
|
||||||
|
const unsubscribeStore = store?.subscribe((catalog: CatalogState) => {
|
||||||
|
if (disposed) return;
|
||||||
|
latestSession = catalog.sessions.get(sessionId);
|
||||||
|
renderRailNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- socket lifecycle ----
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
function cancelReconnect(): void {
|
||||||
|
if (reconnectTimer === null) return;
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCurrentSocket(): void {
|
||||||
|
const socket = ws;
|
||||||
|
ws = null;
|
||||||
|
if (!socket) return;
|
||||||
|
socket.onopen = null;
|
||||||
|
socket.onmessage = null;
|
||||||
|
socket.onerror = null;
|
||||||
|
socket.onclose = null;
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectForReconciliation(): void {
|
||||||
|
if (disposed) return;
|
||||||
|
cancelReconnect();
|
||||||
|
closeCurrentSocket();
|
||||||
|
setConnState("reconnecting…", "connecting");
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(): void {
|
||||||
|
if (disposed) return;
|
||||||
|
cancelReconnect();
|
||||||
|
closeCurrentSocket();
|
||||||
|
const connectionGeneration = ++socketGeneration;
|
||||||
|
const socket = socketFactory(sessionId);
|
||||||
|
ws = socket;
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (disposed || ws !== socket) return;
|
||||||
|
reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
|
setConnState("connected", "open");
|
||||||
|
};
|
||||||
|
socket.onmessage = (ev) => {
|
||||||
|
if (disposed || ws !== socket) return;
|
||||||
|
if (typeof ev.data !== "string") return;
|
||||||
|
let msg: StructuredEvent;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data) as StructuredEvent;
|
||||||
|
} catch {
|
||||||
|
return; // not a structured event we understand
|
||||||
|
}
|
||||||
|
switch (msg.type) {
|
||||||
|
case "transcript_snapshot":
|
||||||
|
applySnapshot(msg.snapshot);
|
||||||
|
break;
|
||||||
|
case "reduced_state":
|
||||||
|
applyState(msg.state, connectionGeneration);
|
||||||
|
break;
|
||||||
|
case "projection_update":
|
||||||
|
applyDelta(msg.transcript);
|
||||||
|
applyState(msg.state, connectionGeneration);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onerror = () => {
|
||||||
|
if (disposed || ws !== socket) return;
|
||||||
|
setConnState("error", "error");
|
||||||
|
};
|
||||||
|
socket.onclose = (ev) => {
|
||||||
|
if (disposed || ws !== socket) return;
|
||||||
|
ws = null;
|
||||||
|
const reason = ev.reason?.trim();
|
||||||
|
if (ev.code >= 4000 && reason) {
|
||||||
|
setConnState(reason, "error");
|
||||||
|
showError(`cannot attach: ${reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||||||
|
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||||
|
setConnState("reconnecting…", "connecting");
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
if (disposed) return;
|
||||||
|
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnState(`closed (${ev.code})`, "closed");
|
||||||
|
opts.onClosed?.(sessionId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// A pane can be parked again before its deferred focus frame fires (a
|
||||||
|
// preview stepped past it, a split closed it) — the frame must not focus a
|
||||||
|
// hidden textarea out from under whatever the operator moved to since.
|
||||||
|
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
|
||||||
|
// it is no longer the most recent one.
|
||||||
|
let mountToken = 0;
|
||||||
|
let focusFrame: number | null = null;
|
||||||
|
|
||||||
|
function cancelFocusFrame(): void {
|
||||||
|
if (focusFrame !== null) window.cancelAnimationFrame(focusFrame);
|
||||||
|
focusFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
el,
|
||||||
|
onMount(mountOpts?: { focus?: boolean }) {
|
||||||
|
const focusOnMount = mountOpts?.focus !== false;
|
||||||
|
const token = ++mountToken;
|
||||||
|
scrollWorkActive = true;
|
||||||
|
pendingScroll = {
|
||||||
|
top: parkedScrollTop ?? scrollEl.scrollTop,
|
||||||
|
follow: followingBottom,
|
||||||
|
};
|
||||||
|
parkedScrollTop = null;
|
||||||
|
finishTranscriptLayout();
|
||||||
|
cancelFocusFrame();
|
||||||
|
focusFrame = window.requestAnimationFrame(() => {
|
||||||
|
focusFrame = null;
|
||||||
|
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
|
||||||
|
if (focusOnMount && !input.disabled) input.focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUnmount() {
|
||||||
|
mountToken++;
|
||||||
|
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
|
||||||
|
scrollWorkActive = false;
|
||||||
|
cancelScrollFrame();
|
||||||
|
cancelFocusFrame();
|
||||||
|
},
|
||||||
|
focusTerminal() {
|
||||||
|
if (!input.disabled) input.focus();
|
||||||
|
},
|
||||||
|
sendInput() {
|
||||||
|
// No PTY behind a structured session — the leader's literal-byte
|
||||||
|
// escape hatch has nothing to do here.
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
mountToken++;
|
||||||
|
scrollWorkActive = false;
|
||||||
|
cancelScrollFrame();
|
||||||
|
cancelFocusFrame();
|
||||||
|
cancelReconnect();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
unsubscribeStore?.();
|
||||||
|
closeCurrentSocket();
|
||||||
|
expandedRowIds.clear();
|
||||||
|
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||||
|
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||||
|
el.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { computeDiffLines, diffStats, type DiffLine } from "./structuredDiff.js";
|
||||||
|
|
||||||
|
function texts(lines: DiffLine[]): Array<[string, string]> {
|
||||||
|
return lines.map((l) => [l.kind, l.text]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeDiffLines", () => {
|
||||||
|
it("renders a brand new file (no oldText) as all additions", () => {
|
||||||
|
const lines = computeDiffLines(null, "a\nb\n");
|
||||||
|
expect(texts(lines)).toEqual([
|
||||||
|
["add", "a"],
|
||||||
|
["add", "b"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a full deletion as all removals", () => {
|
||||||
|
const lines = computeDiffLines("a\nb\n", "");
|
||||||
|
expect(texts(lines)).toEqual([
|
||||||
|
["del", "a"],
|
||||||
|
["del", "b"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a small replace as context around del/add", () => {
|
||||||
|
const lines = computeDiffLines("one\ntwo\nthree\n", "one\ntwx\nthree\n");
|
||||||
|
expect(texts(lines)).toEqual([
|
||||||
|
["ctx", "one"],
|
||||||
|
["del", "two"],
|
||||||
|
["add", "twx"],
|
||||||
|
["ctx", "three"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves identical text as pure context, uncollapsed when short", () => {
|
||||||
|
const lines = computeDiffLines("a\nb\nc\n", "a\nb\nc\n");
|
||||||
|
expect(texts(lines)).toEqual([
|
||||||
|
["ctx", "a"],
|
||||||
|
["ctx", "b"],
|
||||||
|
["ctx", "c"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
||||||
|
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
||||||
|
const newText = [...common.slice(0, 10), "NEW", ...common.slice(10)].join("\n");
|
||||||
|
const lines = computeDiffLines(oldText, newText);
|
||||||
|
// The leading run keeps only its last 3 lines (adjacent to the change),
|
||||||
|
// behind a marker for the rest; the trailing run mirrors that.
|
||||||
|
expect(lines[0].kind).toBe("hunk");
|
||||||
|
expect(lines[1]).toEqual({ kind: "ctx", text: "line7" });
|
||||||
|
expect(lines[2]).toEqual({ kind: "ctx", text: "line8" });
|
||||||
|
expect(lines[3]).toEqual({ kind: "ctx", text: "line9" });
|
||||||
|
expect(lines[4].kind).toBe("del");
|
||||||
|
expect(lines[5].kind).toBe("add");
|
||||||
|
expect(lines[6]).toEqual({ kind: "ctx", text: "line10" });
|
||||||
|
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", () => {
|
||||||
|
// 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.
|
||||||
|
const middle = Array.from({ length: 10 }, (_, i) => `c${i}`);
|
||||||
|
const oldText = ["lead0", "lead1", "OLD1", ...middle, "OLD2", "trail0", "trail1"].join("\n");
|
||||||
|
const newText = ["lead0", "lead1", "NEW1", ...middle, "NEW2", "trail0", "trail1"].join("\n");
|
||||||
|
const lines = computeDiffLines(oldText, newText);
|
||||||
|
const dels = lines.filter((l) => l.kind === "del").map((l) => l.text);
|
||||||
|
const adds = lines.filter((l) => l.kind === "add").map((l) => l.text);
|
||||||
|
expect(dels).toEqual(["OLD1", "OLD2"]);
|
||||||
|
expect(adds).toEqual(["NEW1", "NEW2"]);
|
||||||
|
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(1);
|
||||||
|
expect(texts(lines).slice(0, 2)).toEqual([
|
||||||
|
["ctx", "lead0"],
|
||||||
|
["ctx", "lead1"],
|
||||||
|
]);
|
||||||
|
expect(texts(lines).slice(-3)).toEqual([
|
||||||
|
["ctx", "trail0"],
|
||||||
|
["ctx", "trail1"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an old file with no trailing newline", () => {
|
||||||
|
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
||||||
|
expect(texts(lines)).toEqual([
|
||||||
|
["ctx", "a"],
|
||||||
|
["del", "b"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
["add", "b"],
|
||||||
|
["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", () => {
|
||||||
|
it("counts add/del lines, ignoring context and hunk markers", () => {
|
||||||
|
const lines = computeDiffLines("a\nb\nc\n", "a\nx\ny\nc\n");
|
||||||
|
expect(diffStats(lines)).toEqual({ added: 2, removed: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is zero for identical text", () => {
|
||||||
|
expect(diffStats(computeDiffLines("same\n", "same\n"))).toEqual({ added: 0, removed: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP
|
||||||
|
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
||||||
|
// coloured hunks the design calls for have to be computed here.
|
||||||
|
|
||||||
|
export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof";
|
||||||
|
|
||||||
|
export interface DiffLine {
|
||||||
|
kind: DiffLineKind;
|
||||||
|
/** The line's text (a `hunk` line is a human summary, not source text). */
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interior unchanged runs longer than this collapse to a summary line
|
||||||
|
* flanked by this many lines of real context on each side. */
|
||||||
|
const CONTEXT = 3;
|
||||||
|
|
||||||
|
/** Bounds the O(n·m) LCS table: above this cell count, the changed middle is
|
||||||
|
* shown as one coarse del-then-add block instead of a fine-grained diff —
|
||||||
|
* still correct, just not minimal. */
|
||||||
|
const LCS_CELL_LIMIT = 250_000;
|
||||||
|
|
||||||
|
interface SourceLine {
|
||||||
|
text: string;
|
||||||
|
terminated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
* shared prefix/suffix by the caller. */
|
||||||
|
function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
if (n === 0) return b.map((line) => ({ kind: "add", line }));
|
||||||
|
if (m === 0) return a.map((line) => ({ kind: "del", line }));
|
||||||
|
if (n * m > LCS_CELL_LIMIT) {
|
||||||
|
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:].
|
||||||
|
const columns = m + 1;
|
||||||
|
const dp = new Uint32Array((n + 1) * columns);
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
dp[i * columns + j] = sameLine(a[i], b[j])
|
||||||
|
? dp[(i + 1) * columns + j + 1] + 1
|
||||||
|
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ops: Op[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (sameLine(a[i], b[j])) {
|
||||||
|
ops.push({ kind: "ctx", line: a[i] });
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
|
||||||
|
ops.push({ kind: "del", line: a[i] });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
ops.push({ kind: "add", line: b[j] });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < n) ops.push({ kind: "del", line: a[i++] });
|
||||||
|
while (j < m) ops.push({ kind: "add", line: b[j++] });
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||||
|
let pre = 0;
|
||||||
|
while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++;
|
||||||
|
let suf = 0;
|
||||||
|
while (
|
||||||
|
suf < a.length - pre &&
|
||||||
|
suf < b.length - pre &&
|
||||||
|
sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf])
|
||||||
|
) {
|
||||||
|
suf++;
|
||||||
|
}
|
||||||
|
const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line }));
|
||||||
|
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));
|
||||||
|
return [...prefixCtx, ...middle, ...suffixCtx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse a long run of unchanged lines to a summary, keeping up to
|
||||||
|
* {@link CONTEXT} real lines adjacent to each neighbouring change — same
|
||||||
|
* shape a unified diff gives a reader, without claiming line numbers this
|
||||||
|
* module never tracked. A run touching the start/end of the whole diff has
|
||||||
|
* no change to be adjacent to on that side, so it keeps no context there
|
||||||
|
* (unless it is the *only* run, i.e. nothing changed at all). */
|
||||||
|
function hunk(ops: Op[]): 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;
|
||||||
|
while (i < ops.length) {
|
||||||
|
if (ops[i].kind !== "ctx") {
|
||||||
|
push(ops[i]);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let j = i;
|
||||||
|
while (j < ops.length && ops[j].kind === "ctx") j++;
|
||||||
|
const run = ops.slice(i, j);
|
||||||
|
const wholeDiff = i === 0 && j === ops.length;
|
||||||
|
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
||||||
|
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
||||||
|
if (run.length <= keepHead + keepTail) {
|
||||||
|
run.forEach(push);
|
||||||
|
} else {
|
||||||
|
if (keepHead) run.slice(0, keepHead).forEach(push);
|
||||||
|
const collapsed = run.slice(keepHead, run.length - keepTail);
|
||||||
|
out.push({
|
||||||
|
kind: "hunk",
|
||||||
|
text: `⋯ ${collapsed.length} unchanged lines ⋯`,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent
|
||||||
|
* (a new file) renders every line of `newText` as added. */
|
||||||
|
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
||||||
|
const newLines = splitLines(newText);
|
||||||
|
if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line })));
|
||||||
|
const ops = diffLines(splitLines(oldText), newLines);
|
||||||
|
return hunk(ops);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `+added -removed` counts straight off the diff — real derived data, not a
|
||||||
|
* fabricated duration or line count. */
|
||||||
|
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
|
||||||
|
let added = 0;
|
||||||
|
let removed = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.kind === "add") added++;
|
||||||
|
else if (line.kind === "del") removed++;
|
||||||
|
}
|
||||||
|
return { added, removed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
branchFromWorktreePath,
|
||||||
|
buildTranscriptRow,
|
||||||
|
renderInlineMarkdown,
|
||||||
|
renderRail,
|
||||||
|
} from "./structuredRender.js";
|
||||||
|
import type { Session, TranscriptRow } from "./types.js";
|
||||||
|
|
||||||
|
function row<K extends TranscriptRow["kind"]>(
|
||||||
|
kind: K,
|
||||||
|
data: unknown,
|
||||||
|
id = "r1",
|
||||||
|
): TranscriptRow & { kind: K } {
|
||||||
|
return { id, kind, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildTranscriptRow: user/agent/thought", () => {
|
||||||
|
it("renders a user row with the text content and no markdown", () => {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
row("user", { content: { type: "text", text: "hi <b>there</b>" } }),
|
||||||
|
);
|
||||||
|
expect(el.className).toContain("st-row-user");
|
||||||
|
expect(el.textContent).toContain("hi <b>there</b>");
|
||||||
|
// never actual markup from user input
|
||||||
|
expect(el.querySelector("b")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders agent prose as paragraphs with inline markdown", () => {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }),
|
||||||
|
);
|
||||||
|
expect(el.className).toContain("st-row-agent");
|
||||||
|
const ps = el.querySelectorAll("p");
|
||||||
|
expect(ps).toHaveLength(2);
|
||||||
|
expect(ps[0].innerHTML).toContain("<strong>two</strong>");
|
||||||
|
expect(ps[0].innerHTML).toContain("<code>three</code>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a thought row as plain text", () => {
|
||||||
|
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } }));
|
||||||
|
expect(el.className).toContain("st-row-thought");
|
||||||
|
expect(el.textContent).toBe("hmm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a labelled placeholder for non-text content, never crashing", () => {
|
||||||
|
const el = buildTranscriptRow(row("user", { content: { type: "image" } }));
|
||||||
|
expect(el.textContent).toContain("[image]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never clips text: a very long user message is not truncated", () => {
|
||||||
|
const long = "x".repeat(5000);
|
||||||
|
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
|
||||||
|
expect(el.textContent).toContain(long);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderInlineMarkdown", () => {
|
||||||
|
it("escapes raw HTML before applying markdown", () => {
|
||||||
|
expect(renderInlineMarkdown("<script>alert(1)</script>")).not.toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a single asterisk pair meant as bold as nested italics", () => {
|
||||||
|
expect(renderInlineMarkdown("**bold**")).toBe("<strong>bold</strong>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTranscriptRow: tool calls", () => {
|
||||||
|
it("shows glyph, name, argument and no meta for a completed non-diff call", () => {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
row("tool_call", {
|
||||||
|
toolCallId: "t1",
|
||||||
|
title: "Read(src/lib.rs)",
|
||||||
|
kind: "read",
|
||||||
|
status: "completed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||||||
|
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Read");
|
||||||
|
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("src/lib.rs");
|
||||||
|
expect(head.querySelector(".st-tool-meta")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the kind label and full title when the title has no Name(arg) shape", () => {
|
||||||
|
const el = buildTranscriptRow(row("tool_call", { title: "does something odd", kind: "execute" }));
|
||||||
|
const head = el.querySelector(".st-tool-head")!;
|
||||||
|
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Execute");
|
||||||
|
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("does something odd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a running badge for an in-progress call", () => {
|
||||||
|
const el = buildTranscriptRow(row("tool_call", { title: "Bash(sleep 1)", status: "in_progress" }));
|
||||||
|
const meta = el.querySelector(".st-tool-meta")!;
|
||||||
|
expect(meta.textContent).toBe("running");
|
||||||
|
expect(meta.className).toContain("st-tool-meta-running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a failed badge for a failed call", () => {
|
||||||
|
const el = buildTranscriptRow(row("tool_call", { title: "Bash(false)", status: "failed" }));
|
||||||
|
expect(el.querySelector(".st-tool-meta")!.textContent).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts collapsed and expands its text output on click", () => {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
row("tool_call", {
|
||||||
|
title: "Grep(x)",
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "content", content: { type: "text", text: "match found" } }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||||||
|
const out = el.querySelector(".st-tool-out") as HTMLElement;
|
||||||
|
expect(out.hidden).toBe(true);
|
||||||
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
head.click();
|
||||||
|
expect(out.hidden).toBe(false);
|
||||||
|
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(out.textContent).toContain("match found");
|
||||||
|
head.click();
|
||||||
|
expect(out.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expand when there is nothing to show", () => {
|
||||||
|
const el = buildTranscriptRow(row("tool_call", { title: "Read(x)", status: "completed" }));
|
||||||
|
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||||||
|
head.click();
|
||||||
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTranscriptRow: tool diffs", () => {
|
||||||
|
it("shows +added -removed derived from the diff, and renders coloured hunks on expand", () => {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
row("tool_diff", {
|
||||||
|
title: "Edit(x.rs)",
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "diff", path: "/repo/x.rs", oldText: "a\nb\n", newText: "a\nc\n" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const meta = el.querySelector(".st-tool-meta")!;
|
||||||
|
expect(meta.textContent).toContain("+1");
|
||||||
|
expect(meta.textContent).toContain("−1");
|
||||||
|
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||||
|
const diff = el.querySelector(".st-diff") as HTMLElement;
|
||||||
|
expect(diff).not.toBeNull();
|
||||||
|
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
|
||||||
|
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the diff its own horizontally-scrolling container", () => {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
row("tool_diff", {
|
||||||
|
title: "Edit(x.rs)",
|
||||||
|
content: [{ type: "diff", path: "/repo/x.rs", newText: "new file\n" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||||
|
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
|
||||||
|
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||||||
|
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
const approvalRow = (options: Array<{ optionId: string; name: string; kind: string }>) =>
|
||||||
|
row("approval", {
|
||||||
|
sessionId: "s1",
|
||||||
|
toolCall: { toolCallId: "t1", title: "Write outside worktree", locations: [{ path: "/etc/x" }] },
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allThreeOptions = [
|
||||||
|
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||||
|
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
|
||||||
|
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("shows the three decision buttons only when the request offers all three option kinds", () => {
|
||||||
|
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||||
|
pending: true,
|
||||||
|
onDecide: () => Promise.resolve(),
|
||||||
|
});
|
||||||
|
const buttons = el.querySelectorAll(".st-approval-acts button");
|
||||||
|
expect(buttons).toHaveLength(3);
|
||||||
|
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the buttons and shows an answered note once resolved", () => {
|
||||||
|
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||||
|
pending: false,
|
||||||
|
onDecide: () => Promise.resolve(),
|
||||||
|
});
|
||||||
|
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||||
|
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||||||
|
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||||
|
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||||
|
buttons[2].click();
|
||||||
|
expect(onDecide).toHaveBeenCalledWith("deny");
|
||||||
|
expect(buttons[0].disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a row with no approval state as not pending, defensively", () => {
|
||||||
|
const el = buildTranscriptRow(approvalRow(allThreeOptions));
|
||||||
|
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Allow for session when the request offers no allow_always option", () => {
|
||||||
|
const el = buildTranscriptRow(
|
||||||
|
approvalRow([
|
||||||
|
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||||
|
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
|
||||||
|
]),
|
||||||
|
{ pending: true, onDecide: () => Promise.resolve() },
|
||||||
|
);
|
||||||
|
const labels = [...el.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent);
|
||||||
|
expect(labels).toEqual(["Allow once", "Deny"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Deny for either reject_once or reject_always, never for neither", () => {
|
||||||
|
const rejectAlwaysOnly = buildTranscriptRow(
|
||||||
|
approvalRow([{ optionId: "r2", name: "Reject always", kind: "reject_always" }]),
|
||||||
|
{ pending: true, onDecide: () => Promise.resolve() },
|
||||||
|
);
|
||||||
|
expect([...rejectAlwaysOnly.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent)).toEqual([
|
||||||
|
"Deny",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a 'no decision offered' note instead of an empty action row when nothing matches", () => {
|
||||||
|
const el = buildTranscriptRow(approvalRow([{ optionId: "x", name: "Something else", kind: "allow_once" }]), {
|
||||||
|
pending: true,
|
||||||
|
onDecide: () => Promise.resolve(),
|
||||||
|
});
|
||||||
|
// allow_once is offered, so this specific case still shows a button —
|
||||||
|
// use a kind the client doesn't map to any decision at all.
|
||||||
|
expect(el.querySelectorAll(".st-approval-acts button")).toHaveLength(1);
|
||||||
|
|
||||||
|
const none = buildTranscriptRow(approvalRow([]), { pending: true, onDecide: () => Promise.resolve() });
|
||||||
|
expect(none.querySelector(".st-approval-acts")).toBeNull();
|
||||||
|
expect(none.querySelector(".st-approval-resolved")!.textContent).toBe("no decision offered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-enables the buttons when onDecide rejects", async () => {
|
||||||
|
const onDecide = vi.fn().mockRejectedValue(new Error("network down"));
|
||||||
|
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||||
|
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||||
|
buttons[0].click();
|
||||||
|
expect(buttons[0].disabled).toBe(true);
|
||||||
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
expect(buttons[0].disabled).toBe(false);
|
||||||
|
expect(buttons[1].disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the buttons disabled when onDecide resolves", async () => {
|
||||||
|
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||||
|
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||||
|
buttons[0].click();
|
||||||
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
|
expect(buttons[0].disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||||
|
it("renders notice and unknown row kinds safely", () => {
|
||||||
|
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
|
||||||
|
"[notice]",
|
||||||
|
);
|
||||||
|
const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow;
|
||||||
|
expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no transcript element for a plan row", () => {
|
||||||
|
expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("branchFromWorktreePath", () => {
|
||||||
|
it("takes everything after the last /worktrees/ segment", () => {
|
||||||
|
expect(branchFromWorktreePath("/home/n/tea/maestro/worktrees/acp-bridge")).toBe("acp-bridge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps slashes in a branch name that contains them", () => {
|
||||||
|
expect(branchFromWorktreePath("/repo/worktrees/feature/login")).toBe("feature/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the basename when there is no /worktrees/ marker", () => {
|
||||||
|
expect(branchFromWorktreePath("/some/other/path")).toBe("path");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: "s1",
|
||||||
|
cwd: "/repo",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderRail", () => {
|
||||||
|
it("omits every section that has nothing to show", () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
renderRail(el, {
|
||||||
|
plan: null,
|
||||||
|
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||||
|
toolRowCount: 0,
|
||||||
|
usage: null,
|
||||||
|
session: session(),
|
||||||
|
});
|
||||||
|
// Turn always renders (it has a state and a count); the rest are absent.
|
||||||
|
expect(el.querySelectorAll("h3")).toHaveLength(1);
|
||||||
|
expect(el.querySelector("h3")!.textContent).toBe("Turn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the plan with status marks", () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
renderRail(el, {
|
||||||
|
plan: {
|
||||||
|
entries: [
|
||||||
|
{ content: "done thing", priority: "high", status: "completed" },
|
||||||
|
{ content: "now thing", priority: "high", status: "in_progress" },
|
||||||
|
{ content: "later thing", priority: "low", status: "pending" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||||
|
toolRowCount: 0,
|
||||||
|
usage: null,
|
||||||
|
session: session(),
|
||||||
|
});
|
||||||
|
const items = el.querySelectorAll(".st-plan li");
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
expect(items[0].className).toBe("st-plan-done");
|
||||||
|
expect(items[1].className).toBe("st-plan-now");
|
||||||
|
expect(items[2].className).toBe("st-plan-todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders context usage and a meter proportional to used/size", () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
renderRail(el, {
|
||||||
|
plan: null,
|
||||||
|
turn: { responding: true, thinking: false, active_tool_calls: ["a"] },
|
||||||
|
toolRowCount: 3,
|
||||||
|
usage: { used: 50_000, size: 200_000 },
|
||||||
|
session: session(),
|
||||||
|
});
|
||||||
|
expect(el.querySelector(".st-meter i")!.getAttribute("style")).toContain("25%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the worktree section only when the session has one", () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
renderRail(el, {
|
||||||
|
plan: null,
|
||||||
|
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||||
|
toolRowCount: 0,
|
||||||
|
usage: null,
|
||||||
|
session: session({
|
||||||
|
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
|
||||||
|
blocked_work: { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const headers = [...el.querySelectorAll("h3")].map((h) => h.textContent);
|
||||||
|
expect(headers).toContain("Worktree");
|
||||||
|
const rows = [...el.querySelectorAll(".st-kv-row")].map((r) => r.textContent);
|
||||||
|
expect(rows.some((t) => t?.includes("feat/x"))).toBe(true);
|
||||||
|
expect(rows.some((t) => t?.includes("3"))).toBe(true); // ahead
|
||||||
|
expect(rows.some((t) => t?.includes("dirty") && t.includes("3"))).toBe(true); // 2+1
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
// Pure per-row and rail DOM builders for the structured transcript (#140).
|
||||||
|
// No socket, no daemon calls — structured.ts owns the connection and calls
|
||||||
|
// into here with data already in hand, so every rendering decision here is
|
||||||
|
// unit-testable without a WebSocket.
|
||||||
|
|
||||||
|
import { escapeHtml } from "./html.js";
|
||||||
|
import { computeDiffLines, diffStats } from "./structuredDiff.js";
|
||||||
|
import type {
|
||||||
|
AcpContentBlock,
|
||||||
|
AcpPermissionOption,
|
||||||
|
AcpPlan,
|
||||||
|
AcpToolCallContent,
|
||||||
|
AcpToolCallLike,
|
||||||
|
AcpUsage,
|
||||||
|
InTheWay,
|
||||||
|
Session,
|
||||||
|
StructuredApprovalDecision,
|
||||||
|
TranscriptRow,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// row taxonomy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Glyph shown in a tool row's head, by `ToolKind`. Unrecognised/absent kinds
|
||||||
|
* fall back to the generic bullet — never a blank glyph column. */
|
||||||
|
const KIND_GLYPH: Record<string, string> = {
|
||||||
|
read: "≡",
|
||||||
|
edit: "±",
|
||||||
|
delete: "✕",
|
||||||
|
move: "→",
|
||||||
|
search: "⌕",
|
||||||
|
execute: "$",
|
||||||
|
think: "…",
|
||||||
|
fetch: "⇣",
|
||||||
|
switch_mode: "⇄",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
read: "Read",
|
||||||
|
edit: "Edit",
|
||||||
|
delete: "Delete",
|
||||||
|
move: "Move",
|
||||||
|
search: "Search",
|
||||||
|
execute: "Execute",
|
||||||
|
think: "Think",
|
||||||
|
fetch: "Fetch",
|
||||||
|
switch_mode: "Switch mode",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toolGlyph(kind: string | undefined): string {
|
||||||
|
return (kind && KIND_GLYPH[kind]) || "•";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolKindLabel(kind: string | undefined): string {
|
||||||
|
return (kind && KIND_LABEL[kind]) || "Tool";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a title into `{label, arg}` when it follows the adapter's own
|
||||||
|
* `Name(argument)` convention; otherwise the whole title is the arg and the
|
||||||
|
* tool's `kind` supplies a generic label. Never guesses at a shape the data
|
||||||
|
* doesn't actually show. */
|
||||||
|
function splitToolTitle(
|
||||||
|
title: string | undefined,
|
||||||
|
name: string | null | undefined,
|
||||||
|
kind: string | undefined,
|
||||||
|
): { label: string; arg: string } {
|
||||||
|
const label = name ?? toolKindLabel(kind);
|
||||||
|
if (!title) return { label, arg: "" };
|
||||||
|
const m = /^(\S+)\(([\s\S]*)\)$/.exec(title);
|
||||||
|
if (m) return { label: name ?? m[1], arg: m[2] };
|
||||||
|
return { label, arg: title };
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag: string, className?: string): HTMLElement {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content-block text, or a labelled placeholder for anything that isn't
|
||||||
|
* text — never dropped silently, never fabricated. */
|
||||||
|
function contentBlockText(block: AcpContentBlock | undefined): string {
|
||||||
|
if (!block) return "";
|
||||||
|
switch (block.type) {
|
||||||
|
case "text":
|
||||||
|
return block.text;
|
||||||
|
case "image":
|
||||||
|
return "[image]";
|
||||||
|
case "audio":
|
||||||
|
return "[audio]";
|
||||||
|
case "resource_link":
|
||||||
|
return `[resource: ${block.name ?? block.uri ?? "linked"}]`;
|
||||||
|
case "resource":
|
||||||
|
return "[resource]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tiny, dependency-free inline renderer: `**bold**`, `*italic*`, and
|
||||||
|
* `` `code` `` become real markup; everything else stays escaped plain
|
||||||
|
* text. No block-level markdown (lists, headings) — agent prose is
|
||||||
|
* paragraphs, not documents. */
|
||||||
|
export function renderInlineMarkdown(text: string): string {
|
||||||
|
const escaped = escapeHtml(text);
|
||||||
|
return escaped
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProse(container: HTMLElement, text: string): void {
|
||||||
|
const paragraphs = text.split(/\n{2,}/);
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
if (!para) continue;
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.innerHTML = renderInlineMarkdown(para);
|
||||||
|
container.appendChild(p);
|
||||||
|
}
|
||||||
|
if (container.children.length === 0) container.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// row builders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement {
|
||||||
|
const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined;
|
||||||
|
const text = contentBlockText(data?.content);
|
||||||
|
const wrap = el("div", `st-row st-row-${variant}`);
|
||||||
|
const body = el("div", "st-body");
|
||||||
|
if (variant === "user") {
|
||||||
|
const who = el("div", "st-who");
|
||||||
|
who.textContent = "You";
|
||||||
|
body.appendChild(who);
|
||||||
|
const line = el("div", "st-user-text");
|
||||||
|
line.textContent = text;
|
||||||
|
body.appendChild(line);
|
||||||
|
} else if (variant === "agent") {
|
||||||
|
renderProse(body, text);
|
||||||
|
} else {
|
||||||
|
body.textContent = text;
|
||||||
|
}
|
||||||
|
wrap.appendChild(body);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiffBlock(diff: { path: string; oldText?: string | null; newText: string }): HTMLElement {
|
||||||
|
const wrap = el("div", "st-diff-block");
|
||||||
|
const label = el("div", "st-diff-path");
|
||||||
|
label.textContent = diff.path;
|
||||||
|
label.title = diff.path;
|
||||||
|
wrap.appendChild(label);
|
||||||
|
const scroller = el("div", "st-diff");
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = `st-ln st-ln-${line.kind}`;
|
||||||
|
span.textContent = line.text;
|
||||||
|
pre.appendChild(span);
|
||||||
|
pre.appendChild(document.createTextNode("\n"));
|
||||||
|
}
|
||||||
|
scroller.appendChild(pre);
|
||||||
|
wrap.appendChild(scroller);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `+added -removed` across every diff block in a tool call's content —
|
||||||
|
* real derived data, never a fabricated duration. */
|
||||||
|
function toolDiffMeta(content: AcpToolCallContent[]): string {
|
||||||
|
let added = 0;
|
||||||
|
let removed = 0;
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type !== "diff") continue;
|
||||||
|
const stats = diffStats(computeDiffLines(block.oldText, block.newText));
|
||||||
|
added += stats.added;
|
||||||
|
removed += stats.removed;
|
||||||
|
}
|
||||||
|
return `+${added} −${removed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMeta(status: string | undefined, hasDiff: boolean): { text: string; kind: string } | null {
|
||||||
|
if (status === "in_progress") return { text: "running", kind: "running" };
|
||||||
|
if (status === "failed") return { text: "failed", kind: "failed" };
|
||||||
|
if (status === "pending" && !hasDiff) return { text: "pending", kind: "pending" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolRow(
|
||||||
|
row: TranscriptRow,
|
||||||
|
expanded: boolean,
|
||||||
|
onExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||||
|
const content = tool.content ?? [];
|
||||||
|
const hasDiff = content.some((c) => c.type === "diff");
|
||||||
|
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
|
||||||
|
|
||||||
|
const wrap = el("div", "st-row st-row-tool");
|
||||||
|
const card = el("div", "st-tool");
|
||||||
|
wrap.appendChild(card);
|
||||||
|
|
||||||
|
const head = document.createElement("button");
|
||||||
|
head.type = "button";
|
||||||
|
head.className = "st-tool-head";
|
||||||
|
head.setAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
const glyph = el("span", "st-tool-glyph");
|
||||||
|
glyph.textContent = toolGlyph(tool.kind);
|
||||||
|
glyph.setAttribute("aria-hidden", "true");
|
||||||
|
const name = el("span", "st-tool-name");
|
||||||
|
name.textContent = label;
|
||||||
|
const argEl = el("span", "st-tool-arg");
|
||||||
|
argEl.textContent = arg;
|
||||||
|
argEl.title = arg;
|
||||||
|
head.append(glyph, name, argEl);
|
||||||
|
|
||||||
|
const metaParts: string[] = [];
|
||||||
|
if (hasDiff) metaParts.push(toolDiffMeta(content));
|
||||||
|
const meta = statusMeta(tool.status, hasDiff);
|
||||||
|
if (meta) metaParts.push(meta.text);
|
||||||
|
if (metaParts.length > 0) {
|
||||||
|
const metaEl = el("span", "st-tool-meta");
|
||||||
|
if (meta) metaEl.classList.add(`st-tool-meta-${meta.kind}`);
|
||||||
|
metaEl.textContent = metaParts.join(" · ");
|
||||||
|
head.appendChild(metaEl);
|
||||||
|
}
|
||||||
|
card.appendChild(head);
|
||||||
|
|
||||||
|
const out = el("div", "st-tool-out");
|
||||||
|
out.hidden = true;
|
||||||
|
let outBuilt = false;
|
||||||
|
const buildOut = () => {
|
||||||
|
if (outBuilt) return;
|
||||||
|
outBuilt = true;
|
||||||
|
const texts = content
|
||||||
|
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
|
||||||
|
.map((c) => contentBlockText(c.content))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (texts.length > 0) {
|
||||||
|
const textPanel = el("div", "st-tool-text");
|
||||||
|
textPanel.textContent = texts.join("\n");
|
||||||
|
out.appendChild(textPanel);
|
||||||
|
}
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === "diff") out.appendChild(buildDiffBlock(block));
|
||||||
|
if (block.type === "terminal") {
|
||||||
|
const note = el("div", "st-tool-text");
|
||||||
|
note.textContent = "[embedded terminal]";
|
||||||
|
out.appendChild(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.children.length === 0) out.hidden = true;
|
||||||
|
};
|
||||||
|
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", () => {
|
||||||
|
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
||||||
|
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
|
||||||
|
});
|
||||||
|
setExpanded(expanded);
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlainRow(row: TranscriptRow): HTMLElement {
|
||||||
|
const wrap = el("div", "st-row st-row-notice");
|
||||||
|
const body = el("div", "st-body");
|
||||||
|
body.textContent = `[${row.kind}]`;
|
||||||
|
wrap.appendChild(body);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalRowState {
|
||||||
|
pending: boolean;
|
||||||
|
/** Resolves once the decision has been submitted; rejects when the POST
|
||||||
|
* failed and this approval is still the one awaiting a decision, so the
|
||||||
|
* caller knows to restore its controls. Never rejects for a stale click
|
||||||
|
* (the approval already moved on) — there is nothing to restore then. */
|
||||||
|
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which decisions a permission request actually backs, by the
|
||||||
|
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
|
||||||
|
* "Decision mapping"). A decision with no matching option resolves
|
||||||
|
* `Cancelled` server-side regardless of what the client sent — offering its
|
||||||
|
* button would let the operator "allow" something that silently no-ops. */
|
||||||
|
function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
|
||||||
|
const kinds = new Set(options.map((o) => o.kind));
|
||||||
|
const decisions: StructuredApprovalDecision[] = [];
|
||||||
|
if (kinds.has("allow_once")) decisions.push("allow_once");
|
||||||
|
if (kinds.has("allow_always")) decisions.push("allow_for_session");
|
||||||
|
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
|
||||||
|
return decisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
||||||
|
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||||
|
const toolCall = request.toolCall ?? {};
|
||||||
|
const wrap = el("div", "st-row st-row-approval");
|
||||||
|
const card = el("div", "st-approval");
|
||||||
|
wrap.appendChild(card);
|
||||||
|
|
||||||
|
const title = el("div", "st-approval-title");
|
||||||
|
title.innerHTML = `<span aria-hidden="true">⚠</span> permission requested`;
|
||||||
|
card.appendChild(title);
|
||||||
|
|
||||||
|
const what = el("div", "st-approval-what");
|
||||||
|
what.textContent = toolCall.title ?? toolKindLabel(toolCall.kind);
|
||||||
|
card.appendChild(what);
|
||||||
|
|
||||||
|
const path = toolCall.locations?.[0]?.path;
|
||||||
|
if (path) {
|
||||||
|
const pathEl = el("div", "st-approval-path");
|
||||||
|
pathEl.textContent = path;
|
||||||
|
card.appendChild(pathEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approval.pending) {
|
||||||
|
const acts = el("div", "st-approval-acts");
|
||||||
|
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = cls ? `act ${cls}` : "act";
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
||||||
|
buttons.forEach((b) => (b.disabled = true));
|
||||||
|
approval.onDecide(decision).catch(() => {
|
||||||
|
// Rejects only when this approval is still pending (see
|
||||||
|
// ApprovalRowState) — restore the controls so the operator can
|
||||||
|
// retry; the error itself is surfaced by the connection.
|
||||||
|
buttons.forEach((b) => (b.disabled = false));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
};
|
||||||
|
const offered = offeredDecisions(request.options ?? []);
|
||||||
|
const specs: [StructuredApprovalDecision, string, string][] = [
|
||||||
|
["allow_once", "Allow once", "primary"],
|
||||||
|
["allow_for_session", "Allow for session", ""],
|
||||||
|
["deny", "Deny", "deny"],
|
||||||
|
];
|
||||||
|
for (const [decision, label, cls] of specs) {
|
||||||
|
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
||||||
|
}
|
||||||
|
if (acts.children.length > 0) {
|
||||||
|
card.appendChild(acts);
|
||||||
|
} else {
|
||||||
|
const none = el("div", "st-approval-resolved");
|
||||||
|
none.textContent = "no decision offered";
|
||||||
|
card.appendChild(none);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resolved = el("div", "st-approval-resolved");
|
||||||
|
resolved.textContent = "answered";
|
||||||
|
card.appendChild(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the DOM for one transcript row, or `null` for a plan shown only in
|
||||||
|
* the rail. `approval` is consulted only for an `approval` row; tool
|
||||||
|
* expansion is supplied and reported explicitly. */
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow & { kind: "plan" },
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded?: boolean,
|
||||||
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): null;
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow & { kind: 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;
|
||||||
|
switch (row.kind) {
|
||||||
|
case "user":
|
||||||
|
built = buildContentRow(row, "user");
|
||||||
|
break;
|
||||||
|
case "agent_text":
|
||||||
|
built = buildContentRow(row, "agent");
|
||||||
|
break;
|
||||||
|
case "agent_thought":
|
||||||
|
built = buildContentRow(row, "thought");
|
||||||
|
break;
|
||||||
|
case "tool_call":
|
||||||
|
case "tool_diff":
|
||||||
|
built = buildToolRow(row, toolExpanded, onToolExpandedChange);
|
||||||
|
break;
|
||||||
|
case "approval":
|
||||||
|
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||||
|
break;
|
||||||
|
case "plan":
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
built = buildPlainRow(row);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
built.dataset.rowId = row.id;
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// rail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PLAN_MARK: Record<string, string> = { completed: "✓", in_progress: "▸", pending: "·" };
|
||||||
|
const PLAN_CLASS: Record<string, string> = { completed: "done", in_progress: "now", pending: "todo" };
|
||||||
|
|
||||||
|
function renderPlanSection(plan: AcpPlan | null | undefined): HTMLElement | null {
|
||||||
|
if (!plan || plan.entries.length === 0) return null;
|
||||||
|
const section = el("div", "st-rail-section");
|
||||||
|
const h = document.createElement("h3");
|
||||||
|
h.textContent = "Plan";
|
||||||
|
section.appendChild(h);
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "st-plan";
|
||||||
|
for (const entry of plan.entries) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = `st-plan-${PLAN_CLASS[entry.status] ?? "todo"}`;
|
||||||
|
li.setAttribute("aria-label", `${entry.status.replace("_", " ")}: ${entry.content}`);
|
||||||
|
const mark = el("span", "st-plan-mark");
|
||||||
|
mark.textContent = PLAN_MARK[entry.status] ?? "·";
|
||||||
|
mark.setAttribute("aria-hidden", "true");
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent = entry.content;
|
||||||
|
li.append(mark, text);
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
section.appendChild(list);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kv(label: string, value: string): HTMLElement {
|
||||||
|
const row = el("div", "st-kv-row");
|
||||||
|
const k = el("span", "st-kv-k");
|
||||||
|
k.textContent = label;
|
||||||
|
const v = el("span", "st-kv-v");
|
||||||
|
v.textContent = value;
|
||||||
|
row.append(k, v);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTurnSection(
|
||||||
|
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] },
|
||||||
|
toolRowCount: number,
|
||||||
|
usage: AcpUsage | null | undefined,
|
||||||
|
): HTMLElement {
|
||||||
|
const section = el("div", "st-rail-section");
|
||||||
|
const h = document.createElement("h3");
|
||||||
|
h.textContent = "Turn";
|
||||||
|
section.appendChild(h);
|
||||||
|
const box = el("div", "st-kv");
|
||||||
|
const activity = turn.thinking ? "thinking" : turn.responding ? "responding" : "idle";
|
||||||
|
box.appendChild(kv("state", activity));
|
||||||
|
box.appendChild(kv("active tools", String(turn.active_tool_calls.length)));
|
||||||
|
box.appendChild(kv("tool calls", String(toolRowCount)));
|
||||||
|
if (usage?.cost) box.appendChild(kv("cost", `$${usage.cost.amount.toFixed(2)}`));
|
||||||
|
section.appendChild(box);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContextSection(usage: AcpUsage | null | undefined): HTMLElement | null {
|
||||||
|
if (!usage) return null;
|
||||||
|
const section = el("div", "st-rail-section");
|
||||||
|
const h = document.createElement("h3");
|
||||||
|
h.textContent = "Context";
|
||||||
|
section.appendChild(h);
|
||||||
|
const box = el("div", "st-kv");
|
||||||
|
box.appendChild(kv("used", formatTokenCount(usage.used)));
|
||||||
|
box.appendChild(kv("window", formatTokenCount(usage.size)));
|
||||||
|
section.appendChild(box);
|
||||||
|
const meter = el("div", "st-meter");
|
||||||
|
const fill = el("i");
|
||||||
|
const pct = usage.size > 0 ? Math.min(100, (usage.used / usage.size) * 100) : 0;
|
||||||
|
fill.style.width = `${pct}%`;
|
||||||
|
meter.appendChild(fill);
|
||||||
|
section.appendChild(meter);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenCount(n: number): string {
|
||||||
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `SessionWorktree.path` is `<repo>/worktrees/<branch>` (docs/WORKTREES.md);
|
||||||
|
* the branch is not carried anywhere else on the wire, so derive it here. */
|
||||||
|
export function branchFromWorktreePath(path: string): string {
|
||||||
|
const marker = "/worktrees/";
|
||||||
|
const i = path.indexOf(marker);
|
||||||
|
if (i >= 0) return path.slice(i + marker.length);
|
||||||
|
return path.split("/").filter(Boolean).pop() ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorktreeSection(session: Session | undefined): HTMLElement | null {
|
||||||
|
const worktree = session?.worktree;
|
||||||
|
if (!worktree) return null;
|
||||||
|
const section = el("div", "st-rail-section");
|
||||||
|
const h = document.createElement("h3");
|
||||||
|
h.textContent = "Worktree";
|
||||||
|
section.appendChild(h);
|
||||||
|
const box = el("div", "st-kv");
|
||||||
|
box.appendChild(kv("branch", branchFromWorktreePath(worktree.path)));
|
||||||
|
const blocked: InTheWay | null | undefined = session?.blocked_work;
|
||||||
|
box.appendChild(kv("ahead", String(blocked?.unpushed_commits ?? 0)));
|
||||||
|
const dirty = (blocked?.uncommitted_files ?? 0) + (blocked?.untracked_files ?? 0);
|
||||||
|
box.appendChild(kv("dirty", String(dirty)));
|
||||||
|
section.appendChild(box);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RailContext {
|
||||||
|
plan: AcpPlan | null | undefined;
|
||||||
|
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] };
|
||||||
|
toolRowCount: number;
|
||||||
|
usage: AcpUsage | null | undefined;
|
||||||
|
session: Session | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild the rail wholesale from the latest state — rails are small, so a
|
||||||
|
* full rebuild per update is simpler and cheap enough that no incremental
|
||||||
|
* patching is worth the complexity. */
|
||||||
|
export function renderRail(container: HTMLElement, ctx: RailContext): void {
|
||||||
|
const sections = [
|
||||||
|
renderPlanSection(ctx.plan),
|
||||||
|
renderTurnSection(ctx.turn, ctx.toolRowCount, ctx.usage),
|
||||||
|
renderContextSection(ctx.usage),
|
||||||
|
renderWorktreeSection(ctx.session),
|
||||||
|
].filter((s): s is HTMLElement => s !== null);
|
||||||
|
container.replaceChildren(...sections);
|
||||||
|
}
|
||||||
@@ -2165,6 +2165,510 @@ input.leaf-rename:focus {
|
|||||||
border-color: var(--accent-strong);
|
border-color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- structured view (#140) ----------
|
||||||
|
`.attach.structured` reuses `.attach`'s flex column, `.attach-header` and
|
||||||
|
`.attach-state` verbatim (attach.ts and structured.ts share paneTitle.ts's
|
||||||
|
header markup) — only the body differs: a transcript column + rail grid
|
||||||
|
instead of an xterm surface. Hierarchy is deliberate: the approval is the
|
||||||
|
only lifted, card-like element on the page; everything else is either a
|
||||||
|
tinted block (user), plain text (agent), or a thin bordered row (tool). */
|
||||||
|
|
||||||
|
.st-pills {
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 232px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-body.st-narrow {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-body.st-narrow .structured-rail {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
max-height: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-scroll {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-rows {
|
||||||
|
padding: var(--sp-3) var(--sp-3) 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-rail {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-5);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-rail h3 {
|
||||||
|
margin: 0 0 var(--sp-2);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-plan {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-plan li {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-plan-mark {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-plan-done {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-plan-now {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-plan-now .st-plan-mark,
|
||||||
|
.st-plan-done .st-plan-mark {
|
||||||
|
color: var(--live);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-plan-todo {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-plan-todo .st-plan-mark {
|
||||||
|
color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-kv {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-kv-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-kv-k {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-kv-v {
|
||||||
|
color: var(--ink);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-meter {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-meter i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--live);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* every transcript row carries its own layout; none of them are cards */
|
||||||
|
.st-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* user turn: the only tinted block besides the approval — an accent rail,
|
||||||
|
never a full border, so it still reads lighter than the approval card. */
|
||||||
|
.st-row-user {
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-who {
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
margin-bottom: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-user-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* agent prose: no card, no background — plain text is the point */
|
||||||
|
.st-row-agent .st-body p {
|
||||||
|
margin: 0 0 var(--sp-2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-agent .st-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-agent code {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
padding: 0 3px;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-thought {
|
||||||
|
color: var(--faint);
|
||||||
|
font-style: italic;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-notice {
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tool rows: bordered, compact, deliberately not lifted */
|
||||||
|
.st-tool {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-head {
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-head:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-glyph {
|
||||||
|
width: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-name {
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-arg {
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-meta {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
color: var(--faint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-meta-running {
|
||||||
|
color: var(--live);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-meta-failed {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-out {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `display: flex` above outranks the UA sheet's `[hidden]` (same pitfall as
|
||||||
|
`.wt-step`/`.raw-wrap`) — restate it explicitly. */
|
||||||
|
.st-tool-out[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-tool-text {
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-diff-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-diff-path {
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the diff gets its own horizontal scroller, independent of the page */
|
||||||
|
.st-diff {
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-diff pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-ln {
|
||||||
|
display: block;
|
||||||
|
white-space: pre;
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-ln-add {
|
||||||
|
background: color-mix(in oklab, var(--live) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-ln-del {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-ln-ctx {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-ln-hunk {
|
||||||
|
color: var(--faint);
|
||||||
|
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
|
||||||
|
operator owes a decision on */
|
||||||
|
.st-approval {
|
||||||
|
border: 1px solid var(--warn);
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: color-mix(in oklab, var(--warn) 12%, var(--surface));
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-approval-title {
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-approval-what {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-approval-path {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-approval-acts {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-approval-resolved {
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.act {
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform var(--dur-fast) var(--ease-out),
|
||||||
|
background var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.act:hover {
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.act:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.act:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.act.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
border-color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.act.deny {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.act.deny:hover {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* composer: sticky at the bottom of the scrolling transcript column */
|
||||||
|
.structured-composer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 160px;
|
||||||
|
resize: none;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-input:disabled {
|
||||||
|
color: var(--faint);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `.btn` sets `display: inline-flex`, which outranks the UA sheet's
|
||||||
|
`[hidden]` — restate it explicitly (same pitfall as `.st-tool-out`). */
|
||||||
|
.structured-cancel[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structured-error {
|
||||||
|
margin: 0 var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--danger-tint);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.act {
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- modal ---------- */
|
/* ---------- modal ---------- */
|
||||||
|
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -60,8 +60,15 @@ export interface Session {
|
|||||||
* `session/new` (#134). Only a `Structured` session whose handshake
|
* `session/new` (#134). Only a `Structured` session whose handshake
|
||||||
* succeeded has one; a respawn passes it to `session/load` (#135). */
|
* succeeded has one; a respawn passes it to `session/load` (#135). */
|
||||||
acp_session_id?: string | null;
|
acp_session_id?: string | null;
|
||||||
|
/** Terminal (PTY bytes) or structured (ACP rows) (#133). Fixed at spawn and
|
||||||
|
* immutable for the session's life. Defaults to `"Terminal"` on the wire, so
|
||||||
|
* absent means terminal. */
|
||||||
|
view?: SessionView;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A session's process shape (#133): mirrors `maestro_protocol::SessionView`. */
|
||||||
|
export type SessionView = "Terminal" | "Structured";
|
||||||
|
|
||||||
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||||
export interface SessionWorktree {
|
export interface SessionWorktree {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -269,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 =
|
||||||
@@ -367,6 +375,189 @@ export function abbreviateCwd(cwd: string, home?: string): string {
|
|||||||
return cwd;
|
return cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Structured-session projections (#133/#140). Mirror of
|
||||||
|
// `maestro_protocol`'s "Structured-session projections" section. `data`,
|
||||||
|
// `ReducedState.plan/usage/context/pending_approval` and every `Acp*` type
|
||||||
|
// below are the daemon's opaque ACP payloads (`serde_json::Value` on the
|
||||||
|
// wire) — shaped to `claude-code-acp`'s ACP v1 schema, not covered by
|
||||||
|
// maestro-protocol's own compatibility guarantee. Treat every field as
|
||||||
|
// optional and degrade gracefully rather than trust this shape exactly.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
|
||||||
|
export type TranscriptRowKind =
|
||||||
|
| "user"
|
||||||
|
| "agent_text"
|
||||||
|
| "agent_thought"
|
||||||
|
| "tool_call"
|
||||||
|
| "tool_diff"
|
||||||
|
| "approval"
|
||||||
|
| "plan"
|
||||||
|
| "notice";
|
||||||
|
|
||||||
|
/** One row of a structured session's transcript. */
|
||||||
|
export interface TranscriptRow {
|
||||||
|
id: string;
|
||||||
|
kind: TranscriptRowKind;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete transcript at a durable event-log position (`WS
|
||||||
|
* /api/structured/:id`'s first message, and every replay page). */
|
||||||
|
export interface TranscriptSnapshot {
|
||||||
|
cursor: number;
|
||||||
|
rows: TranscriptRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A change to one transcript row, addressed by `id`. */
|
||||||
|
export type TranscriptDelta =
|
||||||
|
| { type: "append"; row: TranscriptRow }
|
||||||
|
| { type: "patch"; row: TranscriptRow }
|
||||||
|
| { type: "remove"; id: string };
|
||||||
|
|
||||||
|
/** Per-turn indicators derived from the ACP stream — read verbatim, never
|
||||||
|
* re-derived client-side (docs/STRUCTURED-VIEW.md). */
|
||||||
|
export interface TurnState {
|
||||||
|
responding: boolean;
|
||||||
|
thinking: boolean;
|
||||||
|
active_tool_calls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A whole-session view derived from ACP notifications. `plan`/`usage`/
|
||||||
|
* `context`/`pending_approval` are opaque ACP JSON — see the module note
|
||||||
|
* above. `pending_approval` is `{nonce, request: AcpRequestPermissionRequest}`
|
||||||
|
* while a permission request is live, absent otherwise. */
|
||||||
|
export interface ReducedState {
|
||||||
|
turn: TurnState;
|
||||||
|
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
||||||
|
plan?: AcpPlan | null;
|
||||||
|
usage?: AcpUsage | null;
|
||||||
|
context?: AcpUsage | null;
|
||||||
|
model?: string | null;
|
||||||
|
mode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server messages on `WS /api/structured/:id`. */
|
||||||
|
export type StructuredEvent =
|
||||||
|
| { type: "transcript_snapshot"; snapshot: TranscriptSnapshot }
|
||||||
|
| { type: "reduced_state"; state: ReducedState }
|
||||||
|
| { type: "projection_update"; transcript?: TranscriptDelta | null; state: ReducedState };
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/prompt` body. */
|
||||||
|
export interface StructuredPromptRequest {
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
|
||||||
|
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
||||||
|
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
||||||
|
|
||||||
|
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
||||||
|
|
||||||
|
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a
|
||||||
|
* generic placeholder rather than being dropped silently. */
|
||||||
|
export type AcpContentBlock =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "image"; mimeType?: string }
|
||||||
|
| { type: "audio"; mimeType?: string }
|
||||||
|
| { type: "resource_link"; uri?: string; name?: string }
|
||||||
|
| { type: "resource"; resource?: unknown };
|
||||||
|
|
||||||
|
/** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */
|
||||||
|
export interface AcpContentChunk {
|
||||||
|
content: AcpContentBlock;
|
||||||
|
messageId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ToolKind`, snake_case on the wire. */
|
||||||
|
export type AcpToolKind =
|
||||||
|
| "read"
|
||||||
|
| "edit"
|
||||||
|
| "delete"
|
||||||
|
| "move"
|
||||||
|
| "search"
|
||||||
|
| "execute"
|
||||||
|
| "think"
|
||||||
|
| "fetch"
|
||||||
|
| "switch_mode"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
/** `ToolCallStatus`, snake_case on the wire. */
|
||||||
|
export type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
|
||||||
|
|
||||||
|
/** `Diff` — a file modification. `oldText` absent means a new file. */
|
||||||
|
export interface AcpDiff {
|
||||||
|
path: string;
|
||||||
|
oldText?: string | null;
|
||||||
|
newText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ToolCallContent`. */
|
||||||
|
export type AcpToolCallContent =
|
||||||
|
| { type: "content"; content: AcpContentBlock }
|
||||||
|
| ({ type: "diff" } & AcpDiff)
|
||||||
|
| { type: "terminal"; terminalId?: string };
|
||||||
|
|
||||||
|
export interface AcpToolCallLocation {
|
||||||
|
path: string;
|
||||||
|
line?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ToolCall` (a `ToolCall`/`ToolDiff` row's `data`) and, loosely, the
|
||||||
|
* flattened `ToolCallUpdate` an approval's `toolCall` field carries — the
|
||||||
|
* two share every field below, just with different subsets required. */
|
||||||
|
export interface AcpToolCallLike {
|
||||||
|
toolCallId?: string;
|
||||||
|
title?: string;
|
||||||
|
/** Unstable ACP capability; absent when the adapter doesn't send one. */
|
||||||
|
name?: string | null;
|
||||||
|
kind?: AcpToolKind;
|
||||||
|
status?: AcpToolCallStatus;
|
||||||
|
content?: AcpToolCallContent[];
|
||||||
|
locations?: AcpToolCallLocation[];
|
||||||
|
rawInput?: unknown;
|
||||||
|
rawOutput?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PlanEntryPriority` / `PlanEntryStatus`, snake_case on the wire. */
|
||||||
|
export type AcpPlanPriority = "high" | "medium" | "low";
|
||||||
|
export type AcpPlanEntryStatus = "pending" | "in_progress" | "completed";
|
||||||
|
|
||||||
|
export interface AcpPlanEntry {
|
||||||
|
content: string;
|
||||||
|
priority: AcpPlanPriority;
|
||||||
|
status: AcpPlanEntryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Plan` — `ReducedState.plan`. */
|
||||||
|
export interface AcpPlan {
|
||||||
|
entries: AcpPlanEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `UsageUpdate` — `ReducedState.usage`/`.context` (the daemon folds one
|
||||||
|
* update into both fields). */
|
||||||
|
export interface AcpUsage {
|
||||||
|
used: number;
|
||||||
|
size: number;
|
||||||
|
cost?: { amount: number; currency: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpPermissionOption {
|
||||||
|
optionId: string;
|
||||||
|
name: string;
|
||||||
|
kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `RequestPermissionRequest`, serialized without its nonce (the nonce lives
|
||||||
|
* only in `ReducedState.pending_approval.nonce`) — an `Approval` row's `data`,
|
||||||
|
* and `ReducedState.pending_approval.request`. */
|
||||||
|
export interface AcpRequestPermissionRequest {
|
||||||
|
sessionId: string;
|
||||||
|
toolCall: AcpToolCallLike;
|
||||||
|
options: AcpPermissionOption[];
|
||||||
|
}
|
||||||
|
|
||||||
/** `~/git.naps.pt/naps62/maestro` → `~/g/n/maestro`: keep the last segment
|
/** `~/git.naps.pt/naps62/maestro` → `~/g/n/maestro`: keep the last segment
|
||||||
* verbatim, collapse every earlier one to its first character. Two-or-fewer
|
* verbatim, collapse every earlier one to its first character. Two-or-fewer
|
||||||
* segments are left alone (nothing to gain). Returns escaped HTML with the
|
* segments are left alone (nothing to gain). Returns escaped HTML with the
|
||||||
|
|||||||
Reference in New Issue
Block a user