Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba1078386 | ||
|
|
f0a1774e4d | ||
|
|
ac720eb266 | ||
|
|
0ba83c3d8f | ||
|
|
e8e98baf4d | ||
|
|
b286323c9c | ||
|
|
71ec7e073c | ||
|
|
46bae13212 | ||
|
|
20d303278d | ||
|
|
589e0144e2 | ||
|
|
0061378193 | ||
|
|
1cf4825b1d | ||
|
|
f799aaf564 | ||
|
|
5af5ceb9a8 | ||
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
b9f8f02e11 | ||
|
|
f36b7808a7 | ||
|
|
c2d427691d | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 | ||
|
|
3c10a3a08a | ||
|
|
fd79c8116a | ||
|
|
cb8b93f178 | ||
|
|
60e57fde31 | ||
|
|
8d35f7f8c8 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
88afd0df3f | ||
|
|
df51aa7be9 | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
43bfda5a60 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 | ||
|
|
0543f66469 | ||
|
|
4a2f7e98b4 | ||
|
|
8c151a5fb2 | ||
|
|
8d0886f9b7 | ||
|
|
9f82400f0a | ||
|
|
059316cbac | ||
|
|
0b1c9e15cf | ||
|
|
e30084ea24 | ||
|
|
bb898a75f3 | ||
|
|
c8e90af91c | ||
|
|
d0df60ac87 | ||
|
|
25244803a9 | ||
|
|
92b41a8742 | ||
|
|
508262bbac | ||
|
|
002f1b466b | ||
|
|
bc27bffbae | ||
|
|
bbf622ed9b | ||
|
|
0c46fb993a | ||
|
|
be554d0ede | ||
|
|
4fda393378 | ||
|
|
a1f3690aaf | ||
|
|
4ce432c227 | ||
|
|
ba7fd68004 | ||
|
|
c3d008d07f | ||
|
|
c355f67133 | ||
|
|
b5c9970e28 | ||
|
|
22d740a36b | ||
|
|
ccdfec8257 | ||
|
|
30f91176ae | ||
|
|
a771d29210 | ||
|
|
913421e244 | ||
|
|
2e7c5f7f58 | ||
|
|
dba3d8c249 | ||
|
|
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 | ||
|
|
3529947497 | ||
|
|
4851ad9101 | ||
|
|
3cb4bebd1e | ||
|
|
0e20baf7c2 | ||
|
|
af8acbfbf2 | ||
|
|
1ad7845f51 |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git fetch:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(./node_modules/.bin/tsc:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+2
@@ -1467,6 +1467,7 @@ dependencies = [
|
||||
"maestro-cli",
|
||||
"maestro-daemon",
|
||||
"maestro-tui",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -1477,6 +1478,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
|
||||
@@ -5,11 +5,12 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "ACP client role: one claude-code-acp child per structured session"
|
||||
description = "ACP client role: one provider adapter child per structured session"
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol.workspace = true
|
||||
libc.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
+415
-48
@@ -1,5 +1,5 @@
|
||||
//! ACP client role for the daemon (#134): one `claude-code-acp` child per
|
||||
//! structured session.
|
||||
//! ACP client role for the daemon: one provider adapter child per structured
|
||||
//! session.
|
||||
//!
|
||||
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the
|
||||
//! adapter as a child process with stdio pipes, in the session's `cwd` with
|
||||
@@ -9,10 +9,10 @@
|
||||
//!
|
||||
//! **The handshake declares almost nothing.** `initialize` reports
|
||||
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
|
||||
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the
|
||||
//! daemon to act on its behalf — exactly what already happens in the
|
||||
//! terminal view. `session/request_permission` is the one client method that
|
||||
//! gets a real implementation, in #139; here it is a stub that declines.
|
||||
//! adapter uses the provider's own file and command tools instead of asking
|
||||
//! the daemon to act on its behalf — exactly what already happens in the
|
||||
//! terminal view. `session/request_permission` is the one client method with
|
||||
//! a real implementation.
|
||||
//!
|
||||
//! Every `session/update` notification the adapter sends lands on an
|
||||
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
|
||||
@@ -42,30 +42,62 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agent_client_protocol::schema::v1::{
|
||||
CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, Implementation,
|
||||
InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest,
|
||||
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId,
|
||||
SessionNotification,
|
||||
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
|
||||
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
|
||||
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
|
||||
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
|
||||
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
|
||||
SetSessionConfigOptionRequest, SetSessionModeRequest,
|
||||
};
|
||||
use agent_client_protocol::schema::ProtocolVersion;
|
||||
use agent_client_protocol::{ByteStreams, Client};
|
||||
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
pub use agent_client_protocol::schema::v1 as schema;
|
||||
|
||||
/// The adapter binary, resolved on `PATH`.
|
||||
pub const ADAPTER_BIN: &str = "claude-code-acp";
|
||||
/// ACP adapter selected from the structured session's resolved command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AcpAdapter {
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
}
|
||||
|
||||
/// How to install the adapter when it is missing. Named in the error so an
|
||||
/// operator staring at a failed session knows what to run.
|
||||
impl AcpAdapter {
|
||||
pub const fn binary(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCode => "claude-code-acp",
|
||||
Self::Codex => "codex-acp",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn install_command(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCode => "npm install -g --prefix ~/.local @zed-industries/claude-code-acp",
|
||||
Self::Codex => "npm install -g --prefix ~/.local @agentclientprotocol/codex-acp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AcpAdapter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::ClaudeCode => "Claude Code",
|
||||
Self::Codex => "Codex",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility aliases for Claude Code callers.
|
||||
pub const ADAPTER_BIN: &str = "claude-code-acp";
|
||||
pub const INSTALL_COMMAND: &str =
|
||||
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
|
||||
|
||||
/// How long `initialize` + `session/new` may take before the spawn is
|
||||
/// declared failed. The real adapter boots a node process and the Claude
|
||||
/// Code SDK behind it; a slow machine takes seconds, never a minute.
|
||||
/// declared failed. The real adapters boot a node process and their provider
|
||||
/// client behind it; a slow machine takes seconds, never a minute.
|
||||
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
|
||||
@@ -78,6 +110,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Bytes of the child's stderr kept for the failure message.
|
||||
const STDERR_TAIL_BYTES: usize = 4096;
|
||||
const LEGACY_MODEL_CONFIG_ID: &str = "__maestro_legacy_model";
|
||||
const LEGACY_MODE_CONFIG_ID: &str = "__maestro_legacy_mode";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||
#[request(method = "session/new", response = ExtendedNewSessionResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtendedNewSessionRequest {
|
||||
cwd: PathBuf,
|
||||
mcp_servers: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtendedNewSessionResponse {
|
||||
session_id: SessionId,
|
||||
#[serde(default)]
|
||||
modes: Option<SessionModeState>,
|
||||
#[serde(default)]
|
||||
config_options: Option<Vec<SessionConfigOption>>,
|
||||
#[serde(default)]
|
||||
models: Option<LegacyModelState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||
#[request(method = "session/load", response = ExtendedLoadSessionResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtendedLoadSessionRequest {
|
||||
session_id: SessionId,
|
||||
cwd: PathBuf,
|
||||
mcp_servers: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExtendedLoadSessionResponse {
|
||||
#[serde(default)]
|
||||
modes: Option<SessionModeState>,
|
||||
#[serde(default)]
|
||||
config_options: Option<Vec<SessionConfigOption>>,
|
||||
#[serde(default)]
|
||||
models: Option<LegacyModelState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LegacyModelState {
|
||||
current_model_id: String,
|
||||
available_models: Vec<LegacyModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LegacyModel {
|
||||
model_id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
|
||||
#[request(method = "session/set_model", response = LegacySetModelResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LegacySetModelRequest {
|
||||
session_id: SessionId,
|
||||
model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
|
||||
struct LegacySetModelResponse {}
|
||||
|
||||
/// What [`spawn`] needs to start one adapter.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -94,6 +195,8 @@ pub struct SpawnConfig {
|
||||
/// instead of opening a fresh one. The adapter must advertise
|
||||
/// `loadSession`; the handshake fails otherwise.
|
||||
pub load_session: Option<String>,
|
||||
/// The provider-specific adapter to launch.
|
||||
pub adapter: AcpAdapter,
|
||||
}
|
||||
|
||||
impl SpawnConfig {
|
||||
@@ -103,6 +206,7 @@ impl SpawnConfig {
|
||||
env,
|
||||
handshake_timeout: HANDSHAKE_TIMEOUT,
|
||||
load_session: None,
|
||||
adapter: AcpAdapter::ClaudeCode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +215,11 @@ impl SpawnConfig {
|
||||
self.load_session = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
|
||||
self.adapter = adapter;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A blocked ACP permission request delivered to the daemon.
|
||||
@@ -126,31 +235,39 @@ pub struct PermissionRequest {
|
||||
pub enum AcpError {
|
||||
/// The adapter is not on `PATH`. The message names the install command
|
||||
/// so the failed session is actionable rather than silently dead.
|
||||
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")]
|
||||
AdapterMissing,
|
||||
#[error("failed to start {ADAPTER_BIN}: {0}")]
|
||||
Spawn(#[source] std::io::Error),
|
||||
#[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
|
||||
AdapterMissing { adapter: AcpAdapter },
|
||||
#[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
|
||||
Spawn {
|
||||
adapter: AcpAdapter,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// The adapter answered the handshake with an error.
|
||||
#[error("ACP handshake failed: {0}")]
|
||||
Handshake(String),
|
||||
/// The adapter went away before the handshake finished. Carries the
|
||||
/// tail of its stderr, which is where a missing Claude login shows up.
|
||||
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")]
|
||||
ExitedEarly { status: String, stderr: String },
|
||||
#[error("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
|
||||
ExitedEarly {
|
||||
adapter: AcpAdapter,
|
||||
status: String,
|
||||
stderr: String,
|
||||
},
|
||||
#[error("ACP handshake timed out after {0:?}")]
|
||||
HandshakeTimeout(Duration),
|
||||
}
|
||||
|
||||
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
|
||||
/// session env's `PATH` when it sets one, the daemon's own otherwise.
|
||||
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> {
|
||||
pub fn resolve_adapter(adapter: AcpAdapter, env: &HashMap<String, String>) -> Option<PathBuf> {
|
||||
let path: OsString = match env.get("PATH") {
|
||||
Some(p) => OsString::from(p),
|
||||
None => std::env::var_os("PATH")?,
|
||||
};
|
||||
std::env::split_paths(&path)
|
||||
.filter(|dir| !dir.as_os_str().is_empty())
|
||||
.map(|dir| dir.join(ADAPTER_BIN))
|
||||
.map(|dir| dir.join(adapter.binary()))
|
||||
.find(|candidate| is_executable(candidate))
|
||||
}
|
||||
|
||||
@@ -168,9 +285,20 @@ enum Command {
|
||||
turn: u64,
|
||||
done: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
SetConfig {
|
||||
config_id: String,
|
||||
value: SessionConfigOptionValue,
|
||||
done: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
struct ConnectionConfig {
|
||||
cwd: PathBuf,
|
||||
load_session: Option<String>,
|
||||
adapter: AcpAdapter,
|
||||
}
|
||||
|
||||
enum ChildCommand {
|
||||
Signal(libc::c_int),
|
||||
Reap(oneshot::Sender<bool>),
|
||||
@@ -301,6 +429,23 @@ impl AcpSession {
|
||||
Ok(done_rx)
|
||||
}
|
||||
|
||||
/// Change one option advertised by the ACP agent.
|
||||
pub fn set_config(
|
||||
&self,
|
||||
config_id: String,
|
||||
value: SessionConfigOptionValue,
|
||||
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
self.commands
|
||||
.send(Command::SetConfig {
|
||||
config_id,
|
||||
value,
|
||||
done: done_tx,
|
||||
})
|
||||
.map_err(|_| "ACP connection is closed".to_string())?;
|
||||
Ok(done_rx)
|
||||
}
|
||||
|
||||
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
|
||||
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
|
||||
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
|
||||
@@ -341,7 +486,9 @@ impl Drop for AcpSession {
|
||||
/// Fails without a running child: on any error the process is already
|
||||
/// signalled, so a failed session never leaves a stray adapter behind.
|
||||
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?;
|
||||
let provider = config.adapter;
|
||||
let adapter = resolve_adapter(provider, &config.env)
|
||||
.ok_or(AcpError::AdapterMissing { adapter: provider })?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&adapter);
|
||||
cmd.current_dir(&config.cwd)
|
||||
@@ -369,18 +516,27 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(AcpError::Spawn)?;
|
||||
let mut child = cmd.spawn().map_err(|source| AcpError::Spawn {
|
||||
adapter: provider,
|
||||
source,
|
||||
})?;
|
||||
let pid = child.id().expect("child pid is known before wait");
|
||||
let stdin = child.stdin.take().expect("piped stdin");
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
|
||||
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
|
||||
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone(), provider));
|
||||
|
||||
let (exit_tx, exit_rx) = watch::channel(None);
|
||||
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
|
||||
tokio::spawn(supervise_child(
|
||||
child,
|
||||
pid,
|
||||
child_commands_rx,
|
||||
exit_tx,
|
||||
provider,
|
||||
));
|
||||
|
||||
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
||||
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
||||
@@ -389,8 +545,11 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||
tokio::spawn(run_connection(
|
||||
transport,
|
||||
config.cwd.clone(),
|
||||
config.load_session.clone(),
|
||||
ConnectionConfig {
|
||||
cwd: config.cwd.clone(),
|
||||
load_session: config.load_session.clone(),
|
||||
adapter: provider,
|
||||
},
|
||||
updates_tx,
|
||||
permission_tx,
|
||||
commands_rx,
|
||||
@@ -423,6 +582,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
.to_string()
|
||||
};
|
||||
AcpError::ExitedEarly {
|
||||
adapter: provider,
|
||||
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
|
||||
stderr: if stderr.is_empty() {
|
||||
String::new()
|
||||
@@ -457,12 +617,13 @@ async fn supervise_child(
|
||||
pid: u32,
|
||||
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
|
||||
exit_tx: watch::Sender<Option<ExitStatus>>,
|
||||
adapter: AcpAdapter,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
let status = status.ok();
|
||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(status);
|
||||
return;
|
||||
}
|
||||
@@ -476,7 +637,7 @@ async fn supervise_child(
|
||||
Some(ChildCommand::Reap(done)) => match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let _ = done.send(true);
|
||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(Some(status));
|
||||
return;
|
||||
}
|
||||
@@ -484,7 +645,7 @@ async fn supervise_child(
|
||||
let _ = done.send(false);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(pid, %error, "could not reap {ADAPTER_BIN}");
|
||||
tracing::warn!(pid, %error, provider = %adapter, "could not reap ACP adapter");
|
||||
let _ = done.send(false);
|
||||
}
|
||||
},
|
||||
@@ -494,7 +655,7 @@ async fn supervise_child(
|
||||
// a reused PID.
|
||||
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
|
||||
let status = child.wait().await.ok();
|
||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(status);
|
||||
return;
|
||||
}
|
||||
@@ -513,10 +674,104 @@ fn client_capabilities() -> ClientCapabilities {
|
||||
.terminal(false)
|
||||
}
|
||||
|
||||
fn advertised_config_options(
|
||||
mut options: Vec<SessionConfigOption>,
|
||||
modes: Option<&SessionModeState>,
|
||||
models: Option<&LegacyModelState>,
|
||||
) -> Vec<SessionConfigOption> {
|
||||
let has_model = options
|
||||
.iter()
|
||||
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model)));
|
||||
let has_mode = options
|
||||
.iter()
|
||||
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Mode)));
|
||||
if let Some(models) = models.filter(|_| !has_model) {
|
||||
let mut choices = models
|
||||
.available_models
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let mut option =
|
||||
SessionConfigSelectOption::new(model.model_id.clone(), &model.name);
|
||||
if model.model_id == "default" {
|
||||
option.name = "Opus (default)".into();
|
||||
}
|
||||
option.description.clone_from(&model.description);
|
||||
option
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !models
|
||||
.available_models
|
||||
.iter()
|
||||
.any(|model| model.model_id == "fable")
|
||||
{
|
||||
choices.push(SessionConfigSelectOption::new("fable", "Fable"));
|
||||
}
|
||||
options.push(
|
||||
SessionConfigOption::select(
|
||||
LEGACY_MODEL_CONFIG_ID,
|
||||
"Model",
|
||||
models.current_model_id.clone(),
|
||||
choices,
|
||||
)
|
||||
.category(SessionConfigOptionCategory::Model),
|
||||
);
|
||||
}
|
||||
if let Some(modes) = modes.filter(|_| !has_mode) {
|
||||
let choices = modes
|
||||
.available_modes
|
||||
.iter()
|
||||
.map(|mode| {
|
||||
let mut option = SessionConfigSelectOption::new(mode.id.0.clone(), &mode.name);
|
||||
option.description.clone_from(&mode.description);
|
||||
option
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
options.push(
|
||||
SessionConfigOption::select(
|
||||
LEGACY_MODE_CONFIG_ID,
|
||||
"Mode",
|
||||
modes.current_mode_id.0.clone(),
|
||||
choices,
|
||||
)
|
||||
.category(SessionConfigOptionCategory::Mode),
|
||||
);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn replace_config_value(
|
||||
options: &Arc<Mutex<Vec<SessionConfigOption>>>,
|
||||
config_id: &str,
|
||||
value: &SessionConfigOptionValue,
|
||||
) -> Vec<SessionConfigOption> {
|
||||
let mut options = options.lock().expect("config options");
|
||||
if let Some(option) = options
|
||||
.iter_mut()
|
||||
.find(|option| option.id.0.as_ref() == config_id)
|
||||
{
|
||||
match (&mut option.kind, value) {
|
||||
(SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => {
|
||||
select.current_value = value.clone();
|
||||
}
|
||||
(SessionConfigKind::Boolean(boolean), SessionConfigOptionValue::Boolean { value }) => {
|
||||
boolean.current_value = *value;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
options.clone()
|
||||
}
|
||||
|
||||
fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> SessionNotification {
|
||||
SessionNotification::new(
|
||||
SessionId::new(session_id),
|
||||
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_connection<T>(
|
||||
transport: T,
|
||||
cwd: PathBuf,
|
||||
load_session: Option<String>,
|
||||
config: ConnectionConfig,
|
||||
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||
@@ -534,6 +789,7 @@ async fn run_connection<T>(
|
||||
// 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 received_updates = updates_tx.clone();
|
||||
let result = Client
|
||||
.builder()
|
||||
.name("maestro")
|
||||
@@ -544,7 +800,7 @@ async fn run_connection<T>(
|
||||
return Ok(());
|
||||
}
|
||||
// A dropped receiver only means nobody wants updates any more.
|
||||
let _ = updates_tx.send(notification);
|
||||
let _ = received_updates.send(notification);
|
||||
Ok(())
|
||||
},
|
||||
agent_client_protocol::on_receive_notification!(),
|
||||
@@ -589,11 +845,13 @@ async fn run_connection<T>(
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let session_id = match load_session {
|
||||
let (session_id, modes, initial_options, models) = match config.load_session {
|
||||
Some(existing) => {
|
||||
if !initialized.agent_capabilities.load_session {
|
||||
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
|
||||
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}"
|
||||
"{} ACP adapter ({}) does not advertise loadSession; cannot resume session {existing}",
|
||||
config.adapter,
|
||||
config.adapter.binary()
|
||||
))));
|
||||
return Ok(());
|
||||
}
|
||||
@@ -601,7 +859,11 @@ async fn run_connection<T>(
|
||||
let (loaded_tx, loaded_rx) = oneshot::channel();
|
||||
let replaying_done = replaying.clone();
|
||||
if let Err(e) = cx
|
||||
.send_request(LoadSessionRequest::new(existing.clone(), cwd))
|
||||
.send_request(ExtendedLoadSessionRequest {
|
||||
session_id: SessionId::new(existing.as_str()),
|
||||
cwd: config.cwd,
|
||||
mcp_servers: vec![],
|
||||
})
|
||||
.on_receiving_result(move |result| {
|
||||
replaying_done.store(false, Ordering::SeqCst);
|
||||
async move {
|
||||
@@ -613,8 +875,8 @@ async fn run_connection<T>(
|
||||
let _ = ready_tx.send(Err(ready_error(e)));
|
||||
return Ok(());
|
||||
}
|
||||
match loaded_rx.await {
|
||||
Ok(Ok(_)) => {}
|
||||
let loaded = match loaded_rx.await {
|
||||
Ok(Ok(loaded)) => loaded,
|
||||
Ok(Err(e)) => {
|
||||
let _ = ready_tx.send(Err(ready_error(e)));
|
||||
return Ok(());
|
||||
@@ -625,21 +887,48 @@ async fn run_connection<T>(
|
||||
)));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
existing
|
||||
};
|
||||
(existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models)
|
||||
}
|
||||
None => match cx
|
||||
.send_request(NewSessionRequest::new(cwd))
|
||||
.send_request(ExtendedNewSessionRequest {
|
||||
cwd: config.cwd,
|
||||
mcp_servers: vec![],
|
||||
})
|
||||
.block_task()
|
||||
.await
|
||||
{
|
||||
Ok(created) => created.session_id.0.to_string(),
|
||||
Ok(created) => (
|
||||
created.session_id.0.to_string(),
|
||||
created.modes,
|
||||
created.config_options.unwrap_or_default(),
|
||||
created.models,
|
||||
),
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(ready_error(e)));
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
let config_options = Arc::new(Mutex::new(advertised_config_options(
|
||||
initial_options,
|
||||
modes.as_ref(),
|
||||
models.as_ref(),
|
||||
)));
|
||||
if let Some(modes) = &modes {
|
||||
let _ = updates_tx.send(SessionNotification::new(
|
||||
SessionId::new(session_id.as_str()),
|
||||
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||
modes.current_mode_id.clone(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
if !config_options.lock().expect("config options").is_empty() {
|
||||
let _ = updates_tx.send(config_notification(
|
||||
&session_id,
|
||||
config_options.lock().expect("config options").clone(),
|
||||
));
|
||||
}
|
||||
let _ = ready_tx.send(Ok(session_id.clone()));
|
||||
|
||||
loop {
|
||||
@@ -689,6 +978,83 @@ async fn run_connection<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::SetConfig { config_id, value, done }) => {
|
||||
let done = Arc::new(Mutex::new(Some(done)));
|
||||
let callback_done = done.clone();
|
||||
let callback_options = config_options.clone();
|
||||
let callback_updates = updates_tx.clone();
|
||||
let callback_session = session_id.clone();
|
||||
let callback_config_id = config_id.clone();
|
||||
let callback_value = value.clone();
|
||||
let sent = if config_id == LEGACY_MODEL_CONFIG_ID {
|
||||
let Some(model_id) = value.as_value_id() else {
|
||||
let _ = done.lock().expect("config sender").take()
|
||||
.map(|done| done.send(Err("model must be a string value".into())));
|
||||
continue;
|
||||
};
|
||||
cx.send_request(LegacySetModelRequest {
|
||||
session_id: SessionId::new(session_id.as_str()),
|
||||
model_id: model_id.0.to_string(),
|
||||
}).on_receiving_result(move |result| async move {
|
||||
if result.is_ok() {
|
||||
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||
let _ = callback_updates.send(config_notification(&callback_session, options));
|
||||
}
|
||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
} else if config_id == LEGACY_MODE_CONFIG_ID {
|
||||
let Some(mode_id) = value.as_value_id() else {
|
||||
let _ = done.lock().expect("config sender").take()
|
||||
.map(|done| done.send(Err("mode must be a string value".into())));
|
||||
continue;
|
||||
};
|
||||
cx.send_request(SetSessionModeRequest::new(
|
||||
SessionId::new(session_id.as_str()),
|
||||
mode_id.0.to_string(),
|
||||
)).on_receiving_result(move |result| async move {
|
||||
if result.is_ok() {
|
||||
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||
let _ = callback_updates.send(config_notification(&callback_session, options));
|
||||
let _ = callback_updates.send(SessionNotification::new(
|
||||
SessionId::new(callback_session.as_str()),
|
||||
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||
callback_value.as_value_id().expect("checked mode value").0.clone(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
cx.send_request(SetSessionConfigOptionRequest::new(
|
||||
SessionId::new(session_id.as_str()),
|
||||
config_id,
|
||||
value,
|
||||
))
|
||||
.on_receiving_result(move |result| async move {
|
||||
if let Ok(response) = &result {
|
||||
let mut current = callback_options.lock().expect("config options");
|
||||
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
|
||||
current.extend(response.config_options.clone());
|
||||
let _ = callback_updates.send(config_notification(&callback_session, current.clone()));
|
||||
}
|
||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
if let Err(error) = sent {
|
||||
if let Some(done) = done.lock().expect("config sender").take() {
|
||||
let _ = done.send(Err(error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::Close) | None => break,
|
||||
},
|
||||
_ = cx.incoming_closed() => break,
|
||||
@@ -706,10 +1072,11 @@ async fn drain_stderr(
|
||||
stderr: tokio::process::ChildStderr,
|
||||
pid: u32,
|
||||
tail: Arc<Mutex<VecDeque<u8>>>,
|
||||
adapter: AcpAdapter,
|
||||
) {
|
||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
|
||||
tracing::debug!(pid, provider = %adapter, "ACP adapter: {line}");
|
||||
let mut tail = tail.lock().expect("stderr tail");
|
||||
tail.extend(line.as_bytes());
|
||||
tail.push_back(b'\n');
|
||||
|
||||
@@ -19,6 +19,8 @@ Environment knobs, all optional:
|
||||
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
|
||||
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
|
||||
`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_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
|
||||
so the SIGTERM path is exercised)
|
||||
@@ -34,6 +36,7 @@ Environment knobs, all optional:
|
||||
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
|
||||
JSON-RPC error
|
||||
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
|
||||
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
|
||||
callback until a later prompt
|
||||
|
||||
@@ -76,6 +79,41 @@ prompt_failures = 0
|
||||
pending_prompt_id = None
|
||||
pending_permission_id = None
|
||||
delayed_permission = False
|
||||
current_model = "opus"
|
||||
current_mode = "default"
|
||||
current_effort = "high"
|
||||
|
||||
|
||||
def settings_response(include_session=False):
|
||||
result = {}
|
||||
if include_session:
|
||||
result["sessionId"] = session_id
|
||||
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
|
||||
result.update({
|
||||
"models": {
|
||||
"currentModelId": current_model,
|
||||
"availableModels": [
|
||||
{"modelId": "opus", "name": "Opus"},
|
||||
{"modelId": "sonnet", "name": "Sonnet"},
|
||||
],
|
||||
},
|
||||
"modes": {
|
||||
"currentModeId": current_mode,
|
||||
"availableModes": [
|
||||
{"id": "default", "name": "Default"},
|
||||
{"id": "plan", "name": "Plan"},
|
||||
],
|
||||
},
|
||||
"configOptions": [{
|
||||
"id": "effort", "name": "Effort", "category": "thought_level",
|
||||
"type": "select", "currentValue": current_effort,
|
||||
"options": [
|
||||
{"value": "low", "name": "Low"},
|
||||
{"value": "high", "name": "High"},
|
||||
],
|
||||
}],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def chunk(text):
|
||||
@@ -133,8 +171,11 @@ for raw in sys.stdin:
|
||||
"authMethods": [],
|
||||
}})
|
||||
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)
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
|
||||
send(chunk("hello from fake"))
|
||||
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
||||
sys.exit(0)
|
||||
@@ -147,8 +188,18 @@ for raw in sys.stdin:
|
||||
continue
|
||||
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
|
||||
send(chunk(f"replayed {i + 1}"))
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
|
||||
send(chunk("hello after load"))
|
||||
elif method == "session/set_model":
|
||||
current_model = params.get("modelId")
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||
elif method == "session/set_mode":
|
||||
current_mode = params.get("modeId")
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||
elif method == "session/set_config_option":
|
||||
current_effort = params.get("value")
|
||||
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||
"configOptions": settings_response()["configOptions"]}})
|
||||
elif method == "session/cancel":
|
||||
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
||||
send(chunk("final on cancel"))
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
|
||||
//! one ignored test against the real `claude-code-acp`.
|
||||
//! ignored smoke tests against each maintained adapter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
||||
use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
||||
use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fake_agent() -> PathBuf {
|
||||
@@ -25,6 +25,7 @@ impl Fake {
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
|
||||
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
|
||||
.await
|
||||
.expect_err("no adapter on PATH");
|
||||
assert!(matches!(err, AcpError::AdapterMissing));
|
||||
assert!(matches!(
|
||||
err,
|
||||
AcpError::AdapterMissing {
|
||||
adapter: AcpAdapter::ClaudeCode
|
||||
}
|
||||
));
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains(ADAPTER_BIN), "{msg}");
|
||||
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_adapter_has_its_own_binary_and_install_guidance() {
|
||||
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
|
||||
assert!(AcpAdapter::Codex
|
||||
.install_command()
|
||||
.contains("@agentclientprotocol/codex-acp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_selects_the_codex_adapter() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("spawn Codex adapter");
|
||||
assert!(fake.log().contains("recv session/new"));
|
||||
session
|
||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_updates_reach_the_taken_receiver() {
|
||||
let fake = Fake::new();
|
||||
@@ -180,11 +207,68 @@ async fn session_updates_reach_the_taken_receiver() {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn advertised_settings_are_normalized_and_mutable() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path());
|
||||
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
let mut updates = session.take_updates().expect("updates");
|
||||
|
||||
let initial = loop {
|
||||
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||
.await
|
||||
.expect("settings update")
|
||||
.expect("channel open");
|
||||
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
|
||||
break options.config_options;
|
||||
}
|
||||
};
|
||||
assert_eq!(initial.len(), 3);
|
||||
assert!(initial
|
||||
.iter()
|
||||
.any(|option| option.id.0.as_ref() == "effort"));
|
||||
assert!(initial
|
||||
.iter()
|
||||
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
|
||||
assert!(initial
|
||||
.iter()
|
||||
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
|
||||
let model = initial
|
||||
.iter()
|
||||
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
|
||||
.expect("legacy model option");
|
||||
let model = serde_json::to_value(model).unwrap();
|
||||
assert_eq!(model["options"][0]["name"], "Opus");
|
||||
assert!(model["options"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|option| option["value"] == "fable"));
|
||||
|
||||
let changed = session
|
||||
.set_config(
|
||||
"__maestro_legacy_model".into(),
|
||||
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
|
||||
)
|
||||
.expect("queue model change");
|
||||
changed
|
||||
.await
|
||||
.expect("response delivered")
|
||||
.expect("model changed");
|
||||
fake.wait_for_log("recv session/set_model").await;
|
||||
|
||||
session
|
||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminate_sends_cancel_then_sigterm() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()))
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("spawn");
|
||||
let pid = session.pid();
|
||||
@@ -225,7 +309,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
|
||||
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path());
|
||||
let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
|
||||
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("init fails");
|
||||
@@ -284,7 +368,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("child died");
|
||||
match &err {
|
||||
AcpError::ExitedEarly { status, stderr } => {
|
||||
AcpError::ExitedEarly { status, stderr, .. } => {
|
||||
assert!(status.contains('3'), "{status}");
|
||||
assert!(stderr.contains("not logged in"), "{stderr}");
|
||||
}
|
||||
@@ -320,7 +404,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
||||
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
|
||||
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
@@ -353,7 +440,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
||||
async fn load_session_requires_the_load_capability() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config)
|
||||
@@ -368,7 +458,10 @@ async fn load_session_requires_the_load_capability() {
|
||||
async fn load_session_error_fails_the_spawn() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("load fails");
|
||||
@@ -392,3 +485,21 @@ async fn real_adapter_handshake() {
|
||||
.await;
|
||||
assert!(session.has_exited());
|
||||
}
|
||||
|
||||
/// End to end against the maintained Codex adapter. It needs a locally
|
||||
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn real_codex_adapter_handshake() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session =
|
||||
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("real codex-acp handshake");
|
||||
assert!(!session.session_id().is_empty());
|
||||
assert!(pid_alive(session.pid()));
|
||||
session
|
||||
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||
.await;
|
||||
assert!(session.has_exited());
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
pub enum AttachError {
|
||||
#[error("ws: {0}")]
|
||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
#[error("daemon returned {status} ({code}): {message}")]
|
||||
Rejected {
|
||||
status: String,
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("termios: {0}")]
|
||||
@@ -32,6 +38,21 @@ pub enum 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
|
||||
/// transport level (connection refused), as opposed to an HTTP-level
|
||||
/// 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.
|
||||
#[allow(clippy::result_large_err)]
|
||||
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 raw = RawTty::enable()?;
|
||||
@@ -233,4 +256,25 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// Type text into a running session, as if it had been typed on an
|
||||
/// attached terminal.
|
||||
/// Send text to a running session. Terminal sessions receive raw
|
||||
/// keystrokes; structured sessions receive an ACP prompt.
|
||||
Send(SendArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
Hooks(HooksArgs),
|
||||
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
pub existing_branch: bool,
|
||||
/// Run a Claude Code or Codex session in a plain terminal instead of the
|
||||
/// structured (ACP) view it otherwise gets by default.
|
||||
#[arg(long, conflicts_with = "structured")]
|
||||
pub terminal: bool,
|
||||
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
|
||||
/// Codex commands have an adapter; the daemon refuses anything else.
|
||||
#[arg(long)]
|
||||
pub structured: bool,
|
||||
/// After spawning, attach immediately.
|
||||
#[arg(long, short = 'a')]
|
||||
pub attach: bool,
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||
Session, SessionStatus, WorktreeSpec,
|
||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -51,16 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
};
|
||||
|
||||
let body = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
view: spawn_view(&args),
|
||||
cwd: args.cwd.clone(),
|
||||
command: args.command.clone(),
|
||||
env,
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve: false,
|
||||
};
|
||||
client.create_session(&body).await
|
||||
}
|
||||
|
||||
/// The view a `maestro spawn` asks for: structured whenever the command
|
||||
/// resolves to an agent with an ACP adapter, since that is the richer session
|
||||
/// and the one both other clients now default to. `--terminal` opts out;
|
||||
/// `--structured` forces the request through even for a command this side
|
||||
/// does not recognise, leaving the refusal to the daemon.
|
||||
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
||||
if args.terminal {
|
||||
return SessionView::Terminal;
|
||||
}
|
||||
if args.structured || supports_structured_view(&args.command) {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
}
|
||||
}
|
||||
|
||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||
/// the work went.
|
||||
@@ -113,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
||||
/// `maestro send`. Answers with the session that was typed into, so the
|
||||
/// caller can name it once the daemon has accepted the text.
|
||||
///
|
||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||
/// the shape a heredoc or a pipe wants.
|
||||
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||
/// there — ACP prompts are always complete messages).
|
||||
///
|
||||
/// Text comes from the arguments, or from stdin when there is none — the
|
||||
/// shape a heredoc or a pipe wants.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_send(
|
||||
client: &DaemonClient,
|
||||
@@ -138,19 +158,28 @@ pub async fn run_send(
|
||||
return Err(CliError::NothingToSend);
|
||||
}
|
||||
|
||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
let sessions = client.list_sessions().await?;
|
||||
resolve_session(&sessions, &args.session)?
|
||||
}
|
||||
};
|
||||
// We always need the session list: even a bare UUID needs the view field
|
||||
// to decide which endpoint to hit.
|
||||
let sessions = client.list_sessions().await?;
|
||||
let id = resolve_session(&sessions, &args.session)?;
|
||||
let view = sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.view)
|
||||
.unwrap_or(SessionView::Terminal);
|
||||
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
match view {
|
||||
SessionView::Structured => {
|
||||
client.send_structured_prompt(id, &text).await?;
|
||||
}
|
||||
SessionView::Terminal => {
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -179,8 +208,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
)
|
||||
.ok();
|
||||
for s in sorted {
|
||||
@@ -190,11 +219,16 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
SessionStatus::Running => "running",
|
||||
SessionStatus::Dead => "dead",
|
||||
};
|
||||
let view = match s.view {
|
||||
SessionView::Terminal => "terminal",
|
||||
SessionView::Structured => "structured",
|
||||
};
|
||||
let activity = match s.activity {
|
||||
ActivityState::Active => "active",
|
||||
ActivityState::Idle => "idle",
|
||||
ActivityState::AwaitingInput => "awaiting",
|
||||
ActivityState::Error => "error",
|
||||
ActivityState::Starting => "starting",
|
||||
};
|
||||
// Show the live foreground command alongside the spawn command when
|
||||
// the daemon has observed one that differs (NAP-340).
|
||||
@@ -207,8 +241,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
let blocked = blocked_label(s);
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, activity, dir, command
|
||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, view, activity, dir, command
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
@@ -230,11 +264,67 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::DaemonInfo;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::cli::{Cli, Command};
|
||||
|
||||
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
||||
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
||||
argv.extend_from_slice(extra);
|
||||
let cli = Cli::try_parse_from(argv).expect("parses");
|
||||
let Command::Spawn(args) = cli.command else {
|
||||
panic!("expected spawn")
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
/// The CLI follows the two interactive clients: a command with an ACP
|
||||
/// adapter spawns structured unless `--terminal` says otherwise, and
|
||||
/// `--structured` still forces the request for a command this side does
|
||||
/// not recognise.
|
||||
#[test]
|
||||
fn spawn_defaults_to_structured_for_supported_agents() {
|
||||
use super::spawn_view;
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["claude"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["npx codex exec"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
||||
SessionView::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_refuses_both_view_flags_at_once() {
|
||||
let err = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"claude",
|
||||
"--structured",
|
||||
"--terminal",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("--terminal"), "{err}");
|
||||
}
|
||||
|
||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||
Session {
|
||||
view: Default::default(),
|
||||
@@ -286,6 +376,22 @@ mod tests {
|
||||
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]
|
||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||
|
||||
use maestro_protocol::{DaemonClient, DaemonInfo};
|
||||
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
||||
use maestro_testing::{session, MockDaemon};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -200,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
||||
assert_eq!(sent, id);
|
||||
}
|
||||
|
||||
/// A structured session routes through the ACP prompt endpoint, not the
|
||||
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
|
||||
/// complete messages).
|
||||
#[tokio::test]
|
||||
async fn send_to_structured_session_uses_acp_prompt() {
|
||||
let mock = MockDaemon::start().await;
|
||||
let id = Uuid::from_u128(13);
|
||||
let mut s = session(id, "/tmp", "claude");
|
||||
s.view = SessionView::Structured;
|
||||
mock.add_session(s);
|
||||
|
||||
let args = maestro_cli::cli::SendArgs {
|
||||
session: id.to_string(),
|
||||
text: vec!["fix".into(), "the".into(), "tests".into()],
|
||||
no_enter: false,
|
||||
};
|
||||
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||
.await
|
||||
.expect("send");
|
||||
|
||||
assert_eq!(sent, id);
|
||||
// Terminal input must stay empty — the message went through ACP.
|
||||
assert!(mock.inputs().is_empty());
|
||||
let prompts = mock.structured_prompts();
|
||||
assert_eq!(prompts.len(), 1);
|
||||
assert_eq!(prompts[0].0, id);
|
||||
assert_eq!(prompts[0].1.prompt, "fix the tests");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||
let mock = MockDaemon::start().await;
|
||||
|
||||
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "opencode".into(),
|
||||
command: "opencode".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "zsh".into(),
|
||||
label: "Shell".into(),
|
||||
command: "zsh".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
||||
id: CUSTOM_PRESET_ID.into(),
|
||||
label: "Custom…".into(),
|
||||
command: String::new(),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +519,7 @@ auto_install = false
|
||||
id: "fish".into(),
|
||||
label: "fish".into(),
|
||||
command: "fish".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -551,6 +556,7 @@ auto_install = false
|
||||
id: " ".into(),
|
||||
label: "x".into(),
|
||||
command: "x".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -561,6 +567,7 @@ auto_install = false
|
||||
id: "x".into(),
|
||||
label: "x".into(),
|
||||
command: "".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -578,11 +585,13 @@ auto_install = false
|
||||
id: "a".into(),
|
||||
label: "a".into(),
|
||||
command: "a".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "a".into(),
|
||||
label: "a2".into(),
|
||||
command: "a2".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
@@ -597,6 +606,7 @@ auto_install = false
|
||||
id: "custom".into(),
|
||||
label: "Custom".into(),
|
||||
command: "whatever".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -80,13 +80,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_repo_dir_is_none() {
|
||||
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
||||
// the walk can't accidentally hit a real repo above the test's cwd.
|
||||
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
||||
// The fixture has no `.git`, so `find_root` must not return a path
|
||||
// inside it. On hosts where an ancestor of the temp dir *is* a repo
|
||||
// (e.g. `/tmp` lives inside a checkout), the function legitimately
|
||||
// returns that ancestor — the test only asserts the fixture itself
|
||||
// was not mistaken for a repo.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sub = tmp.path().join("plain/dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
assert_eq!(find_root(&sub), None);
|
||||
match find_root(&sub) {
|
||||
None => {} // expected on most hosts
|
||||
Some(root) => assert!(
|
||||
!root.starts_with(tmp.path()),
|
||||
"found .git inside the fixture at {root:?}, but none was created"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{any, delete, get, post};
|
||||
@@ -21,12 +22,13 @@ use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||
StructuredApprovalDecision, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
|
||||
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::{QueueAcpPrompt, SessionManager};
|
||||
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
|
||||
use crate::sidecar::EventCursor;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -69,7 +71,12 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
||||
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_ATTACHMENT,
|
||||
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
|
||||
)
|
||||
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_APPROVAL,
|
||||
post(structured_approval),
|
||||
@@ -173,12 +180,13 @@ fn dir_error_status(code: &str) -> StatusCode {
|
||||
fn session_error_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"not_found" => StatusCode::NOT_FOUND,
|
||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||
"kill_guarded" => StatusCode::CONFLICT,
|
||||
// The session outlived its pane: nothing to type into, and nothing
|
||||
// the daemon can do about it.
|
||||
"not_running" => StatusCode::CONFLICT,
|
||||
"not_terminal" => StatusCode::CONFLICT,
|
||||
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
@@ -429,13 +437,55 @@ async fn structured_replay(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AttachmentQuery {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn structured_attachment(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(query): Query<AttachmentQuery>,
|
||||
bytes: Bytes,
|
||||
) -> Response {
|
||||
let name = query.name;
|
||||
if name.is_empty()
|
||||
|| name.len() > 180
|
||||
|| name == "."
|
||||
|| name == ".."
|
||||
|| name
|
||||
.chars()
|
||||
.any(|c| c.is_control() || c == '/' || c == '\\')
|
||||
{
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_filename",
|
||||
"use a filename without directory separators, up to 180 bytes",
|
||||
);
|
||||
}
|
||||
match s.manager.store_acp_attachment(id, &name, &bytes).await {
|
||||
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
|
||||
Ok(None) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"not_structured",
|
||||
"session is not a live structured session",
|
||||
),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_prompt(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<StructuredPromptRequest>,
|
||||
) -> Response {
|
||||
match s.manager.queue_acp_prompt(id, body.prompt).await {
|
||||
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Ok(QueueAcpPrompt::InFlight) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"turn_in_flight",
|
||||
@@ -471,6 +521,37 @@ async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_config(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<StructuredConfigRequest>,
|
||||
) -> Response {
|
||||
match s
|
||||
.manager
|
||||
.set_acp_config(id, body.config_id, body.value)
|
||||
.await
|
||||
{
|
||||
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(SetAcpConfig::InvalidValue) => error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_config_value",
|
||||
"configuration values must be strings or booleans",
|
||||
),
|
||||
Ok(SetAcpConfig::Unavailable) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"not_structured",
|
||||
"session is not a live structured session",
|
||||
),
|
||||
Ok(SetAcpConfig::Failed(message)) => {
|
||||
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
|
||||
}
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn structured_approval(
|
||||
State(s): State<LocalApiState>,
|
||||
Path((id, nonce)): Path<(Uuid, String)>,
|
||||
@@ -534,12 +615,23 @@ async fn attach_ws(
|
||||
State(s): State<LocalApiState>,
|
||||
ws: axum::extract::WebSocketUpgrade,
|
||||
) -> Result<Response, Response> {
|
||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon",
|
||||
));
|
||||
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
||||
Ok(subscription) => subscription,
|
||||
Err(SessionError::NotFound(_)) => {
|
||||
return Err(error_response(
|
||||
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();
|
||||
Ok(ws.on_upgrade(move |socket| async move {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind,
|
||||
@@ -37,9 +38,12 @@ pub struct Projection {
|
||||
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
|
||||
state: ReducedState,
|
||||
active_turn: Option<u64>,
|
||||
active_turn_started_at: Option<DateTime<Utc>>,
|
||||
active_turn_context_tokens: Option<u64>,
|
||||
turn_failed: bool,
|
||||
next_turn: u64,
|
||||
turn_tool_calls: HashSet<String>,
|
||||
anonymous_content_row: Option<(String, String)>,
|
||||
}
|
||||
|
||||
impl Projection {
|
||||
@@ -60,6 +64,7 @@ impl Projection {
|
||||
SessionUpdate::ToolCall(tool) => self.tool_call(tool),
|
||||
SessionUpdate::ToolCallUpdate(update) => self.tool_update(update),
|
||||
SessionUpdate::Plan(plan) => {
|
||||
self.anonymous_content_row = None;
|
||||
let data = to_value(plan);
|
||||
self.state.plan = Some(data.clone());
|
||||
Some(self.replace("plan".into(), TranscriptRowKind::Plan, data))
|
||||
@@ -77,16 +82,20 @@ impl Projection {
|
||||
SessionUpdate::ConfigOptionUpdate(options) => {
|
||||
let value = to_value(options);
|
||||
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
|
||||
self.state.config_options = Some(value);
|
||||
None
|
||||
}
|
||||
// ACP deliberately adds notification variants over time. Known but
|
||||
// non-transcript updates become a generic notice instead of being
|
||||
// dropped or forcing clients to understand ACP's enum.
|
||||
_ => Some(self.append(
|
||||
format!("notice:{seq}"),
|
||||
TranscriptRowKind::Notice,
|
||||
to_value(¬ification.update),
|
||||
)),
|
||||
_ => {
|
||||
self.anonymous_content_row = None;
|
||||
Some(self.append(
|
||||
format!("notice:{seq}"),
|
||||
TranscriptRowKind::Notice,
|
||||
to_value(¬ification.update),
|
||||
))
|
||||
}
|
||||
};
|
||||
ProjectionUpdate {
|
||||
transcript,
|
||||
@@ -122,12 +131,19 @@ impl Projection {
|
||||
|
||||
/// Start a prompt turn and return its monotonically increasing token.
|
||||
pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) {
|
||||
self.anonymous_content_row = None;
|
||||
self.next_turn += 1;
|
||||
let turn = self.next_turn;
|
||||
self.active_turn = Some(turn);
|
||||
let started_at = Utc::now();
|
||||
let started_context_tokens = usage_tokens(self.state.usage.as_ref());
|
||||
self.active_turn_started_at = Some(started_at);
|
||||
self.active_turn_context_tokens = started_context_tokens;
|
||||
self.turn_failed = false;
|
||||
self.state.turn.responding = true;
|
||||
self.state.turn.thinking = true;
|
||||
self.state.turn.started_at = Some(started_at);
|
||||
self.state.turn.started_context_tokens = started_context_tokens;
|
||||
self.turn_tool_calls.clear();
|
||||
self.state.turn.active_tool_calls.clear();
|
||||
(
|
||||
@@ -156,10 +172,44 @@ impl Projection {
|
||||
self.turn_failed = lifecycle == TurnLifecycle::Failure;
|
||||
self.state.turn.responding = false;
|
||||
self.state.turn.thinking = false;
|
||||
let finished_at = Utc::now();
|
||||
let started_at = self.active_turn_started_at.take();
|
||||
let started_context_tokens = self.active_turn_context_tokens.take();
|
||||
self.state.turn.started_at = None;
|
||||
self.state.turn.started_context_tokens = None;
|
||||
self.turn_tool_calls.clear();
|
||||
self.state.turn.active_tool_calls.clear();
|
||||
let context_tokens = usage_tokens(self.state.usage.as_ref());
|
||||
let context_delta = context_tokens
|
||||
.zip(started_context_tokens)
|
||||
.map(|(end, start)| i128::from(end) - i128::from(start));
|
||||
let elapsed_ms = started_at.map_or(0, |started| {
|
||||
finished_at
|
||||
.signed_duration_since(started)
|
||||
.num_milliseconds()
|
||||
.max(0)
|
||||
});
|
||||
let outcome = match lifecycle {
|
||||
TurnLifecycle::Finish => "completed",
|
||||
TurnLifecycle::Cancel => "cancelled",
|
||||
TurnLifecycle::Failure => "failed",
|
||||
TurnLifecycle::Begin => unreachable!(),
|
||||
};
|
||||
let row = self.append(
|
||||
format!("turn_meta:{}", uuid::Uuid::new_v4()),
|
||||
TranscriptRowKind::Notice,
|
||||
serde_json::json!({
|
||||
"maestroTurnMeta": {
|
||||
"elapsedMs": elapsed_ms,
|
||||
"contextTokens": context_tokens,
|
||||
"contextDelta": context_delta,
|
||||
"outcome": outcome,
|
||||
"model": self.state.model,
|
||||
}
|
||||
}),
|
||||
);
|
||||
Some(ProjectionUpdate {
|
||||
transcript: None,
|
||||
transcript: Some(row),
|
||||
state: self.state(),
|
||||
})
|
||||
}
|
||||
@@ -186,7 +236,8 @@ impl Projection {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_approval(&mut self, row: TranscriptRow) -> ProjectionUpdate {
|
||||
pub fn restore_row(&mut self, row: TranscriptRow) -> ProjectionUpdate {
|
||||
self.anonymous_content_row = None;
|
||||
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());
|
||||
@@ -245,12 +296,32 @@ impl Projection {
|
||||
kind: TranscriptRowKind,
|
||||
prefix: &str,
|
||||
) -> Option<TranscriptDelta> {
|
||||
let id = chunk
|
||||
.message_id
|
||||
.as_ref()
|
||||
.map(|id| format!("{prefix}:{id}"))
|
||||
.unwrap_or_else(|| format!("{prefix}:{seq}"));
|
||||
let data = to_value(chunk);
|
||||
let id = if let Some(message_id) = chunk.message_id.as_ref() {
|
||||
self.anonymous_content_row = None;
|
||||
format!("{prefix}:{message_id}")
|
||||
} else {
|
||||
let text = data.pointer("/content/text").and_then(Value::as_str);
|
||||
let previous = self
|
||||
.anonymous_content_row
|
||||
.as_ref()
|
||||
.filter(|(previous_prefix, _)| previous_prefix == prefix)
|
||||
.map(|(_, id)| id.clone());
|
||||
if text.is_some_and(|text| !text.is_empty()) {
|
||||
if let Some(id) = previous {
|
||||
let index = *self
|
||||
.row_index
|
||||
.get(&id)
|
||||
.expect("anonymous content row remains addressable");
|
||||
let row = &mut self.rows[index];
|
||||
append_text(&mut row.data, &data);
|
||||
return Some(TranscriptDelta::Patch { row: row.clone() });
|
||||
}
|
||||
}
|
||||
let id = format!("{prefix}:{seq}");
|
||||
self.anonymous_content_row = text.map(|_| (prefix.to_owned(), id.clone()));
|
||||
id
|
||||
};
|
||||
if let Some(index) = self.row_index.get(&id).copied() {
|
||||
let row = &mut self.rows[index];
|
||||
append_text(&mut row.data, &data);
|
||||
@@ -260,6 +331,7 @@ impl Projection {
|
||||
}
|
||||
|
||||
fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> {
|
||||
self.anonymous_content_row = None;
|
||||
let id = format!("tool:{}", tool.tool_call_id);
|
||||
let tool_id = tool.tool_call_id.to_string();
|
||||
let mut canonical = tool.clone();
|
||||
@@ -290,6 +362,7 @@ impl Projection {
|
||||
&mut self,
|
||||
update: &maestro_acp::schema::ToolCallUpdate,
|
||||
) -> Option<TranscriptDelta> {
|
||||
self.anonymous_content_row = None;
|
||||
let tool_id = update.tool_call_id.to_string();
|
||||
let Some(tool) = self.tools.get_mut(&tool_id) else {
|
||||
self.pending_tool_updates
|
||||
@@ -383,6 +456,10 @@ impl Projection {
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_tokens(usage: Option<&Value>) -> Option<u64> {
|
||||
usage?.get("used")?.as_u64()
|
||||
}
|
||||
|
||||
fn to_value(value: &impl serde::Serialize) -> Value {
|
||||
serde_json::to_value(value).expect("ACP schema values serialize")
|
||||
}
|
||||
@@ -426,7 +503,7 @@ fn model_from_options(value: &Value) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use maestro_acp::schema::SessionNotification;
|
||||
use maestro_protocol::{TranscriptDelta, TranscriptRowKind};
|
||||
use maestro_protocol::{TranscriptDelta, TranscriptRow, TranscriptRowKind};
|
||||
|
||||
use super::{Projection, TurnLifecycle};
|
||||
|
||||
@@ -496,6 +573,137 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_the_codex_adapter_fixture_without_provider_specific_rows() {
|
||||
let fixture = include_str!("../tests/fixtures/codex-acp-session.jsonl");
|
||||
let events = fixture
|
||||
.lines()
|
||||
.map(serde_json::from_str::<SessionNotification>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("Codex ACP fixture follows the supported schema");
|
||||
let mut projection = Projection::default();
|
||||
|
||||
for (seq, event) in events.iter().enumerate() {
|
||||
projection.apply((seq + 1) as u64, event);
|
||||
}
|
||||
|
||||
let rows = projection.snapshot(events.len() as u64).rows;
|
||||
assert_eq!(
|
||||
rows.iter().map(|row| row.kind).collect::<Vec<_>>(),
|
||||
[
|
||||
TranscriptRowKind::User,
|
||||
TranscriptRowKind::AgentThought,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::ToolDiff,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::Plan,
|
||||
TranscriptRowKind::Notice,
|
||||
TranscriptRowKind::Notice,
|
||||
TranscriptRowKind::AgentText,
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[2].data["rawInput"]["command"], "rg TODO");
|
||||
assert_eq!(rows[2].data["rawOutput"]["exit_code"], 0);
|
||||
assert_eq!(rows[4].data["_meta"]["is_mcp_tool_call"], true);
|
||||
assert_eq!(rows[5].data["rawInput"]["query"], "Agent Client Protocol");
|
||||
assert_eq!(
|
||||
rows[7].data["content"][0]["content"]["mimeType"],
|
||||
"image/png"
|
||||
);
|
||||
|
||||
let state = projection.state();
|
||||
assert_eq!(state.model.as_deref(), Some("gpt-5.3-codex"));
|
||||
assert_eq!(state.mode.as_deref(), Some("agent"));
|
||||
assert_eq!(state.usage.as_ref().unwrap()["used"], 1200);
|
||||
assert_eq!(state.context.as_ref().unwrap()["size"], 258400);
|
||||
assert_eq!(
|
||||
state.config_options.as_ref().unwrap()["configOptions"][2]["currentValue"],
|
||||
"high"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesces_anonymous_stream_chunks_without_joining_turns() {
|
||||
let mut projection = Projection::default();
|
||||
let (first_turn, _) = projection.begin_turn();
|
||||
for (seq, text) in [(1, ""), (2, "p"), (3, "ong")] {
|
||||
projection.apply(
|
||||
seq,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {"type": "text", "text": text},
|
||||
})),
|
||||
);
|
||||
}
|
||||
projection.turn_lifecycle(TurnLifecycle::Finish, first_turn);
|
||||
projection.begin_turn();
|
||||
projection.apply(
|
||||
4,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {"type": "text", "text": "next"},
|
||||
})),
|
||||
);
|
||||
|
||||
let rows = projection.snapshot(4).rows;
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "pong");
|
||||
assert!(rows[1].data.get("maestroTurnMeta").is_some());
|
||||
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_empty_chunk_delimits_turns_during_replay() {
|
||||
let mut projection = Projection::default();
|
||||
for (seq, text) in [(1, ""), (2, "visible"), (3, ""), (4, "p"), (5, "ong")] {
|
||||
projection.apply(
|
||||
seq,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {"type": "text", "text": text},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let rows = projection.snapshot(5).rows;
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "visible");
|
||||
assert_eq!(rows[1].data.pointer("/content/text").unwrap(), "pong");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_user_prompt_delimits_anonymous_agent_rows() {
|
||||
let mut projection = Projection::default();
|
||||
projection.apply(
|
||||
1,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {"type": "text", "text": "before"},
|
||||
})),
|
||||
);
|
||||
projection.restore_row(TranscriptRow {
|
||||
id: "user:prompt".into(),
|
||||
kind: TranscriptRowKind::User,
|
||||
data: serde_json::json!({"content": {"type": "text", "text": "next"}}),
|
||||
});
|
||||
projection.apply(
|
||||
2,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {"type": "text", "text": "after"},
|
||||
})),
|
||||
);
|
||||
|
||||
let rows = projection.snapshot(2).rows;
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "before");
|
||||
assert_eq!(rows[1].kind, TranscriptRowKind::User);
|
||||
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_update_patches_the_original_stable_row() {
|
||||
let mut projection = Projection::default();
|
||||
@@ -614,6 +822,10 @@ mod tests {
|
||||
assert_eq!(state.context.as_ref().unwrap()["size"], 200);
|
||||
assert_eq!(state.mode.as_deref(), Some("plan"));
|
||||
assert_eq!(state.model.as_deref(), Some("opus"));
|
||||
assert_eq!(
|
||||
state.config_options.as_ref().unwrap()["configOptions"][0]["currentValue"],
|
||||
"opus"
|
||||
);
|
||||
assert_eq!(
|
||||
state.plan.as_ref().unwrap()["entries"],
|
||||
serde_json::json!([])
|
||||
@@ -685,6 +897,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_keeps_duration_and_context_delta_in_transcript() {
|
||||
let mut projection = Projection::default();
|
||||
projection.apply(
|
||||
1,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate":"usage_update", "used":10_000, "size":200_000
|
||||
})),
|
||||
);
|
||||
let (turn, begun) = projection.begin_turn();
|
||||
assert!(begun.state.turn.started_at.is_some());
|
||||
assert_eq!(begun.state.turn.started_context_tokens, Some(10_000));
|
||||
projection.apply(
|
||||
2,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate":"usage_update", "used":12_500, "size":200_000
|
||||
})),
|
||||
);
|
||||
|
||||
let finished = projection
|
||||
.turn_lifecycle(TurnLifecycle::Finish, turn)
|
||||
.expect("active turn finishes");
|
||||
let TranscriptDelta::Append { row } = finished.transcript.unwrap() else {
|
||||
panic!("turn footer");
|
||||
};
|
||||
assert_eq!(row.kind, TranscriptRowKind::Notice);
|
||||
assert_eq!(row.data["maestroTurnMeta"]["contextDelta"], 2_500);
|
||||
assert_eq!(row.data["maestroTurnMeta"]["contextTokens"], 12_500);
|
||||
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
|
||||
assert!(finished.state.turn.started_at.is_none());
|
||||
}
|
||||
|
||||
fn terminal_turn_drops_unfinished_tools(
|
||||
terminal: TurnLifecycle,
|
||||
unfinished_id: &str,
|
||||
@@ -703,7 +947,9 @@ mod tests {
|
||||
|
||||
projection.turn_lifecycle(terminal, turn);
|
||||
assert!(projection.state().turn.active_tool_calls.is_empty());
|
||||
assert_eq!(projection.snapshot(1).rows.len(), 1);
|
||||
let rows = projection.snapshot(1).rows;
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows[1].data.get("maestroTurnMeta").is_some());
|
||||
|
||||
projection.begin_turn();
|
||||
projection.apply(
|
||||
@@ -829,7 +1075,7 @@ mod tests {
|
||||
panic!("approval row")
|
||||
};
|
||||
let mut recovered = Projection::default();
|
||||
let update = recovered.restore_approval(row.clone());
|
||||
let update = recovered.restore_row(row.clone());
|
||||
assert_eq!(
|
||||
update.transcript,
|
||||
Some(TranscriptDelta::Append { row: row.clone() })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,9 +263,15 @@ pub struct LoggedEvent {
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
|
||||
pub enum PersistedEvent {
|
||||
User {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
Approval {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
Turn {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
#[serde(skip)]
|
||||
Notification(Box<SessionNotification>),
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
||||
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND};
|
||||
use maestro_acp::{AcpAdapter, ADAPTER_BIN};
|
||||
use maestro_daemon::projection::TurnLifecycle;
|
||||
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
@@ -38,6 +38,7 @@ impl Fake {
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
@@ -96,13 +97,22 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||
}
|
||||
|
||||
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
|
||||
structured_command(cwd, env, "claude")
|
||||
}
|
||||
|
||||
fn structured_command(
|
||||
cwd: &Path,
|
||||
env: HashMap<String, String>,
|
||||
command: &str,
|
||||
) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: SessionView::Structured,
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: "claude".into(),
|
||||
command: command.into(),
|
||||
env,
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +130,25 @@ async fn wait_for_exit(pid: u32) {
|
||||
assert!(!pid_alive(pid), "pid {pid} still alive");
|
||||
}
|
||||
|
||||
/// Wait for a session's background ACP handshake to complete (activity
|
||||
/// transitions out of `Starting`). Returns the refreshed session.
|
||||
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let session = manager
|
||||
.snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.expect("session exists");
|
||||
if session.activity != ActivityState::Starting {
|
||||
return session;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "handshake timed out");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||
let fx = DaemonFixture::new();
|
||||
@@ -128,14 +157,22 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
||||
|
||||
let session = fx
|
||||
let created = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.create(structured_command(
|
||||
cwd.path(),
|
||||
env,
|
||||
"codex --dangerously-bypass-approvals-and-sandbox",
|
||||
))
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
assert_eq!(session.view, SessionView::Structured);
|
||||
assert_eq!(session.status, SessionStatus::Running);
|
||||
assert_eq!(created.view, SessionView::Structured);
|
||||
assert_eq!(created.status, SessionStatus::Running);
|
||||
assert_eq!(created.activity, ActivityState::Starting);
|
||||
assert!(created.acp_session_id.is_none());
|
||||
|
||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||
assert_eq!(session.activity, ActivityState::Idle);
|
||||
let acp_id = session.acp_session_id.clone().expect("acp session id");
|
||||
assert!(acp_id.starts_with("fake-"), "{acp_id}");
|
||||
@@ -150,7 +187,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||
"{log}"
|
||||
);
|
||||
assert!(
|
||||
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
|
||||
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
|
||||
"{log}"
|
||||
);
|
||||
|
||||
@@ -215,9 +252,10 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
||||
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
@@ -281,11 +319,12 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
|
||||
let fx = DaemonFixture::new();
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = fx
|
||||
let created = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
@@ -349,12 +388,76 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
|
||||
.await
|
||||
.expect("finish lifecycle broadcast")
|
||||
.expect("projection broadcast");
|
||||
assert_eq!(finish.transcript, None);
|
||||
let Some(TranscriptDelta::Append { row }) = finish.transcript else {
|
||||
panic!("finish should append durable turn metadata");
|
||||
};
|
||||
assert_eq!(row.kind, TranscriptRowKind::Notice);
|
||||
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
|
||||
assert_eq!(finish.state.turn, Default::default());
|
||||
|
||||
let recovered = fx
|
||||
.manager
|
||||
.read_acp_projection_replay(session.id, EventCursor::Since(0), 10)
|
||||
.await
|
||||
.expect("turn replay")
|
||||
.expect("structured session");
|
||||
assert!(recovered.iter().any(|event| {
|
||||
matches!(
|
||||
&event.transcript,
|
||||
Some(TranscriptDelta::Append { row })
|
||||
if row.data["maestroTurnMeta"]["outcome"] == "completed"
|
||||
)
|
||||
}));
|
||||
|
||||
fx.manager.kill(session.id, false).await.expect("kill");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_prompt_is_a_durable_user_transcript_row() {
|
||||
let fx = DaemonFixture::new();
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
let session = wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
.queue_acp_prompt(session.id, "show my prompt".into())
|
||||
.await
|
||||
.expect("queue prompt"),
|
||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||
));
|
||||
|
||||
let (snapshot, _, _) = fx
|
||||
.manager
|
||||
.subscribe_acp_projection(session.id, EventCursor::Since(0))
|
||||
.await
|
||||
.expect("projection subscription")
|
||||
.expect("structured session");
|
||||
let user = snapshot
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.kind == TranscriptRowKind::User)
|
||||
.expect("user prompt row");
|
||||
assert_eq!(user.data["content"]["text"], "show my prompt");
|
||||
|
||||
let persisted = fx
|
||||
.manager
|
||||
.read_acp_events(session.id, EventCursor::Since(0), 100)
|
||||
.expect("event replay");
|
||||
assert!(persisted.iter().any(|event| matches!(
|
||||
&event.event,
|
||||
maestro_daemon::sidecar::PersistedEvent::User { row }
|
||||
if row.data["content"]["text"] == "show my prompt"
|
||||
)));
|
||||
|
||||
fx.manager.kill(session.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_turn_completion_cannot_end_the_next_turn() {
|
||||
let fx = DaemonFixture::new();
|
||||
@@ -365,6 +468,7 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
|
||||
fx.manager.begin_acp_turn(session.id).await.unwrap()
|
||||
@@ -408,6 +512,7 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
@@ -446,6 +551,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
@@ -505,11 +611,12 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
|
||||
|
||||
let session = fx
|
||||
let created = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.expect("create");
|
||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||
let acp_id = session.acp_session_id.clone().expect("acp id");
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
@@ -621,7 +728,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Structured,
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "claude".into(),
|
||||
command: "codex".into(),
|
||||
env: fake.env(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
@@ -629,10 +736,12 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
assert_eq!(session.cwd, worktree);
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager.shutdown().await;
|
||||
@@ -681,6 +790,7 @@ async fn failed_recovery_cleans_its_fifo() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
let old_pid = fake.pid();
|
||||
fx.manager.shutdown().await;
|
||||
wait_for_exit(old_pid).await;
|
||||
@@ -714,28 +824,56 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
||||
empty.path().to_string_lossy().into_owned(),
|
||||
)]);
|
||||
|
||||
let session = fx
|
||||
let created = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.expect("a failed session, not an error");
|
||||
.expect("create registers a Starting session");
|
||||
assert_eq!(created.activity, ActivityState::Starting);
|
||||
|
||||
// The background handshake fails because the adapter binary is missing.
|
||||
let session = wait_for_ready(&fx.manager, created.id).await;
|
||||
assert_eq!(session.status, SessionStatus::Dead);
|
||||
assert_eq!(session.activity, ActivityState::Error);
|
||||
let detail = session.activity_detail.clone().expect("actionable detail");
|
||||
assert!(detail.contains(ADAPTER_BIN), "{detail}");
|
||||
assert!(detail.contains(INSTALL_COMMAND), "{detail}");
|
||||
assert!(detail.contains(AcpAdapter::Codex.binary()), "{detail}");
|
||||
assert!(
|
||||
detail.contains(AcpAdapter::Codex.install_command()),
|
||||
"{detail}"
|
||||
);
|
||||
assert_eq!(session.acp_session_id, None);
|
||||
|
||||
// It is a real session: listed, on disk, and dismissable like any other
|
||||
// dead one.
|
||||
assert!(fx.manager.has(session.id).await);
|
||||
assert!(fx.manager.has(created.id).await);
|
||||
assert!(fx
|
||||
.sidecar_dir()
|
||||
.join(format!("{}.json", session.id))
|
||||
.join(format!("{}.json", created.id))
|
||||
.exists());
|
||||
fx.manager.kill(session.id, false).await.expect("dismiss");
|
||||
assert!(!fx.manager.has(session.id).await);
|
||||
fx.manager.kill(created.id, false).await.expect("dismiss");
|
||||
assert!(!fx.manager.has(created.id).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_structured_command_fails_without_launching_claude() {
|
||||
let fx = DaemonFixture::new();
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let err = fx
|
||||
.manager
|
||||
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
||||
.await
|
||||
.expect_err("unsupported command is rejected at request validation");
|
||||
|
||||
assert!(
|
||||
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
|
||||
"{err:?}"
|
||||
);
|
||||
assert!(
|
||||
fake.log().is_empty(),
|
||||
"unsupported command launched an adapter"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -750,6 +888,7 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
let pid = fake.pid();
|
||||
assert!(pid_alive(pid));
|
||||
|
||||
@@ -792,6 +931,8 @@ async fn shutdown_reaps_every_adapter_child() {
|
||||
.create(structured(cwd.path(), b.env()))
|
||||
.await
|
||||
.expect("create b");
|
||||
wait_for_ready(&fx.manager, sa.id).await;
|
||||
wait_for_ready(&fx.manager, sb.id).await;
|
||||
let (pa, pb) = (a.pid(), b.pid());
|
||||
assert!(pid_alive(pa) && pid_alive(pb));
|
||||
|
||||
@@ -826,10 +967,13 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.expect("create");
|
||||
fx.manager
|
||||
let blocker_session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), blocker.env()))
|
||||
.await
|
||||
.expect("create blocker");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
wait_for_ready(&fx.manager, blocker_session.id).await;
|
||||
|
||||
let manager = fx.manager.clone();
|
||||
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
||||
@@ -881,6 +1025,7 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
wait_for_exit(fake.pid()).await;
|
||||
|
||||
fx.manager.shutdown().await;
|
||||
@@ -914,23 +1059,31 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
|
||||
let mut env = fake.env();
|
||||
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
|
||||
|
||||
let create_cwd = cwd.path().to_path_buf();
|
||||
let manager = fx.manager.clone();
|
||||
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
|
||||
// create() returns immediately; the handshake runs in the background
|
||||
// and holds a lifecycle permit.
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Wait until the adapter has received session/new (handshake in progress).
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fake.log().contains("recv session/new")
|
||||
})
|
||||
.await;
|
||||
|
||||
// Shutdown must wait for the in-flight background handshake.
|
||||
let manager = fx.manager.clone();
|
||||
let shutdown = tokio::spawn(async move { manager.shutdown().await });
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(
|
||||
!shutdown.is_finished(),
|
||||
"shutdown returned before create finished"
|
||||
"shutdown returned before handshake finished"
|
||||
);
|
||||
|
||||
let session = create.await.expect("create task").expect("create");
|
||||
// The handshake completes in the background (500ms delay).
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
let pid = fake.pid();
|
||||
shutdown.await.expect("shutdown task");
|
||||
|
||||
@@ -950,6 +1103,194 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
|
||||
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(),
|
||||
);
|
||||
|
||||
// Both create() calls return immediately with Starting; the first
|
||||
// adapter's handshake is gated while the second runs freely.
|
||||
let first = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), blocked_env))
|
||||
.await
|
||||
.expect("first create");
|
||||
let second = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), ready.env()))
|
||||
.await
|
||||
.expect("second create");
|
||||
|
||||
// The second handshake completes independently of the first.
|
||||
let second_ready = wait_for_ready(&fx.manager, second.id).await;
|
||||
assert_eq!(second_ready.activity, ActivityState::Idle);
|
||||
|
||||
// The first handshake is still blocked.
|
||||
let first_snap = fx
|
||||
.manager
|
||||
.snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.id == first.id)
|
||||
.unwrap();
|
||||
assert_eq!(first_snap.activity, ActivityState::Starting);
|
||||
|
||||
std::fs::write(&gate, b"release").unwrap();
|
||||
let first_ready = wait_for_ready(&fx.manager, first.id).await;
|
||||
assert_eq!(first_ready.activity, ActivityState::Idle);
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
// Both create() calls return immediately; the background handshake
|
||||
// tasks hold lifecycle permits and are blocked on their gates.
|
||||
let session_a = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env_a))
|
||||
.await
|
||||
.expect("create a");
|
||||
let session_b = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env_b))
|
||||
.await
|
||||
.expect("create b");
|
||||
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();
|
||||
wait_for_ready(&fx.manager, session_a.id).await;
|
||||
assert!(
|
||||
!shutdown.is_finished(),
|
||||
"shutdown ignored the second create"
|
||||
);
|
||||
|
||||
std::fs::write(&gate_b, b"release").unwrap();
|
||||
wait_for_ready(&fx.manager, session_b.id).await;
|
||||
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 killing_a_starting_session_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,
|
||||
}),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
// create() returns immediately with Starting; the handshake is gated.
|
||||
let session = fx.manager.create(request).await.expect("create");
|
||||
assert_eq!(session.activity, ActivityState::Starting);
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fake.log().contains("recv session/new")
|
||||
})
|
||||
.await;
|
||||
let pid = fake.pid();
|
||||
|
||||
// Kill the session while its handshake is still in progress.
|
||||
// force=true because the worktree has an unpushed branch commit.
|
||||
fx.manager.kill(session.id, true).await.expect("kill");
|
||||
|
||||
// Release the gate so the background task can finish and settle.
|
||||
std::fs::write(&gate, b"release").unwrap();
|
||||
|
||||
// Shutdown must not hang — the lifecycle permit is settled.
|
||||
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
|
||||
.await
|
||||
.expect("shutdown hung on a settled permit");
|
||||
|
||||
wait_for_exit(pid).await;
|
||||
assert!(fx.manager.snapshot().await.is_empty());
|
||||
let id = session.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)),
|
||||
"killed Starting session left a sidecar artifact"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_drains_final_notifications_to_the_log() {
|
||||
let fx = DaemonFixture::new();
|
||||
@@ -963,6 +1304,7 @@ async fn shutdown_drains_final_notifications_to_the_log() {
|
||||
.create(structured(cwd.path(), env))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||
@@ -993,6 +1335,7 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
|
||||
.create(structured(cwd.path(), fake.env()))
|
||||
.await
|
||||
.expect("create");
|
||||
wait_for_ready(&fx.manager, session.id).await;
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
|
||||
// The agent dies out from under the daemon: the liveness pass, not tmux,
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -29,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
|
||||
@@ -154,7 +154,8 @@ async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
||||
}
|
||||
|
||||
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
||||
h.manager
|
||||
let id = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Structured,
|
||||
cwd: "/tmp".into(),
|
||||
@@ -162,10 +163,13 @@ async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>)
|
||||
env,
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.id
|
||||
.id;
|
||||
wait_for_session_activity(h, id, ActivityState::Idle).await;
|
||||
id
|
||||
}
|
||||
|
||||
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
||||
@@ -234,6 +238,60 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_attachments_preserve_bytes_and_reject_paths() {
|
||||
let h = spawn("attachments").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
let client = reqwest::Client::new();
|
||||
let endpoint = url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/attachments"),
|
||||
);
|
||||
let bytes = vec![0, 255, 10, 13];
|
||||
let response = client
|
||||
.post(&endpoint)
|
||||
.query(&[("name", "sample.bin")])
|
||||
.body(bytes.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
let body: serde_json::Value = response.json().await.unwrap();
|
||||
let path = body["path"].as_str().unwrap();
|
||||
assert!(Path::new(path).is_absolute());
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
for name in ["../escape", "a/b", "a\\b", "..", ""] {
|
||||
let response = client
|
||||
.post(&endpoint)
|
||||
.query(&[("name", name)])
|
||||
.body("text")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 400, "{name}");
|
||||
}
|
||||
let response = client
|
||||
.post(&endpoint)
|
||||
.query(&[("name", "large.bin")])
|
||||
.body(vec![0; 20 * 1024 * 1024 + 1])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 413);
|
||||
let response = client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
|
||||
))
|
||||
.query(&[("name", "file.txt")])
|
||||
.body("text")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -493,6 +551,7 @@ async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -626,6 +685,7 @@ async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -768,6 +828,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
};
|
||||
let created: Session = reqwest::Client::new()
|
||||
.post(url(h.addr, "/api/sessions"))
|
||||
@@ -942,7 +1003,9 @@ async fn names_honors_count_and_caps_it() {
|
||||
#[tokio::test]
|
||||
async fn gitroot_probes_path() {
|
||||
// #26: resolves the query against the daemon home dir and walks up for a
|
||||
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
||||
// `.git`. The tempdir itself has no `.git`, so the result must either be
|
||||
// null (no repo anywhere above) or — in sandboxed environments where /tmp
|
||||
// itself sits inside a repo — a path strictly above the tempdir.
|
||||
let h = spawn("naps-laptop").await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let resp: serde_json::Value = reqwest::get(url(
|
||||
@@ -957,7 +1020,16 @@ async fn gitroot_probes_path() {
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
||||
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
|
||||
// inside a checkout), the root legitimately points there. The test only
|
||||
// asserts no root was found *inside* the temp dir.
|
||||
match resp.get("root").and_then(|v| v.as_str()) {
|
||||
None => {}
|
||||
Some(root) => assert!(
|
||||
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
|
||||
"found a repo root inside the fixture at {root:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -985,6 +1057,7 @@ async fn patch_session_renames_it() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1028,6 +1101,7 @@ async fn post_focus_stamps_last_focused_at() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1062,6 +1136,7 @@ async fn delete_owned_session_kills_it() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1084,6 +1159,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -1198,6 +1274,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1251,6 +1328,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1281,6 +1359,7 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1311,6 +1390,39 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
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]
|
||||
async fn attach_ws_for_unknown_session_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -1350,6 +1462,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1395,6 +1508,12 @@ async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
||||
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
||||
panic!("expected structured SessionAdded")
|
||||
};
|
||||
assert_eq!(session.activity, ActivityState::Starting);
|
||||
// The background handshake completes and sends SessionUpdated → Idle.
|
||||
let maestro_protocol::CatalogEvent::SessionUpdated { session } = next_event(&mut ws).await
|
||||
else {
|
||||
panic!("expected SessionUpdated after handshake")
|
||||
};
|
||||
assert_eq!(session.activity, ActivityState::Idle);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
@@ -1462,6 +1581,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -70,6 +70,7 @@ impl Fx {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn create_then_kill_round_trip() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -70,6 +71,7 @@ async fn create_appends_to_spawn_history() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -100,6 +102,7 @@ async fn create_without_history_logs_nothing() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -123,6 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -166,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -215,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -273,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -324,6 +331,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -387,6 +395,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -438,6 +447,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -508,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
branch: "feat/indicator".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -601,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
branch: "feat/keep".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -639,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -703,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
branch: "feat/pushed".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -753,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
||||
branch: "feat/forced".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -799,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
branch: "feat/clear".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -840,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
branch: "feat/rusty".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -882,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
||||
branch: "feat/exited".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -944,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
branch: "feat/clean".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1001,6 +1020,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1040,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
branch: "feat/one".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create first");
|
||||
@@ -1058,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
branch: "feat/two".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create second");
|
||||
@@ -1093,6 +1115,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1132,6 +1155,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1158,6 +1182,7 @@ async fn set_focused_true_stamps_last_focused_at() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1207,6 +1232,7 @@ async fn attaching_stamps_last_focused_at() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -405,15 +405,17 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 49ab7e9: a new-branch add onto a name that already exists suffixes the
|
||||
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md.
|
||||
/// `git worktree add -b` alone fails outright when the branch already exists,
|
||||
/// even with the target directory free — a random handle commonly collides
|
||||
/// with one an earlier, already-removed worktree left behind. `add_new`
|
||||
/// sidesteps it on the branch name itself instead of erroring (#77).
|
||||
#[test]
|
||||
fn add_new_suffixes_when_branch_already_exists() {
|
||||
fn add_new_suffixes_branch_name_on_branch_collision() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// pre-create the branch
|
||||
// pre-create the branch, unused by any worktree.
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
@@ -425,9 +427,17 @@ fn add_new_suffixes_when_branch_already_exists() {
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
let path = mgr.add_new(&repo, "feat/dup").unwrap();
|
||||
let path = mgr
|
||||
.add_new(&repo, "feat/dup")
|
||||
.expect("sidesteps the branch collision");
|
||||
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
|
||||
assert!(path.is_dir());
|
||||
assert!(path.exists());
|
||||
|
||||
// The pre-existing branch is untouched; the new worktree checked out the
|
||||
// suffixed one instead of erroring or adopting `feat/dup`.
|
||||
let branches = worktree_branches(&repo);
|
||||
assert!(branches.contains(&"feat/dup-2".to_string()), "{branches:?}");
|
||||
assert!(!branches.contains(&"feat/dup".to_string()), "{branches:?}");
|
||||
}
|
||||
|
||||
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
|
||||
|
||||
@@ -33,6 +33,7 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +112,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +208,7 @@ async fn kill_removes_clean_worktree() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
@@ -260,6 +263,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
@@ -319,6 +323,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client
|
||||
@@ -383,13 +388,11 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a second spawn onto an *existing* branch that already has a worktree
|
||||
/// is rejected with a structured worktree error; the first worktree stays
|
||||
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9
|
||||
/// suffixes the new branch name instead, see
|
||||
/// `colliding_branch_name_gets_suffixed_too`.)
|
||||
/// #66/#77: a second spawn onto the same branch used to be rejected outright;
|
||||
/// since the branch-collision suffixing landed, it succeeds instead — the
|
||||
/// second worktree lands on `<branch>-2` and the first stays untouched.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn second_spawn_on_same_branch_is_rejected() {
|
||||
async fn second_spawn_on_same_branch_gets_suffixed() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
@@ -399,28 +402,33 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await
|
||||
.expect("first spawn");
|
||||
let dup = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", false))
|
||||
.await;
|
||||
match dup {
|
||||
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
|
||||
assert_eq!(
|
||||
info.code, "worktree_failed",
|
||||
"expected worktree error: {info:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected rejection for duplicate branch, got {other:?}"),
|
||||
}
|
||||
let second = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await
|
||||
.expect("second spawn suffixes instead of rejecting");
|
||||
|
||||
// Exactly one registration, one directory, still usable.
|
||||
// Count branch records only: the worktree path now contains the branch
|
||||
// name too (`worktrees/feature/dup`), so a substring match over every line
|
||||
// would count the same tree twice.
|
||||
let holders = git_worktree_list(base.path())
|
||||
.iter()
|
||||
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
|
||||
.count();
|
||||
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
||||
let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
|
||||
assert_eq!(
|
||||
second.cwd, suffixed,
|
||||
"second worktree must land on the suffixed path"
|
||||
);
|
||||
assert!(suffixed.is_dir());
|
||||
|
||||
// Both registrations exist: the original branch, untouched, and the
|
||||
// suffixed one the second spawn created.
|
||||
let registrations = git_worktree_list(base.path());
|
||||
assert!(
|
||||
registrations
|
||||
.iter()
|
||||
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
|
||||
"original branch must stay registered: {registrations:?}"
|
||||
);
|
||||
assert!(
|
||||
registrations
|
||||
.iter()
|
||||
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
|
||||
"suffixed branch must be registered too: {registrations:?}"
|
||||
);
|
||||
assert!(
|
||||
base.path()
|
||||
.join("worktrees")
|
||||
@@ -431,7 +439,8 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(first.id).await.expect("kill");
|
||||
client.kill_session(first.id).await.expect("kill first");
|
||||
client.kill_session(second.id).await.expect("kill second");
|
||||
}
|
||||
|
||||
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||
@@ -823,6 +832,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("spawn dirty");
|
||||
@@ -872,6 +882,7 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,6 +1075,7 @@ async fn sigterm_stops_structured_child_before_events_ws_drains() {
|
||||
env: fake.env.clone(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("spawn structured session");
|
||||
|
||||
@@ -166,45 +166,32 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
||||
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 —
|
||||
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
||||
/// 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 {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
let mut i = 0;
|
||||
while i < words.len() {
|
||||
let word = words[i];
|
||||
if is_env_assignment(word) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
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(),
|
||||
match resolve(command) {
|
||||
Some(r) => match r.words.get(r.program_index) {
|
||||
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(),
|
||||
},
|
||||
None => legacy_first_word(command),
|
||||
}
|
||||
}
|
||||
|
||||
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
||||
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
||||
/// 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] = [
|
||||
("claude", "claude"),
|
||||
("claude-code", "claude"),
|
||||
@@ -226,29 +213,500 @@ const BY_PROGRAM: [(&str, &str); 17] = [
|
||||
];
|
||||
|
||||
/// 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.
|
||||
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
|
||||
/// the same tools the web's `splitCommand().tool` does.
|
||||
/// `opencode`, ...), or `None` when the program is not one we have a mark for
|
||||
/// — including a command line [`resolve`] would only guess at. The command
|
||||
/// 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> {
|
||||
let program = command_program(command).to_lowercase();
|
||||
let r = resolve(command)?;
|
||||
let program = basename(r.words.get(r.program_index)?).to_lowercase();
|
||||
BY_PROGRAM
|
||||
.iter()
|
||||
.find(|(name, _)| *name == program)
|
||||
.map(|(_, tool)| *tool)
|
||||
}
|
||||
|
||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||
fn is_env_assignment(word: &str) -> bool {
|
||||
let mut chars = word.chars();
|
||||
/// Whether a command line resolves to an agent Maestro can run as a
|
||||
/// structured (ACP) session — Claude Code or Codex, the two adapters
|
||||
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
|
||||
/// control on this, and the daemon rejects a `Structured` request for
|
||||
/// anything else, so the answer has to come from one place.
|
||||
pub fn supports_structured_view(command: &str) -> bool {
|
||||
matches!(command_tool(command), Some("claude" | "codex"))
|
||||
}
|
||||
|
||||
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
||||
/// of the word that is the actual program (past every assignment/wrapper it
|
||||
/// resolved through).
|
||||
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() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
let rest: String = chars.collect();
|
||||
match rest.split_once('=') {
|
||||
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
None => false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
fn basename(word: &str) -> &str {
|
||||
@@ -497,38 +955,132 @@ mod tests {
|
||||
"claude"
|
||||
);
|
||||
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]
|
||||
fn resolves_the_shared_command_resolution_fixture() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Case {
|
||||
command: String,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn word(text: &str) -> Word {
|
||||
Word {
|
||||
text: text.into(),
|
||||
plain_eq: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
|
||||
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
|
||||
#[test]
|
||||
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
|
||||
assert_eq!(
|
||||
tokenize("claude --resume"),
|
||||
Some(vec![word("claude"), word("--resume")])
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Real shell grammar this resolver doesn't attempt — pipelines, list
|
||||
/// operators, subshells/substitution, an unescaped newline, an
|
||||
/// unterminated quote — is rejected outright rather than guessed
|
||||
/// through (#199).
|
||||
#[test]
|
||||
fn tokenize_rejects_ambiguous_shell_grammar() {
|
||||
for command in [
|
||||
"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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_program_looks_through_paths_env_and_wrappers() {
|
||||
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
|
||||
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
|
||||
assert_eq!(command_program("npx opencode"), "opencode");
|
||||
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_program_keeps_a_line_that_is_only_wrappers() {
|
||||
assert_eq!(command_program("sudo"), "sudo");
|
||||
assert_eq!(command_program(""), "");
|
||||
}
|
||||
|
||||
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
|
||||
/// tools the web's `splitCommand().tool` does.
|
||||
#[test]
|
||||
fn command_tool_names_the_mark_the_card_shows() {
|
||||
assert_eq!(command_tool("claude --resume"), Some("claude"));
|
||||
assert_eq!(command_tool("npx opencode"), Some("opencode"));
|
||||
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
|
||||
assert_eq!(command_tool("CLAUDE"), Some("claude"));
|
||||
}
|
||||
|
||||
/// Narrow on purpose: an unrecognised program has no mark, so the
|
||||
/// candidate has no `tool` field to match rather than a wrong one.
|
||||
#[test]
|
||||
fn command_tool_is_absent_for_an_unrecognised_program() {
|
||||
assert_eq!(command_tool("hx src/main.rs"), None);
|
||||
assert_eq!(command_tool(""), None);
|
||||
fn structured_view_support_follows_the_resolved_program() {
|
||||
assert!(supports_structured_view("claude --resume"));
|
||||
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
|
||||
assert!(supports_structured_view("npx claude-code"));
|
||||
assert!(!supports_structured_view("opencode"));
|
||||
assert!(!supports_structured_view("bash -l"));
|
||||
assert!(!supports_structured_view("claude-code-acp"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ pub struct Session {
|
||||
/// daemon that predates the field keeps working untouched.
|
||||
#[serde(default)]
|
||||
pub view: SessionView,
|
||||
/// The ACP session id the `claude-code-acp` adapter handed back from
|
||||
/// The ACP session id the selected provider adapter handed back from
|
||||
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
|
||||
/// one, and only once its handshake succeeded; a later respawn passes it
|
||||
/// to `session/load` so the conversation continues (#135). Distinct from
|
||||
@@ -175,6 +175,10 @@ pub enum ActivityState {
|
||||
AwaitingInput,
|
||||
/// An agent reported a failure, or a structured session/turn failed.
|
||||
Error,
|
||||
/// The ACP adapter is booting; the session is registered but cannot accept
|
||||
/// prompts yet. Older clients that do not know this variant degrade it to
|
||||
/// `Idle` via the lenient deserializer — acceptable.
|
||||
Starting,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ActivityState {
|
||||
@@ -187,6 +191,7 @@ impl<'de> Deserialize<'de> for ActivityState {
|
||||
"Active" => Self::Active,
|
||||
"AwaitingInput" => Self::AwaitingInput,
|
||||
"Error" => Self::Error,
|
||||
"Starting" => Self::Starting,
|
||||
// Known or not, any other spelling degrades to the neutral default
|
||||
_ => Self::Idle,
|
||||
})
|
||||
@@ -225,9 +230,10 @@ where
|
||||
"Idle" => Ok(ActivityState::Idle),
|
||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||
"Error" => Ok(ActivityState::Error),
|
||||
"Starting" => Ok(ActivityState::Starting),
|
||||
other => Err(D::Error::unknown_variant(
|
||||
other,
|
||||
&["Active", "Idle", "AwaitingInput", "Error"],
|
||||
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -383,6 +389,10 @@ pub struct CreateSessionRequest {
|
||||
/// untouched.
|
||||
#[serde(default)]
|
||||
pub view: SessionView,
|
||||
/// When `true`, the daemon auto-approves every ACP permission request
|
||||
/// for this structured session.
|
||||
#[serde(default)]
|
||||
pub auto_approve: bool,
|
||||
}
|
||||
|
||||
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
||||
@@ -522,6 +532,11 @@ pub struct SpawnPreset {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
/// When `true`, the daemon auto-approves every ACP permission request
|
||||
/// for structured sessions spawned from this preset instead of surfacing
|
||||
/// them to the client.
|
||||
#[serde(default)]
|
||||
pub auto_approve: bool,
|
||||
}
|
||||
|
||||
/// Build provenance of the running daemon: the commit it was compiled
|
||||
@@ -824,6 +839,15 @@ pub struct TurnState {
|
||||
pub thinking: bool,
|
||||
#[serde(default)]
|
||||
pub active_tool_calls: Vec<String>,
|
||||
/// Wall-clock start of the active prompt. This lets reconnecting clients
|
||||
/// keep one continuous elapsed-time display instead of restarting at zero.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
/// Context-window usage when the active prompt began. ACP exposes context
|
||||
/// occupancy, not provider billing-token accounting, so clients must label
|
||||
/// deltas as context tokens rather than tokens billed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_context_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// A whole-session view derived from ACP notifications.
|
||||
@@ -846,6 +870,8 @@ pub struct ReducedState {
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_options: Option<Value>,
|
||||
}
|
||||
|
||||
/// Server messages sent over a structured-session WebSocket.
|
||||
@@ -871,6 +897,13 @@ pub struct StructuredPromptRequest {
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
/// HTTP body for `POST /api/sessions/:id/structured/config`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StructuredConfigRequest {
|
||||
pub config_id: String,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
/// Operator decision for a pending ACP permission request.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1233,6 +1266,7 @@ mod tests {
|
||||
create_branch: true,
|
||||
}),
|
||||
view: SessionView::Terminal,
|
||||
auto_approve: false,
|
||||
};
|
||||
assert_eq!(req, round_trip(&req));
|
||||
}
|
||||
|
||||
@@ -86,11 +86,21 @@ pub fn structured_prompt(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/prompt")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
|
||||
pub fn structured_attachment(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/attachments")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
|
||||
pub fn structured_cancel(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/cancel")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/structured/config` — change an ACP session option.
|
||||
pub fn structured_config(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/structured/config")
|
||||
}
|
||||
|
||||
/// `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}")
|
||||
@@ -108,7 +118,9 @@ pub mod patterns {
|
||||
pub const STRUCTURED: &str = "/api/structured/:id";
|
||||
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
|
||||
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
|
||||
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
|
||||
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
|
||||
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
|
||||
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ use chrono::Utc;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
@@ -60,6 +61,8 @@ struct Inner {
|
||||
forced_error: Option<(u16, ApiError)>,
|
||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||
/// Every ACP prompt sent to a structured session, in order.
|
||||
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -92,6 +95,7 @@ impl MockDaemon {
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
@@ -178,6 +182,16 @@ impl MockDaemon {
|
||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||
}
|
||||
|
||||
/// Every ACP prompt this daemon received, oldest first.
|
||||
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
|
||||
self.state
|
||||
.inner
|
||||
.lock()
|
||||
.expect("poisoned")
|
||||
.structured_prompts
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||
}
|
||||
@@ -266,6 +280,26 @@ async fn send_input(
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn structured_prompt(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<StructuredPromptRequest>,
|
||||
) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
}
|
||||
let mut guard = s.inner.lock().expect("poisoned");
|
||||
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
guard.structured_prompts.push((id, req));
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! `dispatch` / the overlay routing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -54,6 +55,36 @@ enum OverlayDecision {
|
||||
Pass,
|
||||
}
|
||||
|
||||
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
|
||||
let encoded = base64_encode(text.as_bytes());
|
||||
let mut output = std::io::stdout();
|
||||
write!(output, "\x1b]52;c;{encoded}\x07")?;
|
||||
output.flush()
|
||||
}
|
||||
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let bits = (u32::from(chunk[0]) << 16)
|
||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
|
||||
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
|
||||
output.push(if chunk.len() > 1 {
|
||||
TABLE[((bits >> 6) & 0x3f) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
output.push(if chunk.len() > 2 {
|
||||
TABLE[(bits & 0x3f) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
cfg: TuiConfig,
|
||||
home_dir: String,
|
||||
@@ -340,6 +371,10 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if self.handle_structured_mouse(&key) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(effects) = self.handle_structured_key(&key) {
|
||||
self.perform(effects).await;
|
||||
return Ok(false);
|
||||
@@ -423,6 +458,61 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
/// Transcript blocks are pointer targets: click selects the whole block,
|
||||
/// gives it the selection surface, and copies its complete text.
|
||||
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
|
||||
let InputKey::Mouse {
|
||||
kind,
|
||||
region: Region::Pane,
|
||||
row,
|
||||
..
|
||||
} = key
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
|
||||
return false;
|
||||
}
|
||||
let Some(sid) = self.state.active_session() else {
|
||||
return false;
|
||||
};
|
||||
let Some(handle) = self.structured.get(&sid) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pane) = self.region.pane else {
|
||||
return false;
|
||||
};
|
||||
let offset = usize::from(row.saturating_sub(pane.y));
|
||||
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
|
||||
if offset >= usize::from(transcript_height) {
|
||||
return true;
|
||||
}
|
||||
let mut state = handle.state.lock().expect("structured state mutex");
|
||||
let rendered_line = state.scroll.saturating_add(offset);
|
||||
if !state.select_rendered_line(rendered_line) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = state.selected.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
|
||||
return true;
|
||||
};
|
||||
if *kind == MouseKind::Hover {
|
||||
return true;
|
||||
}
|
||||
let text = crate::structured_pane::copy_text(transcript);
|
||||
drop(state);
|
||||
if text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match copy_to_terminal_clipboard(&text) {
|
||||
Ok(()) => self.state.toast("copied transcript block"),
|
||||
Err(error) => self.state.toast(format!("copy failed: {error}")),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||
/// the daemon.
|
||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||
@@ -477,7 +567,7 @@ impl App {
|
||||
.state
|
||||
.lock()
|
||||
.expect("structured state mutex")
|
||||
.approval_failed(error.to_string());
|
||||
.approval_failed(&nonce, error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipboard_payload_uses_standard_base64() {
|
||||
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
||||
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
|
||||
}
|
||||
use crate::action::Action;
|
||||
use crate::draft::DraftField;
|
||||
use crate::keymap::Mode;
|
||||
@@ -3009,6 +3105,7 @@ mod tests {
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
};
|
||||
app.submit_spawn(req).await;
|
||||
|
||||
@@ -3053,17 +3150,20 @@ mod tests {
|
||||
let request = |view| CreateSessionRequest {
|
||||
view,
|
||||
cwd: std::path::PathBuf::from("/srv/work"),
|
||||
command: "claude".into(),
|
||||
command: "codex".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let structured_id = Uuid::from_u128(188);
|
||||
app.state.mode = Mode::Insert;
|
||||
app.submit_spawn(request(SessionView::Structured)).await;
|
||||
assert!(app.catalog.snapshot().sessions.is_empty());
|
||||
assert!(app.structured.contains_key(&structured_id));
|
||||
assert!(app.emulators.get(structured_id).is_none());
|
||||
assert_eq!(app.state.mode, Mode::Normal);
|
||||
|
||||
let terminal_id = Uuid::from_u128(187);
|
||||
app.submit_spawn(request(SessionView::Terminal)).await;
|
||||
|
||||
+382
-13
@@ -41,8 +41,10 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||
};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -86,9 +88,11 @@ pub enum DraftField {
|
||||
/// apply immediately, mirroring the web form's per-click choice.
|
||||
WorktreeChoice,
|
||||
Name,
|
||||
/// Disclosure collapsing the worktree/branch controls below (#93).
|
||||
/// Disclosure collapsing the optional view and worktree controls below.
|
||||
/// Closed by default; Enter / Space toggles.
|
||||
Advanced,
|
||||
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
|
||||
StructuredToggle,
|
||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||
WorktreeToggle,
|
||||
/// New-vs-existing branch source; Left / Right switches.
|
||||
@@ -251,6 +255,14 @@ pub struct DraftPane {
|
||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||
/// take the default target.
|
||||
pub advanced_collapsed: bool,
|
||||
/// ACP-backed structured transcript. Supported only when the resolved
|
||||
/// command is Claude Code or Codex, and on by default whenever it is —
|
||||
/// until the operator works the toggle themselves.
|
||||
pub structured_view: bool,
|
||||
/// Set once the operator toggles structured view by hand. From then on a
|
||||
/// command change never re-enables what they turned off, the same guard
|
||||
/// `name_touched` gives the name field.
|
||||
pub structured_view_touched: bool,
|
||||
/// Enclosing repository of the chosen target — from the project scan for
|
||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||
pub base_repo: Option<PathBuf>,
|
||||
@@ -291,7 +303,7 @@ impl Default for DraftPane {
|
||||
// while its data loads, and stay usable when the daemon reports none.
|
||||
let mut presets = builtin_presets();
|
||||
presets.push(custom_preset());
|
||||
Self {
|
||||
let mut draft = Self {
|
||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
presets,
|
||||
selected_preset: 0,
|
||||
@@ -311,6 +323,8 @@ impl Default for DraftPane {
|
||||
name_touched: false,
|
||||
worktree_enabled: true,
|
||||
advanced_collapsed: true,
|
||||
structured_view: false,
|
||||
structured_view_touched: false,
|
||||
base_repo: None,
|
||||
probed_cwd: None,
|
||||
probe_seq: 0,
|
||||
@@ -329,7 +343,11 @@ impl Default for DraftPane {
|
||||
// straight away, and Tab from here reaches the command tiles.
|
||||
focused_field: DraftField::DirPicker,
|
||||
home_dir: String::new(),
|
||||
}
|
||||
};
|
||||
// The built-in tile the form opens on decides whether structured view
|
||||
// starts ticked, exactly as a later preset list would.
|
||||
draft.sync_structured_view();
|
||||
draft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +366,7 @@ impl DraftPane {
|
||||
.position(|p| !p.command.is_empty())
|
||||
.unwrap_or(0);
|
||||
self.apply_seed_command();
|
||||
self.sync_structured_view();
|
||||
}
|
||||
|
||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||
@@ -663,6 +682,28 @@ impl DraftPane {
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_structured_view(&self) -> bool {
|
||||
supports_structured_view(self.command())
|
||||
}
|
||||
|
||||
/// Re-resolve the toggle after the command changed: an unsupported
|
||||
/// command forces it off, a supported one turns it back on unless the
|
||||
/// operator has already made the choice themselves.
|
||||
fn sync_structured_view(&mut self) {
|
||||
if !self.supports_structured_view() {
|
||||
self.structured_view = false;
|
||||
} else if !self.structured_view_touched {
|
||||
self.structured_view = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_structured_view(&mut self) {
|
||||
if self.supports_structured_view() {
|
||||
self.structured_view = !self.structured_view;
|
||||
self.structured_view_touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen working directory: a picked project row, else the raw path
|
||||
/// input when it holds something.
|
||||
pub fn cwd(&self) -> Option<PathBuf> {
|
||||
@@ -693,13 +734,22 @@ impl DraftPane {
|
||||
);
|
||||
}
|
||||
let worktree = self.build_worktree()?;
|
||||
let auto_approve = self
|
||||
.presets
|
||||
.get(self.selected_preset)
|
||||
.is_some_and(|p| p.auto_approve);
|
||||
Ok(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
view: if self.structured_view && self.supports_structured_view() {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
},
|
||||
cwd,
|
||||
command: command.to_string(),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -754,6 +804,9 @@ impl DraftPane {
|
||||
order.push(DraftField::Name);
|
||||
order.push(DraftField::Advanced);
|
||||
if !self.advanced_collapsed {
|
||||
if self.supports_structured_view() {
|
||||
order.push(DraftField::StructuredToggle);
|
||||
}
|
||||
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||
order.push(DraftField::WorktreeChoice);
|
||||
}
|
||||
@@ -795,6 +848,7 @@ impl DraftPane {
|
||||
}
|
||||
self.selected_preset =
|
||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||
self.sync_structured_view();
|
||||
}
|
||||
|
||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||
@@ -1001,9 +1055,9 @@ impl DraftPane {
|
||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||
// highlighted row isn't the picked one until a first Enter chooses
|
||||
// it (the next Enter, now that it's active, falls through to
|
||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
||||
// than submitting, same as the web form's own `<button>` controls.
|
||||
// submit); and the disclosure/checkbox fields, which activate on
|
||||
// Enter like buttons rather than submitting, matching the web
|
||||
// form controls.
|
||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||
let choose_only =
|
||||
@@ -1023,6 +1077,10 @@ impl DraftPane {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
|
||||
self.toggle_structured_view();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter => DraftInputResult::Submit,
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
@@ -1040,6 +1098,14 @@ impl DraftPane {
|
||||
self.toggle_worktree();
|
||||
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 => {
|
||||
self.move_selection(-1);
|
||||
DraftInputResult::Consumed
|
||||
@@ -1083,7 +1149,10 @@ impl DraftPane {
|
||||
other => {
|
||||
match self.focused_field {
|
||||
DraftField::Preset | DraftField::Submit => {}
|
||||
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
||||
DraftField::CustomCommand => {
|
||||
self.custom_command.handle_key(other);
|
||||
self.sync_structured_view();
|
||||
}
|
||||
// Typing filters the list; the highlight resets to the top
|
||||
// so a narrowed search always starts at its best match.
|
||||
DraftField::DirPicker => {
|
||||
@@ -1121,9 +1190,9 @@ impl DraftPane {
|
||||
|
||||
let show_custom = self.is_custom();
|
||||
let show_raw = self.cwd_raw_visible;
|
||||
// The worktree choice step, the worktree toggle and its body all stay
|
||||
// behind the "Advanced options" disclosure (#93): collapsed hides
|
||||
// them, regardless of what they'd otherwise show.
|
||||
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
|
||||
// Optional view and worktree controls stay behind the "Advanced
|
||||
// options" disclosure (#93), regardless of what they'd otherwise show.
|
||||
let show_wt_toggle = !self.advanced_collapsed;
|
||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||
let show_wt_choice =
|
||||
@@ -1142,6 +1211,7 @@ impl DraftPane {
|
||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||
+ u16::from(show_custom)
|
||||
+ u16::from(show_raw)
|
||||
+ u16::from(show_structured)
|
||||
+ u16::from(show_wt_toggle)
|
||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||
@@ -1161,6 +1231,9 @@ impl DraftPane {
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // name
|
||||
constraints.push(Constraint::Length(1)); // advanced header
|
||||
if show_structured {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
if show_wt_choice {
|
||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||
}
|
||||
@@ -1359,6 +1432,24 @@ impl DraftPane {
|
||||
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 {
|
||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||
// worktrees newest-first.
|
||||
@@ -1706,6 +1797,7 @@ mod tests {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
}]);
|
||||
assert_eq!(
|
||||
draft.command(),
|
||||
@@ -1728,11 +1820,13 @@ mod tests {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
@@ -1752,6 +1846,7 @@ mod tests {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
}]);
|
||||
assert!(draft.is_custom());
|
||||
assert_eq!(draft.command(), "bash -l");
|
||||
@@ -1817,6 +1912,280 @@ mod tests {
|
||||
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
||||
let mut draft = DraftPane {
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "OpenCode".into(),
|
||||
command: "opencode".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert!(draft.structured_view, "on by default for a supported agent");
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(rendered.contains("[x] structured view"), "{rendered}");
|
||||
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "codex");
|
||||
assert!(
|
||||
draft.structured_view,
|
||||
"Codex keeps the structured selection"
|
||||
);
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert!(
|
||||
!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 Code or Codex, so
|
||||
/// the toggle stays offered and the request carries `Structured`; an
|
||||
/// unrelated program does not.
|
||||
#[test]
|
||||
fn structured_view_follows_command_resolution_for_custom_commands() {
|
||||
let cases: &[(&str, bool)] = &[
|
||||
("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),
|
||||
("codex", true),
|
||||
("codex --full-auto", true),
|
||||
("CODEX=1 codex", true),
|
||||
("/usr/local/bin/codex exec", true),
|
||||
("npx codex", true),
|
||||
("sudo -u root codex", true),
|
||||
("env -i codex", true),
|
||||
("command codex", true),
|
||||
("pnpm --silent dlx codex", true),
|
||||
("\"/opt/my agent/codex\" exec", true),
|
||||
("bash", false),
|
||||
("opencode", false),
|
||||
("/usr/local/bin/bash -l", false),
|
||||
// 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);
|
||||
draft.sync_structured_view();
|
||||
|
||||
assert_eq!(
|
||||
draft.field_order().contains(&DraftField::StructuredToggle),
|
||||
supported,
|
||||
"toggle visibility for {command:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
draft.structured_view, supported,
|
||||
"default selection for {command:?}"
|
||||
);
|
||||
let view = draft.build_request().unwrap().view;
|
||||
assert_eq!(
|
||||
view,
|
||||
if supported {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
},
|
||||
"request view for {command:?}"
|
||||
);
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"the toggle turns the default off for {command:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A configured preset (the spawn-form "alias" for a command) is gated
|
||||
/// the same way as free text: a preset whose command resolves to Claude
|
||||
/// Code or Codex 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(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex-wrapped".into(),
|
||||
label: "Codex (npx)".into(),
|
||||
command: "npx codex exec".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(draft.command(), "npx claude-code --resume");
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
assert!(draft.structured_view);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
// Switching between supported presets preserves the selection.
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "npx codex exec");
|
||||
assert!(draft.structured_view);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
// Switching to a preset that does not resolve to an ACP agent clears the
|
||||
// stale selection instead of letting an invalid request through.
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert!(!draft.structured_view, "stale selection must clear");
|
||||
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
/// The default only applies until the operator has an opinion: once they
|
||||
/// untick the box, moving between supported commands leaves it unticked
|
||||
/// instead of turning it back on under them.
|
||||
#[test]
|
||||
fn an_unticked_structured_view_survives_a_command_change() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert!(draft.structured_view);
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(!draft.structured_view);
|
||||
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "codex");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"the operator's choice outranks the default"
|
||||
);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
/// Editing the custom-command text away from a supported agent clears an armed
|
||||
/// 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_supported_agent() {
|
||||
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("codex");
|
||||
draft.sync_structured_view();
|
||||
assert!(draft.structured_view);
|
||||
|
||||
typed(&mut draft, " -p");
|
||||
assert_eq!(draft.command(), "codex -p");
|
||||
assert!(draft.structured_view, "still Codex, selection survives");
|
||||
|
||||
for _ in 0.."codex -p".chars().count() {
|
||||
draft.on_input(&InputKey::Backspace);
|
||||
}
|
||||
typed(&mut draft, "bash");
|
||||
assert_eq!(draft.command(), "bash");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"editing the program away from Codex clears the stale selection"
|
||||
);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -121,6 +121,7 @@ impl FastForwardDefaults {
|
||||
branch: handle.to_string(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -184,6 +185,7 @@ mod tests {
|
||||
id: id.into(),
|
||||
label: id.into(),
|
||||
command: command.into(),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ pub enum MouseKind {
|
||||
Click,
|
||||
DblClick,
|
||||
Drag,
|
||||
Hover,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
}
|
||||
@@ -148,6 +149,7 @@ impl InputKey {
|
||||
MouseKind::Click => "click",
|
||||
MouseKind::DblClick => "dblclick",
|
||||
MouseKind::Drag => "drag",
|
||||
MouseKind::Hover => "hover",
|
||||
MouseKind::ScrollUp => "scrollup",
|
||||
MouseKind::ScrollDown => "scrolldown",
|
||||
};
|
||||
@@ -178,6 +180,7 @@ impl InputKey {
|
||||
"click" => MouseKind::Click,
|
||||
"dblclick" => MouseKind::DblClick,
|
||||
"drag" => MouseKind::Drag,
|
||||
"hover" => MouseKind::Hover,
|
||||
"scrollup" => MouseKind::ScrollUp,
|
||||
"scrolldown" => MouseKind::ScrollDown,
|
||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||
@@ -297,6 +300,7 @@ where
|
||||
let mk = match kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||
MouseEventKind::Moved => MouseKind::Hover,
|
||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||
_ => return None,
|
||||
@@ -681,6 +685,9 @@ mod tests {
|
||||
let s = "mouse:dblclick(sidebar-row)";
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s);
|
||||
let s = "mouse:hover(pane)";
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -82,7 +82,7 @@ impl Surface {
|
||||
pub fn activity(&self, state: ActivityState) -> Color {
|
||||
match state {
|
||||
ActivityState::Active => self.active,
|
||||
ActivityState::Idle => self.idle,
|
||||
ActivityState::Idle | ActivityState::Starting => self.idle,
|
||||
ActivityState::AwaitingInput => self.awaiting,
|
||||
ActivityState::Error => self.error,
|
||||
}
|
||||
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const STATES: [ActivityState; 4] = [
|
||||
const STATES: [ActivityState; 5] = [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
];
|
||||
|
||||
@@ -979,6 +979,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
@@ -1009,6 +1010,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
|
||||
@@ -14,6 +14,8 @@ use crate::session_task::SessionState;
|
||||
use crate::structured_task::{offered_decisions, StructuredHandle, StructuredState};
|
||||
|
||||
const DIFF_PREVIEW_LINES: usize = 3;
|
||||
const DIFF_CONTEXT_LINES: usize = 3;
|
||||
const LCS_CELL_LIMIT: usize = 250_000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructuredPane {
|
||||
@@ -88,6 +90,7 @@ impl StructuredPane {
|
||||
|
||||
let viewport = usize::from(area.height);
|
||||
let max_scroll = lines.len().saturating_sub(viewport);
|
||||
state.scroll = reconcile_scroll(&state.scroll, &state.line_rows, &lines);
|
||||
if state.follow_selection {
|
||||
if let Some(selected) = state.selected.as_deref() {
|
||||
let first = lines
|
||||
@@ -136,6 +139,7 @@ impl StructuredPane {
|
||||
Rect::new(area.x, area.y + offset as u16, area.width, 1),
|
||||
);
|
||||
}
|
||||
state.line_rows = lines.iter().map(|line| line.row_id.clone()).collect();
|
||||
}
|
||||
|
||||
fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
|
||||
@@ -212,13 +216,104 @@ fn error_line(text: &str) -> VisualLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate `scroll` — a raw line index into `old_rows` (the previous
|
||||
/// render's row-per-line layout) — into the equivalent index into `lines`
|
||||
/// (this render's freshly rebuilt layout), anchored on row identity rather
|
||||
/// than raw position. A row's wrapped lines above or intersecting the
|
||||
/// viewport growing, shrinking, or moving index therefore shifts `scroll`
|
||||
/// along with it instead of leaving the viewport looking at different
|
||||
/// content. Falls back to the nearest surviving neighbour row when the
|
||||
/// anchor row itself is gone, and to the raw index (clamped) when nothing
|
||||
/// nearby survived either.
|
||||
fn reconcile_scroll(scroll: &usize, old_rows: &[Option<String>], lines: &[VisualLine]) -> usize {
|
||||
if old_rows.is_empty() {
|
||||
return *scroll;
|
||||
}
|
||||
let old_index = (*scroll).min(old_rows.len() - 1);
|
||||
let anchor = old_rows[old_index].clone();
|
||||
match anchor {
|
||||
Some(id) => match find_row_block(lines, &id) {
|
||||
Some((start, len)) => {
|
||||
start + (old_index - block_start(old_rows, old_index)).min(len - 1)
|
||||
}
|
||||
None => fallback_after_removal(old_rows, lines, old_index),
|
||||
},
|
||||
None => {
|
||||
// Untagged tail lines (connecting/dead/error) carry no row identity;
|
||||
// approximate by preserving distance from the end of the transcript.
|
||||
let distance_from_end = old_rows.len() - old_index;
|
||||
lines.len().saturating_sub(distance_from_end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the first line belonging to `id`, and how many lines it spans.
|
||||
/// A row's wrapped lines are always contiguous in `lines` — `build_lines`
|
||||
/// emits them as one run per row — so a single forward scan suffices.
|
||||
fn find_row_block(lines: &[VisualLine], id: &str) -> Option<(usize, usize)> {
|
||||
let start = lines
|
||||
.iter()
|
||||
.position(|line| line.row_id.as_deref() == Some(id))?;
|
||||
let mut end = start;
|
||||
while end + 1 < lines.len() && lines[end + 1].row_id.as_deref() == Some(id) {
|
||||
end += 1;
|
||||
}
|
||||
Some((start, end - start + 1))
|
||||
}
|
||||
|
||||
/// First index of the contiguous run that `index` sits in within `rows`.
|
||||
fn block_start(rows: &[Option<String>], index: usize) -> usize {
|
||||
let mut start = index;
|
||||
while start > 0 && rows[start - 1] == rows[index] {
|
||||
start -= 1;
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
/// The anchor row is gone (removed, or replaced wholesale by a fresh
|
||||
/// snapshot). Search outward from its old position, nearest first, for a
|
||||
/// still-live neighbour row and land on its first line; if none of the old
|
||||
/// layout's rows survived, fall back to the raw old index clamped into the
|
||||
/// new layout.
|
||||
fn fallback_after_removal(
|
||||
old_rows: &[Option<String>],
|
||||
lines: &[VisualLine],
|
||||
old_index: usize,
|
||||
) -> usize {
|
||||
let len = old_rows.len();
|
||||
let mut distance = 1usize;
|
||||
loop {
|
||||
if distance > old_index && old_index + distance >= len {
|
||||
return old_index.min(lines.len().saturating_sub(1));
|
||||
}
|
||||
if distance <= old_index {
|
||||
if let Some((start, _)) = old_rows[old_index - distance]
|
||||
.as_ref()
|
||||
.and_then(|id| find_row_block(lines, id))
|
||||
{
|
||||
return start;
|
||||
}
|
||||
}
|
||||
if old_index + distance < len {
|
||||
if let Some((start, _)) = old_rows[old_index + distance]
|
||||
.as_ref()
|
||||
.and_then(|id| find_row_block(lines, id))
|
||||
{
|
||||
return start;
|
||||
}
|
||||
}
|
||||
distance += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
let mut out = Vec::new();
|
||||
for row in &state.rows {
|
||||
for (index, row) in state.rows.iter().enumerate() {
|
||||
if row.kind == TranscriptRowKind::Plan {
|
||||
continue;
|
||||
}
|
||||
let row_width = width.saturating_sub(1).max(1);
|
||||
let start = out.len();
|
||||
match row.kind {
|
||||
TranscriptRowKind::User => push_wrapped(
|
||||
&mut out,
|
||||
@@ -262,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
),
|
||||
TranscriptRowKind::Plan => {}
|
||||
}
|
||||
if out.len() > start
|
||||
&& state.rows[index + 1..]
|
||||
.iter()
|
||||
.any(|next| next.kind != TranscriptRowKind::Plan)
|
||||
{
|
||||
// A blank surface line is the vertical padding that keeps adjacent
|
||||
// transcript blocks readable at a glance.
|
||||
out.push(VisualLine {
|
||||
text: String::new(),
|
||||
row_id: None,
|
||||
rail: false,
|
||||
tone: Tone::Normal,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text supplied to the terminal clipboard when a transcript block is
|
||||
/// clicked. It preserves the full tool detail rather than its compact preview.
|
||||
pub fn copy_text(row: &TranscriptRow) -> String {
|
||||
match row.kind {
|
||||
TranscriptRowKind::User
|
||||
| TranscriptRowKind::AgentText
|
||||
| TranscriptRowKind::AgentThought => content_text(row),
|
||||
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
|
||||
let (name, argument) = tool_title(row);
|
||||
let mut lines = vec![if argument.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{name} {argument}")
|
||||
}];
|
||||
lines.extend(tool_content_lines(row, None));
|
||||
lines.join("\n")
|
||||
}
|
||||
TranscriptRowKind::Approval => row
|
||||
.data
|
||||
.get("toolCall")
|
||||
.and_then(|tool| tool.get("title"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("permission requested")
|
||||
.into(),
|
||||
TranscriptRowKind::Notice => "[notice]".into(),
|
||||
TranscriptRowKind::Plan => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_wrapped(
|
||||
out: &mut Vec<VisualLine>,
|
||||
row: &TranscriptRow,
|
||||
@@ -322,8 +460,8 @@ fn tool_lines(
|
||||
},
|
||||
});
|
||||
|
||||
let content = tool_content_lines(row);
|
||||
if row.kind == TranscriptRowKind::ToolDiff && !expanded {
|
||||
let content = tool_content_lines(row, Some(DIFF_PREVIEW_LINES + 1));
|
||||
let shown = content.len().min(DIFF_PREVIEW_LINES);
|
||||
for text in content.iter().take(shown) {
|
||||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(text), false);
|
||||
@@ -340,6 +478,7 @@ fn tool_lines(
|
||||
);
|
||||
}
|
||||
} else if expanded {
|
||||
let content = tool_content_lines(row, None);
|
||||
for text in content {
|
||||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(&text), false);
|
||||
}
|
||||
@@ -448,7 +587,7 @@ fn tool_title(row: &TranscriptRow) -> (String, String) {
|
||||
(explicit.unwrap_or(fallback).to_string(), title.to_string())
|
||||
}
|
||||
|
||||
fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
fn tool_content_lines(row: &TranscriptRow, limit: Option<usize>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for block in row
|
||||
.data
|
||||
@@ -457,6 +596,9 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if limit.is_some_and(|limit| out.len() >= limit) {
|
||||
break;
|
||||
}
|
||||
match block.get("type").and_then(serde_json::Value::as_str) {
|
||||
Some("content") => {
|
||||
let nested = block.get("content").cloned().unwrap_or_default();
|
||||
@@ -466,7 +608,8 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
data: serde_json::json!({"content": nested}),
|
||||
};
|
||||
let text = content_text(&fake);
|
||||
out.extend(text.lines().map(str::to_string));
|
||||
let remaining = limit.map_or(usize::MAX, |limit| limit.saturating_sub(out.len()));
|
||||
out.extend(text.lines().take(remaining).map(str::to_string));
|
||||
}
|
||||
Some("diff") => {
|
||||
let path = block
|
||||
@@ -474,11 +617,10 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("file");
|
||||
out.push(format!("{path}:"));
|
||||
if let Some(old) = block.get("oldText").and_then(serde_json::Value::as_str) {
|
||||
out.extend(old.lines().map(|line| format!("- {line}")));
|
||||
}
|
||||
let old = block.get("oldText").and_then(serde_json::Value::as_str);
|
||||
if let Some(new) = block.get("newText").and_then(serde_json::Value::as_str) {
|
||||
out.extend(new.lines().map(|line| format!("+ {line}")));
|
||||
let remaining = limit.map(|limit| limit.saturating_sub(out.len()));
|
||||
out.extend(render_diff(old, new, remaining));
|
||||
}
|
||||
}
|
||||
Some("terminal") => out.push("[embedded terminal]".into()),
|
||||
@@ -488,6 +630,236 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct SourceLine<'a> {
|
||||
text: &'a str,
|
||||
terminated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum DiffKind {
|
||||
Add,
|
||||
Delete,
|
||||
Context,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct DiffOp<'a> {
|
||||
kind: DiffKind,
|
||||
line: SourceLine<'a>,
|
||||
}
|
||||
|
||||
fn split_diff_lines(text: &str) -> Vec<SourceLine<'_>> {
|
||||
text.split_inclusive('\n')
|
||||
.map(|part| SourceLine {
|
||||
text: part.strip_suffix('\n').unwrap_or(part),
|
||||
terminated: part.ends_with('\n'),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lcs_diff<'a>(old: &[SourceLine<'a>], new: &[SourceLine<'a>]) -> Vec<DiffOp<'a>> {
|
||||
if old.is_empty() {
|
||||
return new
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if new.is_empty() {
|
||||
return old
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Delete,
|
||||
line,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if old.len().saturating_mul(new.len()) > LCS_CELL_LIMIT {
|
||||
return old
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Delete,
|
||||
line,
|
||||
})
|
||||
.chain(new.iter().copied().map(|line| DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line,
|
||||
}))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let columns = new.len() + 1;
|
||||
let mut table = vec![0_u32; (old.len() + 1) * columns];
|
||||
for i in (0..old.len()).rev() {
|
||||
for j in (0..new.len()).rev() {
|
||||
table[i * columns + j] = if old[i] == new[j] {
|
||||
table[(i + 1) * columns + j + 1] + 1
|
||||
} else {
|
||||
table[(i + 1) * columns + j].max(table[i * columns + j + 1])
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let mut ops = Vec::with_capacity(old.len() + new.len());
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < old.len() && j < new.len() {
|
||||
if old[i] == new[j] {
|
||||
ops.push(DiffOp {
|
||||
kind: DiffKind::Context,
|
||||
line: old[i],
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if table[(i + 1) * columns + j] >= table[i * columns + j + 1] {
|
||||
ops.push(DiffOp {
|
||||
kind: DiffKind::Delete,
|
||||
line: old[i],
|
||||
});
|
||||
i += 1;
|
||||
} else {
|
||||
ops.push(DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line: new[j],
|
||||
});
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
ops.extend(old[i..].iter().copied().map(|line| DiffOp {
|
||||
kind: DiffKind::Delete,
|
||||
line,
|
||||
}));
|
||||
ops.extend(new[j..].iter().copied().map(|line| DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line,
|
||||
}));
|
||||
ops
|
||||
}
|
||||
|
||||
fn diff_ops<'a>(old: &'a str, new: &'a str) -> Vec<DiffOp<'a>> {
|
||||
let old = split_diff_lines(old);
|
||||
let new = split_diff_lines(new);
|
||||
let mut prefix = 0;
|
||||
while prefix < old.len() && prefix < new.len() && old[prefix] == new[prefix] {
|
||||
prefix += 1;
|
||||
}
|
||||
let mut suffix = 0;
|
||||
while suffix < old.len() - prefix
|
||||
&& suffix < new.len() - prefix
|
||||
&& old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix]
|
||||
{
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
let mut ops = old[..prefix]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Context,
|
||||
line,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ops.extend(lcs_diff(
|
||||
&old[prefix..old.len() - suffix],
|
||||
&new[prefix..new.len() - suffix],
|
||||
));
|
||||
ops.extend(
|
||||
old[old.len() - suffix..]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Context,
|
||||
line,
|
||||
}),
|
||||
);
|
||||
ops
|
||||
}
|
||||
|
||||
fn render_diff_ops(ops: &[DiffOp<'_>]) -> Vec<String> {
|
||||
let mut rendered = Vec::with_capacity(ops.len());
|
||||
let mut index = 0;
|
||||
while index < ops.len() {
|
||||
if ops[index].kind != DiffKind::Context {
|
||||
push_diff_op(&mut rendered, ops[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut end = index;
|
||||
while end < ops.len() && ops[end].kind == DiffKind::Context {
|
||||
end += 1;
|
||||
}
|
||||
let run = &ops[index..end];
|
||||
let whole_diff = index == 0 && end == ops.len();
|
||||
let keep_head = if whole_diff || index > 0 {
|
||||
DIFF_CONTEXT_LINES
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let keep_tail = if whole_diff || end < ops.len() {
|
||||
DIFF_CONTEXT_LINES
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if run.len() <= keep_head + keep_tail {
|
||||
for op in run {
|
||||
push_diff_op(&mut rendered, *op);
|
||||
}
|
||||
} else {
|
||||
for op in run.iter().take(keep_head) {
|
||||
push_diff_op(&mut rendered, *op);
|
||||
}
|
||||
let collapsed = &run[keep_head..run.len() - keep_tail];
|
||||
rendered.push(format!("… {} unchanged lines …", collapsed.len()));
|
||||
if collapsed.iter().any(|op| !op.line.terminated) {
|
||||
rendered.push("\\ No newline at end of file".into());
|
||||
}
|
||||
for op in run.iter().skip(run.len() - keep_tail) {
|
||||
push_diff_op(&mut rendered, *op);
|
||||
}
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn push_diff_op(rendered: &mut Vec<String>, op: DiffOp<'_>) {
|
||||
let prefix = match op.kind {
|
||||
DiffKind::Add => "+ ",
|
||||
DiffKind::Delete => "- ",
|
||||
DiffKind::Context => " ",
|
||||
};
|
||||
rendered.push(format!("{prefix}{}", op.line.text));
|
||||
if !op.line.terminated {
|
||||
rendered.push("\\ No newline at end of file".into());
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff(old: Option<&str>, new: &str, limit: Option<usize>) -> Vec<String> {
|
||||
let ops = old.map_or_else(
|
||||
|| {
|
||||
split_diff_lines(new)
|
||||
.into_iter()
|
||||
.map(|line| DiffOp {
|
||||
kind: DiffKind::Add,
|
||||
line,
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
|old| diff_ops(old, new),
|
||||
);
|
||||
let mut rendered = render_diff_ops(&ops);
|
||||
if let Some(limit) = limit {
|
||||
rendered.truncate(limit);
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn diff_tone(text: &str) -> Tone {
|
||||
if text.starts_with("+ ") {
|
||||
Tone::Active
|
||||
@@ -672,6 +1044,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_blocks_have_a_padding_line_between_them() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows = vec![
|
||||
row(
|
||||
"one",
|
||||
TranscriptRowKind::User,
|
||||
json!({"content":{"type":"text","text":"first"}}),
|
||||
),
|
||||
row(
|
||||
"two",
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"second"}}),
|
||||
),
|
||||
];
|
||||
|
||||
let lines = build_lines(&state, 80);
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[1].text, "");
|
||||
assert!(lines[1].row_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_tool_keeps_its_full_detail() {
|
||||
let tool = row(
|
||||
"t",
|
||||
TranscriptRowKind::ToolCall,
|
||||
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
|
||||
);
|
||||
|
||||
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_is_compact_until_selected_row_expands() {
|
||||
let mut state = StructuredState::default();
|
||||
@@ -722,6 +1127,230 @@ mod tests {
|
||||
assert!(lines.last().unwrap().text.contains("Enter to expand"));
|
||||
}
|
||||
|
||||
fn diff_content(old_text: Option<&str>, new_text: &str) -> Vec<String> {
|
||||
let old_text = old_text.map_or(serde_json::Value::Null, |text| json!(text));
|
||||
let row = row(
|
||||
"d",
|
||||
TranscriptRowKind::ToolDiff,
|
||||
json!({"content":[{"type":"diff","path":"x","oldText":old_text,"newText":new_text}]}),
|
||||
);
|
||||
tool_content_lines(&row, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_preserves_unchanged_lines() {
|
||||
assert_eq!(diff_content(Some("a\nb\n"), "a\nb\n"), ["x:", " a", " b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_handles_insert_delete_replace_and_empty_files() {
|
||||
assert_eq!(
|
||||
diff_content(Some("a\nc\n"), "a\nb\nc\n"),
|
||||
["x:", " a", "+ b", " c"]
|
||||
);
|
||||
assert_eq!(
|
||||
diff_content(Some("a\nb\nc\n"), "a\nc\n"),
|
||||
["x:", " a", "- b", " c"]
|
||||
);
|
||||
assert_eq!(
|
||||
diff_content(Some("a\nb\n"), "a\nc\n"),
|
||||
["x:", " a", "- b", "+ c"]
|
||||
);
|
||||
assert_eq!(diff_content(Some(""), ""), ["x:"]);
|
||||
assert_eq!(diff_content(None, "a\n"), ["x:", "+ a"]);
|
||||
assert_eq!(diff_content(Some("a\n"), ""), ["x:", "- a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_marks_added_and_removed_final_newlines() {
|
||||
assert_eq!(
|
||||
diff_content(Some("a"), "a\n"),
|
||||
["x:", "- a", "\\ No newline at end of file", "+ a"]
|
||||
);
|
||||
assert_eq!(
|
||||
diff_content(Some("a\n"), "a"),
|
||||
["x:", "- a", "+ a", "\\ No newline at end of file"]
|
||||
);
|
||||
assert_eq!(
|
||||
diff_content(None, "a"),
|
||||
["x:", "+ a", "\\ No newline at end of file"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_marks_unchanged_unterminated_final_context() {
|
||||
assert_eq!(
|
||||
diff_content(Some("a\nb\nc"), "a\nX\nc"),
|
||||
[
|
||||
"x:",
|
||||
" a",
|
||||
"- b",
|
||||
"+ X",
|
||||
" c",
|
||||
"\\ No newline at end of file"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_collapses_long_context_around_changes() {
|
||||
let common = (0..20)
|
||||
.map(|index| format!("line{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
let old = common[..10]
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(["OLD".into()])
|
||||
.chain(common[10..].iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let new = common[..10]
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(["NEW".into()])
|
||||
.chain(common[10..].iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert_eq!(
|
||||
render_diff(Some(&old), &new, None),
|
||||
[
|
||||
"… 7 unchanged lines …",
|
||||
" line7",
|
||||
" line8",
|
||||
" line9",
|
||||
"- OLD",
|
||||
"+ NEW",
|
||||
" line10",
|
||||
" line11",
|
||||
" line12",
|
||||
"… 7 unchanged lines …",
|
||||
"\\ No newline at end of file",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_preserves_eof_marker_through_trailing_collapse() {
|
||||
let trailing = (0..10).map(|index| format!("t{index}")).collect::<Vec<_>>();
|
||||
let old = ["OLD".into()]
|
||||
.into_iter()
|
||||
.chain(trailing.iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let new = ["NEW".into()]
|
||||
.into_iter()
|
||||
.chain(trailing)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert_eq!(
|
||||
render_diff(Some(&old), &new, None),
|
||||
[
|
||||
"- OLD",
|
||||
"+ NEW",
|
||||
" t0",
|
||||
" t1",
|
||||
" t2",
|
||||
"… 7 unchanged lines …",
|
||||
"\\ No newline at end of file",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_collapses_only_long_interior_context() {
|
||||
let middle = (0..10).map(|index| format!("c{index}")).collect::<Vec<_>>();
|
||||
let old = ["lead0", "lead1", "OLD1"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.chain(middle.iter().cloned())
|
||||
.chain(["OLD2", "trail0", "trail1"].into_iter().map(str::to_string))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let new = ["lead0", "lead1", "NEW1"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.chain(middle.iter().cloned())
|
||||
.chain(["NEW2", "trail0", "trail1"].into_iter().map(str::to_string))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let rendered = render_diff(Some(&old), &new, None);
|
||||
|
||||
assert_eq!(
|
||||
rendered
|
||||
.iter()
|
||||
.filter(|line| line.contains("unchanged lines"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(&rendered[..4], [" lead0", " lead1", "- OLD1", "+ NEW1"]);
|
||||
assert_eq!(
|
||||
&rendered[4..11],
|
||||
[
|
||||
" c0",
|
||||
" c1",
|
||||
" c2",
|
||||
"… 4 unchanged lines …",
|
||||
" c7",
|
||||
" c8",
|
||||
" c9"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
&rendered[11..],
|
||||
[
|
||||
"- OLD2",
|
||||
"+ NEW2",
|
||||
" trail0",
|
||||
" trail1",
|
||||
"\\ No newline at end of file"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_preview_remains_bounded_after_hunking() {
|
||||
let common = (0..10)
|
||||
.map(|index| format!("line{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
let old = common
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(["OLD".into()])
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let new = common
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(["NEW".into()])
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let expanded = render_diff(Some(&old), &new, None);
|
||||
|
||||
assert_eq!(render_diff(Some(&old), &new, Some(4)), expanded[..4]);
|
||||
assert_eq!(expanded[0], "… 7 unchanged lines …");
|
||||
assert!(expanded.contains(&"- OLD".into()));
|
||||
assert!(expanded.contains(&"+ NEW".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_diff_bounds_pathological_changed_middles() {
|
||||
let old_lines = (0..501)
|
||||
.map(|index| format!("line{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut new_lines = old_lines[1..].to_vec();
|
||||
new_lines.push(old_lines[0].clone());
|
||||
let old = format!("{}\n", old_lines.join("\n"));
|
||||
let new = format!("{}\n", new_lines.join("\n"));
|
||||
let rendered = render_diff(Some(&old), &new, None);
|
||||
|
||||
assert_eq!(rendered.len(), 1_002);
|
||||
assert!(rendered[..501].iter().all(|line| line.starts_with("- ")));
|
||||
assert!(rendered[501..].iter().all(|line| line.starts_with("+ ")));
|
||||
assert_eq!(render_diff(Some(&old), &new, Some(4)), rendered[..4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_is_not_a_transcript_row_and_status_is_two_lines() {
|
||||
let mut state = StructuredState::default();
|
||||
@@ -812,4 +1441,213 @@ mod tests {
|
||||
assert!(text.contains("adapter exited"), "{text}");
|
||||
assert!(text.contains("request failed"), "{text}");
|
||||
}
|
||||
|
||||
fn test_pane() -> StructuredPane {
|
||||
StructuredPane {
|
||||
handle: StructuredHandle::test_handle(StructuredState::default()),
|
||||
focused: true,
|
||||
mode: Mode::Normal,
|
||||
session_status: SessionStatus::Running,
|
||||
activity_detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered_rows(state: &mut StructuredState, count: usize) {
|
||||
for n in 0..count {
|
||||
state.rows.push(row(
|
||||
&format!("r{n}"),
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":format!("line {n}")}}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn grow_row(state: &mut StructuredState, id: &str) {
|
||||
let index = state.rows.iter().position(|r| r.id == id).unwrap();
|
||||
state.rows[index] = row(
|
||||
id,
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"a very long streaming reply that keeps growing and will wrap across several lines now"}}),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_transcript_lines(
|
||||
pane: &StructuredPane,
|
||||
state: &mut StructuredState,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> Vec<String> {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| pane.render_transcript(frame, frame.area(), state))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
(0..height)
|
||||
.map(|y| {
|
||||
(0..width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_growing_above_the_viewport_keeps_the_visual_anchor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
|
||||
grow_row(&mut state, "r3");
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after[0].contains("line 10"),
|
||||
"a row growing above the viewport must not shift what's on screen: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_a_row_below_the_viewport_keeps_the_visual_anchor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
|
||||
state.rows.push(row(
|
||||
"r20",
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"line 20"}}),
|
||||
));
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert_eq!(before, after, "growth below the viewport must not move it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_row_above_the_viewport_keeps_the_visual_anchor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
|
||||
state.rows.retain(|r| r.id != "r3");
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after[0].contains("line 10"),
|
||||
"a row removed above the viewport must not shift what's on screen: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_anchor_row_falls_back_to_the_nearest_surviving_neighbor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
|
||||
state.rows.retain(|r| r.id != "r10");
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after[0].contains("line 9"),
|
||||
"removing the anchor row should fall back to its nearest surviving neighbor: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patching_a_row_above_the_viewport_keeps_the_visual_anchor() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 20;
|
||||
|
||||
render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||||
// viewport expanding into a multi-line diff.
|
||||
let index = state.rows.iter().position(|r| r.id == "r5").unwrap();
|
||||
state.rows[index] = row(
|
||||
"r5",
|
||||
TranscriptRowKind::ToolDiff,
|
||||
json!({"title":"Edit(x)","content":[{"type":"diff","path":"x","oldText":"a","newText":"a\nb\nc\nd"}]}),
|
||||
);
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after[0].contains("line 10"),
|
||||
"a patched row above the viewport must not shift what's on screen: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_width_reconciles_without_panicking() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
|
||||
render_transcript_lines(&pane, &mut state, 1, 3);
|
||||
grow_row(&mut state, "r3");
|
||||
render_transcript_lines(&pane, &mut state, 1, 3);
|
||||
assert!(state.scroll <= state.line_rows.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_selection_without_a_selection_stays_pinned_to_the_live_edge() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = true;
|
||||
state.selected = None;
|
||||
numbered_rows(&mut state, 20);
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before.last().unwrap().contains("line 19"), "{before:?}");
|
||||
|
||||
state.rows.push(row(
|
||||
"r20",
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"line 20"}}),
|
||||
));
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after.last().unwrap().contains("line 20"),
|
||||
"bottom-follow must track the live edge as rows are appended: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_selection_keeps_the_selected_row_visible_when_a_row_above_it_grows() {
|
||||
let pane = test_pane();
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = true;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.selected = Some("r15".into());
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
before.iter().any(|line| line.contains("line 15")),
|
||||
"{before:?}"
|
||||
);
|
||||
|
||||
grow_row(&mut state, "r3");
|
||||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(
|
||||
after.iter().any(|line| line.contains("line 15")),
|
||||
"the selected row must stay visible while follow_selection is on: {after:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ pub struct StructuredState {
|
||||
pub selected: Option<String>,
|
||||
pub 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>,
|
||||
@@ -42,6 +48,7 @@ impl Default for StructuredState {
|
||||
selected: None,
|
||||
expanded: HashSet::new(),
|
||||
scroll: 0,
|
||||
line_rows: Vec::new(),
|
||||
follow_selection: true,
|
||||
composer: TextInput::default(),
|
||||
error: None,
|
||||
@@ -192,6 +199,18 @@ impl StructuredState {
|
||||
self.follow_selection = true;
|
||||
}
|
||||
|
||||
/// Select the transcript block under a rendered transcript line. Pointer
|
||||
/// input uses the same row identity map as scroll reconciliation, so a
|
||||
/// wrapped block behaves as one target.
|
||||
pub fn select_rendered_line(&mut self, line: usize) -> bool {
|
||||
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
|
||||
return false;
|
||||
};
|
||||
self.selected = Some(id);
|
||||
self.follow_selection = false;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn toggle_selected(&mut self) {
|
||||
let Some(id) = self.selected.clone() else {
|
||||
return;
|
||||
@@ -289,7 +308,10 @@ impl StructuredState {
|
||||
Some((nonce, decision))
|
||||
}
|
||||
|
||||
pub fn approval_failed(&mut self, error: String) {
|
||||
pub fn approval_failed(&mut self, nonce: &str, error: String) {
|
||||
if self.pending_nonce() != Some(nonce) {
|
||||
return;
|
||||
}
|
||||
self.approval_submitting = false;
|
||||
self.error = Some(error);
|
||||
}
|
||||
@@ -554,6 +576,81 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_approval_ignores_its_late_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
state.approval_failed("old", "response lost".into());
|
||||
|
||||
assert_eq!(state.error, None);
|
||||
assert!(!state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_approval_ignores_its_late_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.rows.push(row(
|
||||
"approval:2",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: ReducedState {
|
||||
pending_approval: Some(json!({"nonce":"new"})),
|
||||
..ReducedState::default()
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
state.approval_decision('a'),
|
||||
Some(("new".into(), StructuredApprovalDecision::AllowOnce))
|
||||
);
|
||||
|
||||
state.approval_failed("old", "response lost".into());
|
||||
|
||||
assert_eq!(state.error, None);
|
||||
assert!(state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_approval_surfaces_its_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"current"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.approval_failed("current", "request failed".into());
|
||||
|
||||
assert_eq!(state.error.as_deref(), Some("request failed"));
|
||||
assert!(!state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
||||
let mut state = StructuredState::default();
|
||||
@@ -572,6 +669,25 @@ mod tests {
|
||||
assert_eq!(state.composer.value(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_rendered_line_selects_its_whole_block() {
|
||||
let mut state = StructuredState {
|
||||
line_rows: vec![
|
||||
Some("one".into()),
|
||||
Some("one".into()),
|
||||
None,
|
||||
Some("two".into()),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(state.select_rendered_line(1));
|
||||
assert_eq!(state.selected.as_deref(), Some("one"));
|
||||
assert!(!state.follow_selection);
|
||||
assert!(!state.select_rendered_line(2));
|
||||
assert_eq!(state.selected.as_deref(), Some("one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
||||
let mut state = StructuredState::default();
|
||||
|
||||
@@ -29,3 +29,6 @@ tracing-subscriber.workspace = true
|
||||
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
|
||||
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
|
||||
maestro-tui = { path = "../maestro-tui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Process-level routing for the umbrella `maestro` binary (#10): each
|
||||
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
|
||||
//! not just parse successfully.
|
||||
//!
|
||||
//! A malformed `--config` file makes every crate's dispatch fail identically
|
||||
//! at config load, before any subcommand touches tmux, the network, or a
|
||||
//! terminal. That lets the assertions tell "clap parsed it but the handler
|
||||
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
|
||||
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
|
||||
//! exit 1).
|
||||
//!
|
||||
//! The absence tests below only compile when the matching feature is off, so
|
||||
//! they run under CI's per-feature-subset matrix (`--no-default-features
|
||||
//! --features cli|daemon|tui`) rather than the default all-features build.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn bin() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
|
||||
}
|
||||
|
||||
fn bad_config() -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
|
||||
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
|
||||
f
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(bin())
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
|
||||
}
|
||||
|
||||
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
|
||||
/// config crate's error surfacing through — never clap's own usage-error
|
||||
/// page, which would mean the subcommand never reached its handler.
|
||||
fn assert_dispatched(out: &Output) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
|
||||
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
|
||||
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
|
||||
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
|
||||
}
|
||||
|
||||
/// Never reached a handler: clap rejected the subcommand outright because the
|
||||
/// feature that defines it is off. Only used by the `not(feature = "...")`
|
||||
/// tests below, which themselves only exist in a single-feature build.
|
||||
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
|
||||
fn assert_unrecognized(out: &Output, name: &str) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
|
||||
assert!(
|
||||
stderr.contains("unrecognized subcommand"),
|
||||
"stderr:\n{stderr}"
|
||||
);
|
||||
assert!(stderr.contains(name), "stderr:\n{stderr}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
#[test]
|
||||
fn list_dispatches_into_maestro_cli() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cli"))]
|
||||
#[test]
|
||||
fn list_is_unrecognized_without_cli_feature() {
|
||||
assert_unrecognized(&run(&["list"]), "list");
|
||||
}
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
#[test]
|
||||
fn daemon_dispatches_into_maestro_daemon() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "daemon"))]
|
||||
#[test]
|
||||
fn daemon_is_unrecognized_without_daemon_feature() {
|
||||
assert_unrecognized(&run(&["daemon"]), "daemon");
|
||||
}
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
#[test]
|
||||
fn tui_dispatches_into_maestro_tui() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
#[test]
|
||||
fn tui_is_unrecognized_without_tui_feature() {
|
||||
assert_unrecognized(&run(&["tui"]), "tui");
|
||||
}
|
||||
+12
-9
@@ -195,25 +195,28 @@ An unknown name is rejected as an `[tui].theme` configuration error.
|
||||
The full action vocabulary is the `Action` enum in
|
||||
`crates/maestro-tui/src/action.rs`.
|
||||
|
||||
## Structured sessions: `claude-code-acp`
|
||||
## Structured sessions: ACP adapters
|
||||
|
||||
A session spawned with `view: Structured` (#134) runs Claude Code as a child
|
||||
A session spawned with `view: Structured` runs Claude Code or Codex as a child
|
||||
of the daemon over the Agent Client Protocol instead of in a tmux pane. The
|
||||
daemon resolves the adapter binary, `claude-code-acp`, on its `PATH` at spawn
|
||||
time. Install it once per host:
|
||||
resolved session command selects the adapter binary, which must be on the
|
||||
session's `PATH` when the spawn request sets one, or the daemon's `PATH`
|
||||
otherwise. Install the adapter for each provider you use:
|
||||
|
||||
```sh
|
||||
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
|
||||
npm install -g --prefix ~/.local @agentclientprotocol/codex-acp
|
||||
```
|
||||
|
||||
That puts `claude-code-acp` in `~/.local/bin`, which must be on the daemon's
|
||||
`PATH` (a systemd user unit does not read your shell profile; set
|
||||
That puts `claude-code-acp` and `codex-acp` in `~/.local/bin`, which must be on
|
||||
the applicable `PATH` (a systemd user unit does not read your shell profile; set
|
||||
`Environment=PATH=…` in the unit or install somewhere already on it). The
|
||||
adapter needs a logged-in Claude Code (`claude /login`).
|
||||
Claude adapter needs a logged-in Claude Code (`claude /login`); Codex uses the
|
||||
Codex CLI's or adapter's configured authentication.
|
||||
|
||||
When the adapter is missing the spawn still creates the session, marked dead
|
||||
with the install command above as its activity detail, so the sidebar says
|
||||
what to run. The adapter is a child of the daemon: it ends with a `DELETE
|
||||
with the provider-specific install command as its activity detail, so clients
|
||||
say what to run. The adapter is a child of the daemon: it ends with a `DELETE
|
||||
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
|
||||
itself. A daemon restart ends an in-flight structured turn, but preserves its
|
||||
worktree, session record, and event-log transcript. The replacement daemon
|
||||
|
||||
@@ -160,6 +160,7 @@ The state word is never spelled out — the colour carries it.
|
||||
| ---------------- | --------- | ---------------- |
|
||||
| `Active` | `--live` | `active` |
|
||||
| `Idle` | `--muted` | `idle` |
|
||||
| `Starting` | `--muted` | `idle` |
|
||||
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||
| `Error` | `--danger`| `error` |
|
||||
|
||||
|
||||
+116
-44
@@ -16,10 +16,10 @@ does not exist yet, and nothing here describes it.
|
||||
|
||||
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;
|
||||
daemon is the ACP **client**; the provider adapter process is the ACP
|
||||
**agent**. It translates the provider's events 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.
|
||||
|
||||
@@ -35,32 +35,44 @@ session's worktree holds unsaved work — applies to a structured session
|
||||
exactly as it does to a terminal one; only what gets terminated differs (an
|
||||
ACP handle instead of a tmux pane).
|
||||
|
||||
## Choosing the view
|
||||
|
||||
`view` is fixed at spawn and never changes. Every client picks it the same
|
||||
way, from the resolved command alone
|
||||
(`maestro_protocol::display::supports_structured_view`): a command that
|
||||
resolves to Claude Code or Codex spawns **Structured** by default, anything
|
||||
else spawns **Terminal**. The operator can override the default — the web and
|
||||
TUI spawn forms show the checkbox ticked and let it be unticked, and
|
||||
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
|
||||
even for a command the CLI does not recognise and lets the daemon refuse it).
|
||||
Once the operator works the toggle, a later command change never re-ticks it
|
||||
under them; it can still be forced off when the new command has no adapter.
|
||||
|
||||
The daemon is the last word: a `Structured` request for a command with no
|
||||
adapter is refused there, whichever client sent it.
|
||||
|
||||
## The adapter
|
||||
|
||||
`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:
|
||||
The resolved structured command selects a provider adapter. Its binary is
|
||||
resolved by walking the session's `PATH` when the spawn request sets one, or
|
||||
the daemon's `PATH` otherwise, and taking the first executable with that name.
|
||||
|
||||
```sh
|
||||
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
|
||||
```
|
||||
| Command | Adapter binary | Install command | Provider detail |
|
||||
| --- | --- | --- | --- |
|
||||
| `claude` | `claude-code-acp` | `npm install -g --prefix ~/.local @zed-industries/claude-code-acp` | Needs a logged-in Claude Code. |
|
||||
| `codex` | `codex-acp` | `npm install -g --prefix ~/.local @agentclientprotocol/codex-acp` | Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration. |
|
||||
|
||||
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 selected install command is baked into `AcpError::AdapterMissing`. When an
|
||||
adapter cannot be found, spawn still creates a **Dead** session with that
|
||||
command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
|
||||
caveat). Provider authentication failures surface through the adapter's stderr
|
||||
tail in the same field.
|
||||
|
||||
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.
|
||||
slow machine to boot an adapter and its provider process, 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
|
||||
|
||||
@@ -69,17 +81,17 @@ 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. |
|
||||
| `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
|
||||
| `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
|
||||
| `terminal` | `false` | Providers run commands through their own tool surface. |
|
||||
|
||||
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.
|
||||
default; the daemon does not advertise support because neither supported
|
||||
adapter needs 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.
|
||||
@@ -96,6 +108,14 @@ 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.
|
||||
|
||||
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
||||
shell commands, file changes, MCP calls, web search, image generation/view,
|
||||
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
||||
those fold into the same eight rows. Codex's `available_commands_update` and
|
||||
`session_info_update`, like equivalent Claude updates, remain opaque
|
||||
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
||||
just as it is for Claude.
|
||||
|
||||
## The eight transcript row kinds
|
||||
|
||||
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
||||
@@ -106,14 +126,14 @@ 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`. |
|
||||
| `User` | The prompt accepted by Maestro's structured prompt endpoint, persisted before ACP dispatch. An adapter `user_message_chunk` is also supported for imported ACP history. | `user:<uuid>` for a daemon-recorded prompt; `user:<message_id>` or `user:<seq>` for an ACP chunk. |
|
||||
| `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>`, or `agent:<seq>` from the first chunk when no id is supplied. Later chunks for the same id—or consecutive anonymous text chunks—are appended into `data.content.text` in place. An empty anonymous chunk starts a fresh row, matching the turn marker emitted by adapters that omit message ids. |
|
||||
| `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / first anonymous `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` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
||||
|
||||
`Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the
|
||||
`agent-client-protocol` dependency's schema already deserializes (it
|
||||
@@ -147,7 +167,10 @@ choices, not daemon ones.
|
||||
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).
|
||||
`.active_tool_calls` / `.started_at` are derived, not renderer-computed).
|
||||
Ending a turn appends a durable `maestroTurnMeta` footer with its elapsed
|
||||
time, outcome, model, and context-window usage/delta. ACP does not expose
|
||||
provider billing-token counts, so these are explicitly context tokens.
|
||||
- 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.
|
||||
@@ -162,9 +185,31 @@ choices, not daemon ones.
|
||||
**A client decides:**
|
||||
|
||||
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
||||
diff view for `ToolDiff`, a tool card for `ToolCall`, a checklist for
|
||||
`Plan`, and a floor presentation for `Notice`.
|
||||
- How `responding` vs. `thinking` are distinguished visually, if at all.
|
||||
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; the TUI may show the current plan in its status line,
|
||||
while the web client deliberately has no stats or plan rail.
|
||||
- The web client groups two or more tool rows after the same `User` row into
|
||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||
appear *between* tool rows in the same turn are absorbed into the group
|
||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||
outside as the prominent final answer. While a turn is still active
|
||||
(responding or thinking), the most recent intermediate `AgentText` or
|
||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
||||
by the next text row when one arrives, and absorbed into the group when the
|
||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
||||
Opening the group reveals tools and intermediate text in chronological
|
||||
order, with the text rendered in a subdued style.
|
||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||
copy their complete source text when clicked or keyboard-activated.
|
||||
- The web client consumes `available_commands_update` notices as the live
|
||||
source for composer `/` autocomplete at any whitespace-delimited token under
|
||||
the caret. Completing a command replaces only that token. The client omits
|
||||
those state-only notices, along with `session_info_update`, from the visible
|
||||
transcript.
|
||||
- How live `responding`, `thinking`, tool activity, elapsed time, and context
|
||||
usage are drawn. The web client keeps these in a compact strip above the
|
||||
composer and renders each durable turn footer inline with the transcript.
|
||||
- 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.
|
||||
@@ -195,8 +240,9 @@ every row, once appended, is only ever patched or left alone.
|
||||
|
||||
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
|
||||
`PersistedEvent` envelope is what lets `User` and `Approval` rows share a cursor
|
||||
sequence with raw ACP JSON without pretending to be ACP traffic; the envelope
|
||||
also carries durable `Turn` footers). Two ways to
|
||||
read it:
|
||||
|
||||
- **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and
|
||||
@@ -245,7 +291,7 @@ pending decision, entirely on the daemon side:
|
||||
- **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`)
|
||||
restoring an `Approval` row from disk (`Projection::restore_row`)
|
||||
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
|
||||
@@ -279,6 +325,26 @@ pending decision, entirely on the daemon side:
|
||||
|
||||
## Prompt and cancel: bounded liveness
|
||||
|
||||
The web composer accepts up to ten files through its attachment picker, file
|
||||
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
|
||||
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
|
||||
be removed before sending; failed sends retain the draft and attachments.
|
||||
Pastes also stay inline when all ten attachment slots are occupied or the text
|
||||
exceeds the 20 MiB file limit.
|
||||
|
||||
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
|
||||
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
|
||||
Only live structured sessions accept uploads. Filenames are limited to 180
|
||||
bytes and cannot contain directory separators or control characters. Files get unique names and private
|
||||
file permissions in `<id>.attachments` beside the session sidecar. They persist
|
||||
across daemon restarts; removing a composer chip does not delete uploaded data.
|
||||
There is currently no automatic attachment cleanup.
|
||||
|
||||
The prompt includes each filename and host path so the adapter can read it
|
||||
through its own file tools, including images when the provider supports them.
|
||||
These references are persisted in the ordinary user transcript row. Attachment
|
||||
bytes are not embedded in ACP messages or transcript history.
|
||||
|
||||
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
|
||||
against each other through one per-session `operation` lock
|
||||
(`AcpEventStream::operation`), because ACP permission callbacks carry no
|
||||
@@ -287,9 +353,15 @@ 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
|
||||
- Web clients may collect a visible FIFO of operator messages while a turn is
|
||||
active. They submit each queued message only after the projected turn becomes
|
||||
idle; the queue is client-local and unsent messages do not survive a reload.
|
||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||
operator's input. If that append fails, the prompt is not dispatched. 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.
|
||||
queuing in the daemon — one active turn per session, never a hidden
|
||||
server-side 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
|
||||
@@ -313,8 +385,8 @@ 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
|
||||
readable) or a `{"maestroEvent": "user"|"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
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -148,7 +148,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
|
||||
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+14
-1
@@ -11,7 +11,8 @@
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"marked": "^18.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.11.6",
|
||||
@@ -1222,6 +1223,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.11",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
|
||||
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
+14
-3
@@ -6,7 +6,7 @@
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b tsconfig.build.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -16,12 +16,23 @@
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"marked": "^18.0.11",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"happy-dom": "^20.11.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.9"
|
||||
"vitest": "^2.1.9",
|
||||
"zustand": "^5.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+994
-11
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<div id="react-root" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RecentDirs,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
StructuredConfigRequest,
|
||||
StructuredPromptRequest,
|
||||
Uuid,
|
||||
} from "./types.js";
|
||||
@@ -93,6 +94,21 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Change one option advertised by a live ACP session. */
|
||||
export async function setStructuredConfig(
|
||||
id: Uuid,
|
||||
req: StructuredConfigRequest,
|
||||
): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredConfig(id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Report whether this client's pane is the one currently showing `id` — not
|
||||
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||
@@ -168,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
|
||||
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
body: file,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cancel a structured session's active ACP turn (#138). */
|
||||
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
|
||||
await jsonOrError(
|
||||
|
||||
+21
-2
@@ -64,6 +64,7 @@ export function createAttachConnection(
|
||||
<div class="attach-body"></div>
|
||||
`;
|
||||
|
||||
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
|
||||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
@@ -323,6 +324,9 @@ export function createAttachConnection(
|
||||
let ws: WebSocket;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
|
||||
// Follow it once per socket; later scrolling remains under the operator's control.
|
||||
let awaitingReplay = true;
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
@@ -342,6 +346,7 @@ export function createAttachConnection(
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
awaitingReplay = true;
|
||||
ws = new WebSocket(attachWsUrl(sessionId));
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
@@ -362,10 +367,20 @@ export function createAttachConnection(
|
||||
return;
|
||||
}
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(ev.data));
|
||||
const replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(ev.data), () => {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
} else if (ev.data instanceof Blob) {
|
||||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
||||
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
|
||||
void ev.data.arrayBuffer().then((b) => {
|
||||
const replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(b), () => {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -463,6 +478,10 @@ export function createAttachConnection(
|
||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||
// bar already take.
|
||||
sendInput,
|
||||
updateInfo(next: AttachInfo) {
|
||||
titleEl.innerHTML = paneTitleHtml(sessionId, next);
|
||||
titleEl.title = paneTitleAttr(sessionId, next);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
+4
-3
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
applyNav();
|
||||
};
|
||||
|
||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
||||
// out: there is no hover there, and a tap already opens the session.
|
||||
// Pointer hover previews only while Ctrl is held — plain hover was too
|
||||
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
||||
// unconditionally via applyNav.
|
||||
let hovered: string | null = null;
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
opts.onPreview?.(id);
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||
setHovered(row?.dataset.sessionId ?? null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
|
||||
// corners, full card height. It paints for every card, Idle included, and it
|
||||
// carries the accessible name of the state so status is never colour-only.
|
||||
|
||||
import { activityColorSlug } from "../../activity.js";
|
||||
import type { ActivityState } from "../../types.js";
|
||||
|
||||
export const STATE_WORDS: Record<string, string> = {
|
||||
active: "Active",
|
||||
idle: "Idle",
|
||||
"awaiting-input": "Awaiting input",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
/** Rail fill per state, and the motion the state earns. */
|
||||
const RAIL_CLASS: Record<string, string> = {
|
||||
active: "bg-live",
|
||||
idle: "bg-border-strong",
|
||||
"awaiting-input": "bg-warn rail-blink",
|
||||
error: "bg-danger rail-flash",
|
||||
};
|
||||
|
||||
/** Duration text inherits the rail's colour (docs/SIDEBAR.md). */
|
||||
export const DURATION_CLASS: Record<string, string> = {
|
||||
active: "text-live",
|
||||
idle: "text-muted",
|
||||
"awaiting-input": "text-warn",
|
||||
error: "text-danger",
|
||||
};
|
||||
|
||||
export function stateWordFor(activity: ActivityState | string | undefined): string {
|
||||
return STATE_WORDS[activityColorSlug(activity ?? "Idle")] ?? STATE_WORDS.idle;
|
||||
}
|
||||
|
||||
export function ActivityRail({ activity }: { activity: ActivityState | string | undefined }) {
|
||||
const slug = activityColorSlug(activity ?? "Idle");
|
||||
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
return (
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
|
||||
data-activity={slug}
|
||||
role="img"
|
||||
title={word}
|
||||
aria-label={word}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
|
||||
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
onSelect(): void;
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
const onDown = (e: Event) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("pointerdown", onDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("pointerdown", onDown, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
|
||||
item.danger ? "text-danger" : "text-ink"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// A collapsible directory in the sidebar tree. Only a *collapsed* header is a
|
||||
// keyboard-walk stop (docs/SIDEBAR.md); an expanded one is decoration in front
|
||||
// of the cards it holds, so it carries no highlight of its own.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { splitFolderLabel } from "./model.js";
|
||||
|
||||
export interface FolderNodeProps {
|
||||
name: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
collapsed: boolean;
|
||||
highlighted: boolean;
|
||||
onToggle(): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FolderNode(props: FolderNodeProps) {
|
||||
const { stem, last } = splitFolderLabel(props.name);
|
||||
return (
|
||||
<div className="tree-folder">
|
||||
<button
|
||||
type="button"
|
||||
className={`tree-folder-header flex w-full items-center gap-sp-1 py-[2px] text-fs-11 text-muted ${
|
||||
props.highlighted ? "ring-1 ring-accent-ring" : ""
|
||||
}`}
|
||||
style={{ paddingLeft: props.depth * 16 }}
|
||||
aria-expanded={!props.collapsed}
|
||||
data-nav-key={`folder:${props.path}`}
|
||||
data-highlighted={props.highlighted ? "true" : undefined}
|
||||
title={props.name}
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
{/* One glyph, rotated when collapsed, so the mark and the rotation
|
||||
never disagree about which way the folder points. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block transition-transform ${props.collapsed ? "-rotate-90" : ""}`}
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{stem && <span className="text-faint">{stem}/</span>}
|
||||
{last}
|
||||
</span>
|
||||
</button>
|
||||
{!props.collapsed && props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
|
||||
// confirmation names what the card last saw in the way — the daemon refuses an
|
||||
// unforced kill over such a tree, and saying so beforehand is cheaper than the
|
||||
// refusal. Cancel holds the focus so a stray Enter kills nothing.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { InTheWay } from "../../types.js";
|
||||
import { formatInTheWay } from "../../types.js";
|
||||
|
||||
export interface KillConfirmProps {
|
||||
name: string;
|
||||
blockers: InTheWay | null;
|
||||
/** Set when the daemon already refused an unforced kill: this pass forces. */
|
||||
force?: boolean;
|
||||
onConfirm(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
export function KillConfirm(props: KillConfirmProps) {
|
||||
const cancel = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => cancel.current?.focus(), []);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.force ? "Force kill session" : "Kill session"}
|
||||
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") props.onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
|
||||
<p className="text-fs-13 text-ink">
|
||||
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
|
||||
</p>
|
||||
{props.blockers && (
|
||||
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
|
||||
)}
|
||||
<div className="mt-sp-3 flex justify-end gap-sp-2">
|
||||
<button
|
||||
ref={cancel}
|
||||
type="button"
|
||||
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
{props.force ? "Force kill" : "Kill"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// docs/SIDEBAR.md: a session card is always two lines — the name, then the
|
||||
// adaptive meta line — with the activity rail in the gutter beside them.
|
||||
|
||||
import { useEffect, useRef, type MouseEvent } from "react";
|
||||
|
||||
import {
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
META_LINE_MIN_HOLD_MS,
|
||||
} from "../../activity.js";
|
||||
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
|
||||
import type { InTheWay, Session } from "../../types.js";
|
||||
import {
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
formatInTheWay,
|
||||
isProvisionalName,
|
||||
MAX_SESSION_NAME_LEN,
|
||||
normalizeSessionName,
|
||||
sessionBlockers,
|
||||
sessionName,
|
||||
} from "../../types.js";
|
||||
import { ActivityRail, DURATION_CLASS, stateWordFor } from "./ActivityRail.js";
|
||||
import { activityColorSlug } from "../../activity.js";
|
||||
|
||||
export interface SessionCardProps {
|
||||
session: Session;
|
||||
/** Nesting depth; the row's padding (and so the rail) shifts outward with it. */
|
||||
depth: number;
|
||||
/** Clock the duration is measured against — the sidebar ticks it. */
|
||||
now: number;
|
||||
highlighted: boolean;
|
||||
/** Showing in the focused pane. */
|
||||
active: boolean;
|
||||
/** Open in some pane, not the focused one. */
|
||||
open: boolean;
|
||||
previewing: boolean;
|
||||
renaming: boolean;
|
||||
onSelect(): void;
|
||||
onOpenMenu(x: number, y: number): void;
|
||||
onSpawnSimilar(): void;
|
||||
onKill(): void;
|
||||
onRenameCommit(name: string): void;
|
||||
onRenameCancel(): void;
|
||||
}
|
||||
|
||||
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
|
||||
* wins. Written as one ordered run so the precedence is the order it reads in. */
|
||||
function rowFill(p: SessionCardProps): string {
|
||||
if (p.active) return "bg-accent-fill";
|
||||
if (p.previewing) return "bg-preview-tint";
|
||||
if (p.open) return "bg-accent/[0.06]";
|
||||
if (p.highlighted) return "bg-preview-tint";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function SessionCard(props: SessionCardProps) {
|
||||
const { session: s, depth, now, renaming } = props;
|
||||
const name = sessionName(s);
|
||||
const running = s.status === "Running";
|
||||
const word = stateWordFor(s.activity);
|
||||
|
||||
return (
|
||||
<a
|
||||
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
|
||||
running ? "" : "opacity-60"
|
||||
}`}
|
||||
href={`#/attach/${encodeURIComponent(s.id)}`}
|
||||
style={{ paddingLeft: depth * 16 + 8 }}
|
||||
data-session-id={s.id}
|
||||
data-nav-key={`session:${s.id}`}
|
||||
data-status={running ? "running" : "dead"}
|
||||
data-highlighted={props.highlighted ? "true" : undefined}
|
||||
aria-label={`${name}, ${running ? "running" : "exited"}, ${word}`}
|
||||
onClick={(e) => {
|
||||
if (renaming) return;
|
||||
e.preventDefault();
|
||||
props.onSelect();
|
||||
}}
|
||||
onContextMenu={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onOpenMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
>
|
||||
<ActivityRail activity={s.activity} />
|
||||
<span className="flex min-w-0 items-center gap-sp-2">
|
||||
{renaming ? (
|
||||
<RenameField
|
||||
session={s}
|
||||
onCommit={props.onRenameCommit}
|
||||
onCancel={props.onRenameCancel}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-fs-13 ${
|
||||
isProvisionalName(s) ? "text-muted" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex flex-none items-center gap-[2px]">
|
||||
<button
|
||||
type="button"
|
||||
className="leaf-clone text-faint hover:text-ink"
|
||||
title="Spawn similar"
|
||||
aria-label={`Spawn similar to ${name}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onSpawnSimilar();
|
||||
}}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="leaf-kill text-faint hover:text-danger"
|
||||
title="Kill session"
|
||||
aria-label={`Kill ${name}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onKill();
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<MetaLine session={s} now={now} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`.
|
||||
* The line itself is unconditional; only its fragments are adaptive. */
|
||||
export function MetaLine({ session: s, now }: { session: Session; now: number }) {
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration =
|
||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||
const blockers = sessionBlockers(s);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="card-line2 flex min-w-0 items-center gap-sp-2 text-fs-11"
|
||||
data-activity={slug}
|
||||
>
|
||||
{duration && (
|
||||
<span className={`activity-duration flex-none ${DURATION_CLASS[slug] ?? "text-muted"}`}>
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
{s.activity_detail && (
|
||||
<span className="activity-detail min-w-0 flex-1 truncate text-faint">
|
||||
{s.activity_detail}
|
||||
</span>
|
||||
)}
|
||||
<CommandFragment command={s.command} />
|
||||
{blockers && <BlockedFragment blockers={blockers} />}
|
||||
{s.status === "Dead" && <span className="flex-none text-faint">exited</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: the card names the program only, as its tool mark when we
|
||||
* have one. The full command line stays in the tooltip either way. */
|
||||
function CommandFragment({ command }: { command: string }) {
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
return (
|
||||
<span
|
||||
className="leaf-cmd flex min-w-0 flex-none items-center gap-[4px] truncate text-faint"
|
||||
title={command}
|
||||
>
|
||||
{icon ? (
|
||||
<span className="contents" dangerouslySetInnerHTML={{ __html: icon }} />
|
||||
) : (
|
||||
<span className="truncate">{program}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Blocked work in diff notation — `+124 -18 ?3 ↑2`. Only `+` and `-` take
|
||||
* colour; the spelled-out breakdown survives as the tooltip. */
|
||||
function BlockedFragment({ blockers }: { blockers: InTheWay }) {
|
||||
const stats = blockerStats(blockers);
|
||||
return (
|
||||
<span
|
||||
className="leaf-blocked flex flex-none items-center gap-[4px] text-faint"
|
||||
title={`Kill will be refused unless forced — ${formatInTheWay(blockers)}`}
|
||||
>
|
||||
{stats.map((st) => (
|
||||
<span
|
||||
key={st.kind}
|
||||
data-stat={st.kind}
|
||||
className={
|
||||
st.kind === "add" ? "text-live" : st.kind === "del" ? "text-danger" : "text-muted"
|
||||
}
|
||||
>
|
||||
{st.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline rename (#125): Enter commits, Escape and blur cancel, an emptied
|
||||
* field clears the name so the command fallback resumes. It starts from the
|
||||
* session's own name, never from the command it falls back to. */
|
||||
function RenameField({
|
||||
session: s,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
session: Session;
|
||||
onCommit(name: string): void;
|
||||
onCancel(): void;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const settled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
className="leaf-rename min-w-0 flex-1 bg-surface-2 text-fs-13 text-ink"
|
||||
defaultValue={customSessionName(s) ?? ""}
|
||||
maxLength={MAX_SESSION_NAME_LEN}
|
||||
aria-label={`Rename ${sessionName(s)}`}
|
||||
// The card is an <a> the tree also drags from: without these, clicking
|
||||
// into the field navigates away or starts a drag.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (settled.current) return;
|
||||
settled.current = true;
|
||||
onCommit(normalizeSessionName(e.currentTarget.value));
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (settled.current) return;
|
||||
settled.current = true;
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (settled.current) return;
|
||||
settled.current = true;
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { Sidebar } from "./Sidebar.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/n/alpha",
|
||||
command: "hx src/",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...over,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
/** The card element for a session, by the `data-session-id` the row carries. */
|
||||
function card(id: string): HTMLElement {
|
||||
const el = document.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||
if (!el) throw new Error(`no card for ${id}`);
|
||||
return el;
|
||||
}
|
||||
|
||||
function tree(): HTMLElement {
|
||||
return document.querySelector<HTMLElement>(".tree")!;
|
||||
}
|
||||
|
||||
describe("SessionCard layout", () => {
|
||||
it("renders two lines: the name, then a meta line naming the program", () => {
|
||||
render(<Sidebar sessions={[session("alpha")]} />);
|
||||
|
||||
const row = card("alpha");
|
||||
expect(row.textContent).toContain("alpha");
|
||||
const meta = row.querySelector(".card-line2")!;
|
||||
// The program only — never its arguments.
|
||||
expect(meta.textContent).toContain("hx");
|
||||
expect(meta.textContent).not.toContain("src/");
|
||||
});
|
||||
|
||||
it("shows no duration under the 30s hold, and one above it", () => {
|
||||
const now = Date.parse("2026-08-24T10:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
|
||||
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
|
||||
|
||||
render(<Sidebar sessions={[fresh, held]} />);
|
||||
|
||||
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
|
||||
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
|
||||
render(
|
||||
<Sidebar
|
||||
sessions={[
|
||||
session("idle"),
|
||||
session("await", { activity: "AwaitingInput" }),
|
||||
session("err", { activity: "Error" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
|
||||
expect(rail("idle").dataset.activity).toBe("idle");
|
||||
expect(rail("idle").className).not.toContain("rail-");
|
||||
expect(rail("await").className).toContain("rail-blink");
|
||||
expect(rail("err").className).toContain("rail-flash");
|
||||
// Status never rests on colour alone.
|
||||
expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input");
|
||||
});
|
||||
|
||||
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
|
||||
render(
|
||||
<Sidebar
|
||||
sessions={[
|
||||
session("alpha", {
|
||||
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
|
||||
expect(blocked.textContent).toBe("+124-18?3");
|
||||
expect(blocked.title).toContain("2 uncommitted files");
|
||||
});
|
||||
|
||||
it("dims a provisional name and leaves a settled one alone", () => {
|
||||
render(
|
||||
<Sidebar
|
||||
sessions={[
|
||||
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
|
||||
session("user", { metadata: { name: "user", name_source: "User" } }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
|
||||
expect(card("user").textContent).toContain("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard walk", () => {
|
||||
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
|
||||
|
||||
it("j and k step the highlight through the cards", () => {
|
||||
render(<Sidebar sessions={two} />);
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
expect(card("a").dataset.highlighted).toBe("true");
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
expect(card("b").dataset.highlighted).toBe("true");
|
||||
fireEvent.keyDown(tree(), { key: "k" });
|
||||
expect(card("a").dataset.highlighted).toBe("true");
|
||||
});
|
||||
|
||||
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
|
||||
render(<Sidebar sessions={two} />);
|
||||
|
||||
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
|
||||
fireEvent.click(header);
|
||||
expect(header.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
expect(
|
||||
document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!.dataset.highlighted,
|
||||
).toBe("true");
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
||||
expect(card("a").dataset.highlighted).toBe("true");
|
||||
});
|
||||
|
||||
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onFocusPane = vi.fn();
|
||||
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
||||
expect(onSelect).toHaveBeenCalledWith("a");
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "Tab" });
|
||||
expect(onFocusPane).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews the session the walk lands on", () => {
|
||||
const onPreview = vi.fn();
|
||||
render(<Sidebar sessions={two} onPreview={onPreview} />);
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
expect(onPreview).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("x asks to kill the highlighted session and r renames it", () => {
|
||||
const onKill = vi.fn();
|
||||
render(<Sidebar sessions={two} onKill={onKill} />);
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
fireEvent.keyDown(tree(), { key: "x" });
|
||||
expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "r" });
|
||||
expect(card("a").querySelector(".leaf-rename")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("context menu", () => {
|
||||
it("offers rename, spawn similar and kill", () => {
|
||||
const onSpawnSimilar = vi.fn();
|
||||
const onKill = vi.fn();
|
||||
render(
|
||||
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(card("alpha"));
|
||||
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent);
|
||||
expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]);
|
||||
|
||||
fireEvent.click(screen.getByText("Spawn similar"));
|
||||
expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" }));
|
||||
});
|
||||
|
||||
it("renames in place: Enter commits the field, Escape cancels", () => {
|
||||
const onRename = vi.fn();
|
||||
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
|
||||
|
||||
fireEvent.contextMenu(card("alpha"));
|
||||
fireEvent.click(screen.getByText("Rename…"));
|
||||
|
||||
const input = card("alpha").querySelector<HTMLInputElement>(".leaf-rename")!;
|
||||
expect(input.value).toBe("alpha");
|
||||
fireEvent.change(input, { target: { value: " renamed " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onRename).toHaveBeenCalledWith("alpha", "renamed");
|
||||
expect(card("alpha").querySelector(".leaf-rename")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("filters the tree down to matching sessions", () => {
|
||||
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
|
||||
|
||||
expect(document.querySelector('[data-session-id="alpha"]')).toBeNull();
|
||||
expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("offers a spawn when there are no sessions at all", () => {
|
||||
const onSpawn = vi.fn();
|
||||
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
|
||||
|
||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
||||
expect(onSpawn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
// React port of the sidebar tree (catalog.ts). The contract it renders is
|
||||
// docs/SIDEBAR.md: two-line cards behind an activity rail, folder-grouped by
|
||||
// repository, walked with j/k.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { ApiRequestError, killSession, renameSession } from "../../api.js";
|
||||
import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js";
|
||||
import type { InTheWay, Session } from "../../types.js";
|
||||
import { sessionBlockers, sessionName } from "../../types.js";
|
||||
import { buildTree, type TreeNode } from "../../tree.js";
|
||||
import { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
|
||||
import { FolderNode } from "./FolderNode.js";
|
||||
import { KillConfirm } from "./KillConfirm.js";
|
||||
import { SessionCard } from "./SessionCard.js";
|
||||
import { SidebarSearch } from "./SidebarSearch.js";
|
||||
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
|
||||
import { useNow } from "./useNow.js";
|
||||
import "./sidebar.css";
|
||||
|
||||
export interface SidebarProps {
|
||||
/** Live catalog. Either this or `sessions` supplies the tree. */
|
||||
store?: CatalogStoreApi;
|
||||
/** Static session list — what tests and the design kit render from. */
|
||||
sessions?: Session[];
|
||||
/** The daemon's home dir, so cwds abbreviate to `~/…`. */
|
||||
homeDir?: string;
|
||||
/** Showing in the focused pane. */
|
||||
activeId?: string | null;
|
||||
/** Open in some pane, focused one included. */
|
||||
openIds?: ReadonlySet<string>;
|
||||
/** Previewing (docs/PREVIEW.md) — a view, never durable state. */
|
||||
previewId?: string | null;
|
||||
onSelect?(id: string): void;
|
||||
/** The highlight moved onto a session, or off every session. */
|
||||
onPreview?(id: string | null): void;
|
||||
onSpawnSimilar?(session: Session): void;
|
||||
onSpawn?(): void;
|
||||
/** Fast-forward spawn (`N`). */
|
||||
onSpawnDefaults?(): void;
|
||||
/** Tab: hand the keyboard back to the pane. */
|
||||
onFocusPane?(): void;
|
||||
onHelp?(): void;
|
||||
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
|
||||
onKill?(session: Session): void;
|
||||
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
|
||||
onRename?(id: string, name: string): void;
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
interface KillState {
|
||||
session: Session;
|
||||
blockers: InTheWay | null;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const sessions = useSessions(props);
|
||||
const now = useNow();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());
|
||||
const [navKey, setNavKey] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
const [kill, setKill] = useState<KillState | null>(null);
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tree = useMemo(
|
||||
() => buildTree(filterSessions(sessions, query), props.homeDir),
|
||||
[sessions, query, props.homeDir],
|
||||
);
|
||||
const rows = useMemo(() => navRows(tree, collapsed), [tree, collapsed]);
|
||||
|
||||
// A row that went away — session killed, folder collapsed over it — drops
|
||||
// the highlight rather than leaving it pointing at nothing.
|
||||
useEffect(() => {
|
||||
if (navKey !== null && !rows.some((r) => r.key === navKey)) setNavKey(null);
|
||||
}, [rows, navKey]);
|
||||
|
||||
const preview = props.onPreview;
|
||||
const previewOf = useCallback(
|
||||
(key: string | null) => {
|
||||
preview?.(key?.startsWith("session:") ? key.slice("session:".length) : null);
|
||||
},
|
||||
[preview],
|
||||
);
|
||||
|
||||
const moveNav = useCallback(
|
||||
(delta: number) => {
|
||||
if (rows.length === 0) return;
|
||||
const cur = rows.findIndex((r) => r.key === navKey);
|
||||
// Nothing highlighted: start one off the end being walked from, so the
|
||||
// first step lands on the first (or last) row.
|
||||
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
|
||||
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
|
||||
setNavKey(next.key);
|
||||
previewOf(next.key);
|
||||
},
|
||||
[rows, navKey, previewOf],
|
||||
);
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder and
|
||||
* land on the first row it was hiding. */
|
||||
const activateNav = useCallback(() => {
|
||||
const row = rows.find((r) => r.key === navKey);
|
||||
if (!row) return;
|
||||
if (row.kind === "session") {
|
||||
props.onSelect?.(row.id);
|
||||
return;
|
||||
}
|
||||
const expanded = new Set(collapsed);
|
||||
expanded.delete(row.path);
|
||||
setCollapsed(expanded);
|
||||
const revealed = firstRowUnder(tree, expanded, row.path);
|
||||
setNavKey(revealed?.key ?? null);
|
||||
}, [rows, navKey, collapsed, tree, props]);
|
||||
|
||||
const beginKill = useCallback(
|
||||
(s: Session) => {
|
||||
if (props.onKill) {
|
||||
props.onKill(s);
|
||||
return;
|
||||
}
|
||||
setKill({ session: s, blockers: sessionBlockers(s), force: false });
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const commitRename = useCallback(
|
||||
(id: string, name: string) => {
|
||||
setRenamingId(null);
|
||||
if (props.onRename) props.onRename(id, name);
|
||||
else void renameSession(id, name).catch(() => {});
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
/** The session behind the highlighted row, when the highlight is on a card. */
|
||||
const highlightedSession = useCallback((): Session | undefined => {
|
||||
const row = rows.find((r) => r.key === navKey);
|
||||
if (row?.kind !== "session") return undefined;
|
||||
return sessions.find((s) => s.id === row.id);
|
||||
}, [rows, navKey, sessions]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
// The inline rename field owns its own keys; the walk stays out of it.
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
switch (e.key) {
|
||||
case "j":
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
moveNav(1);
|
||||
return;
|
||||
case "k":
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
moveNav(-1);
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activateNav();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
props.onFocusPane?.();
|
||||
return;
|
||||
case "c": {
|
||||
const row = rows.find((r) => r.key === navKey);
|
||||
if (row?.kind !== "folder") return;
|
||||
e.preventDefault();
|
||||
toggleFolder(row.path);
|
||||
return;
|
||||
}
|
||||
case "r": {
|
||||
const s = highlightedSession();
|
||||
if (!s) return;
|
||||
e.preventDefault();
|
||||
setRenamingId(s.id);
|
||||
return;
|
||||
}
|
||||
case "x": {
|
||||
const s = highlightedSession();
|
||||
if (!s) return;
|
||||
e.preventDefault();
|
||||
beginKill(s);
|
||||
return;
|
||||
}
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
props.onSpawn?.();
|
||||
return;
|
||||
case "N":
|
||||
e.preventDefault();
|
||||
props.onSpawnDefaults?.();
|
||||
return;
|
||||
case "?":
|
||||
e.preventDefault();
|
||||
props.onHelp?.();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = (s: Session): ContextMenuItem[] => [
|
||||
{ label: "Rename…", onSelect: () => setRenamingId(s.id) },
|
||||
{ label: "Spawn similar", onSelect: () => props.onSpawnSimilar?.(s) },
|
||||
{ label: "Kill session", danger: true, onSelect: () => beginKill(s) },
|
||||
];
|
||||
|
||||
const renderNode = (node: TreeNode, depth: number): React.ReactNode => {
|
||||
if (node.kind === "leaf") {
|
||||
const s = node.session;
|
||||
return (
|
||||
<SessionCard
|
||||
key={s.id}
|
||||
session={s}
|
||||
depth={depth}
|
||||
now={now}
|
||||
highlighted={navKey === sessionKey(s.id)}
|
||||
active={props.activeId === s.id}
|
||||
open={(props.openIds?.has(s.id) ?? false) && props.activeId !== s.id}
|
||||
previewing={props.previewId === s.id && props.activeId !== s.id}
|
||||
renaming={renamingId === s.id}
|
||||
onSelect={() => props.onSelect?.(s.id)}
|
||||
onOpenMenu={(x, y) => setMenu({ x, y, session: s })}
|
||||
onSpawnSimilar={() => props.onSpawnSimilar?.(s)}
|
||||
onKill={() => beginKill(s)}
|
||||
onRenameCommit={(name) => commitRename(s.id, name)}
|
||||
onRenameCancel={() => setRenamingId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!node.name) {
|
||||
return node.children.map((c) => renderNode(c, depth));
|
||||
}
|
||||
return (
|
||||
<FolderNode
|
||||
key={node.path}
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
depth={depth}
|
||||
collapsed={collapsed.has(node.path)}
|
||||
highlighted={navKey === `folder:${node.path}`}
|
||||
onToggle={() => toggleFolder(node.path)}
|
||||
>
|
||||
{node.children.map((c) => renderNode(c, depth + 1))}
|
||||
</FolderNode>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sidebar flex h-full min-h-0 flex-col bg-surface">
|
||||
<SidebarSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onDismiss={() => treeRef.current?.focus()}
|
||||
/>
|
||||
<div
|
||||
ref={treeRef}
|
||||
className="tree min-h-0 flex-1 overflow-y-auto outline-none"
|
||||
tabIndex={-1}
|
||||
role="tree"
|
||||
aria-label="Sessions"
|
||||
onKeyDown={onKeyDown}
|
||||
// The keyboard walk's preview belongs to the list holding the
|
||||
// keyboard: once it doesn't, the pane goes back to what was opened.
|
||||
onBlur={() => preview?.(null)}
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState onSpawn={() => props.onSpawn?.()} />
|
||||
) : (
|
||||
renderNode(tree, 0)
|
||||
)}
|
||||
</div>
|
||||
{menu && (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
items={menuItems(menu.session)}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{kill && (
|
||||
<KillConfirm
|
||||
name={sessionName(kill.session)}
|
||||
blockers={kill.blockers}
|
||||
force={kill.force}
|
||||
onCancel={() => setKill(null)}
|
||||
onConfirm={() => {
|
||||
const { session: s, force } = kill;
|
||||
setKill(null);
|
||||
void killSession(s.id, force).catch((e: unknown) => {
|
||||
// The daemon refuses an unforced kill over a worktree holding
|
||||
// unsaved work; that refusal needs its own prompt
|
||||
// (docs/WORKTREES.md).
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
setKill({
|
||||
session: s,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tree's one empty state: no sessions at all. */
|
||||
function EmptyState({ onSpawn }: { onSpawn(): void }) {
|
||||
return (
|
||||
<div className="empty flex flex-col items-center gap-sp-2 p-sp-6 text-center">
|
||||
<div aria-hidden="true" className="text-fs-20 text-faint">
|
||||
+
|
||||
</div>
|
||||
<div className="text-fs-13 text-ink">No sessions yet</div>
|
||||
<p className="text-fs-12 text-muted">Start your first session — it'll show up here.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-r-1 bg-accent px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
|
||||
onClick={onSpawn}
|
||||
>
|
||||
+ Spawn session
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sessions from the store when one is wired, else the static prop. */
|
||||
function useSessions(props: SidebarProps): Session[] {
|
||||
const fromStore = useStore(
|
||||
// `useStore` needs a store every render; the no-store case is served by a
|
||||
// stable empty stand-in whose result is thrown away below.
|
||||
props.store ?? EMPTY_STORE,
|
||||
(s: CatalogStore) => s.sessions,
|
||||
);
|
||||
return useMemo(
|
||||
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
|
||||
[props.store, props.sessions, fromStore],
|
||||
);
|
||||
}
|
||||
|
||||
// One frozen state object, not a fresh one per call: `useStore` reads the
|
||||
// snapshot on every render and a new Map each time would never settle.
|
||||
const EMPTY_STATE = { sessions: new Map<string, Session>() };
|
||||
const EMPTY_STORE = {
|
||||
getState: () => EMPTY_STATE,
|
||||
getInitialState: () => EMPTY_STATE,
|
||||
setState: () => {},
|
||||
subscribe: () => () => {},
|
||||
} as unknown as CatalogStoreApi;
|
||||
@@ -0,0 +1,34 @@
|
||||
// The sidebar's search field: a substring filter over the tree (name, command
|
||||
// and cwd). Escape clears it and hands the keyboard back to the list.
|
||||
|
||||
export interface SidebarSearchProps {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
/** Escape on an already-empty field — the list wants the keyboard back. */
|
||||
onDismiss?(): void;
|
||||
}
|
||||
|
||||
export function SidebarSearch({ value, onChange, onDismiss }: SidebarSearchProps) {
|
||||
return (
|
||||
<header className="sidebar-search p-sp-2">
|
||||
<input
|
||||
type="search"
|
||||
className="search w-full rounded-r-1 border border-border bg-surface-2 px-sp-2 py-sp-1 text-fs-12 text-ink"
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
if (value) onChange("");
|
||||
else onDismiss?.();
|
||||
}}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Sidebar, type SidebarProps } from "./Sidebar.js";
|
||||
export { SessionCard, type SessionCardProps } from "./SessionCard.js";
|
||||
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
|
||||
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
|
||||
export { ActivityRail } from "./ActivityRail.js";
|
||||
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
|
||||
export { KillConfirm } from "./KillConfirm.js";
|
||||
export * from "./model.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildTree } from "../../tree.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import { filterSessions, firstRowUnder, navRows, splitFolderLabel } from "./model.js";
|
||||
|
||||
function session(id: string, cwd: string, command = "claude"): Session {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
command,
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("navRows", () => {
|
||||
const tree = buildTree([session("a", "/one"), session("b", "/two")], undefined);
|
||||
|
||||
it("stops on every card and skips expanded folder headers", () => {
|
||||
expect(navRows(tree, new Set()).map((r) => r.key)).toEqual(["session:a", "session:b"]);
|
||||
});
|
||||
|
||||
it("stops on a collapsed folder header instead of the cards it hides", () => {
|
||||
expect(navRows(tree, new Set(["one"])).map((r) => r.key)).toEqual([
|
||||
"folder:one",
|
||||
"session:b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the first row an expanded folder reveals", () => {
|
||||
expect(firstRowUnder(tree, new Set(), "one")?.key).toBe("session:a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitFolderLabel", () => {
|
||||
it("keeps a shallow path whole", () => {
|
||||
expect(splitFolderLabel("~/maestro")).toEqual({ stem: null, last: "~/maestro" });
|
||||
});
|
||||
|
||||
it("initials the leading segments of a deep path", () => {
|
||||
expect(splitFolderLabel("~/git.naps.pt/naps62/maestro")).toEqual({
|
||||
stem: "~/g/n",
|
||||
last: "maestro",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterSessions", () => {
|
||||
const all = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
|
||||
|
||||
it("returns everything for a blank query", () => {
|
||||
expect(filterSessions(all, " ")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches the cwd and the command, case-insensitively", () => {
|
||||
expect(filterSessions(all, "ALPHA").map((s) => s.id)).toEqual(["a"]);
|
||||
expect(filterSessions(all, "bash").map((s) => s.id)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Pure model helpers behind the React sidebar. Kept out of the components so
|
||||
// the walk order and the folder label can be tested without a DOM.
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { sessionCwd, sessionName } from "../../types.js";
|
||||
import type { TreeNode } from "../../tree.js";
|
||||
|
||||
/** A row the keyboard walk can land on (docs/SIDEBAR.md "Keyboard walk"):
|
||||
* every session card, and a folder header only while it is collapsed. */
|
||||
export type NavRow =
|
||||
| { key: string; kind: "folder"; path: string }
|
||||
| { key: string; kind: "session"; id: string };
|
||||
|
||||
export function folderKey(path: string): string {
|
||||
return `folder:${path}`;
|
||||
}
|
||||
|
||||
export function sessionKey(id: string): string {
|
||||
return `session:${id}`;
|
||||
}
|
||||
|
||||
/** The walkable rows in visual order. An expanded folder contributes nothing
|
||||
* itself — its cards are one row away — while a collapsed one is the only row
|
||||
* it has, so skipping it would leave the folder unreachable. */
|
||||
export function navRows(root: TreeNode, collapsed: ReadonlySet<string>): NavRow[] {
|
||||
const out: NavRow[] = [];
|
||||
const walk = (node: TreeNode): void => {
|
||||
if (node.kind === "leaf") {
|
||||
out.push({ key: sessionKey(node.session.id), kind: "session", id: node.session.id });
|
||||
return;
|
||||
}
|
||||
if (node.name && collapsed.has(node.path)) {
|
||||
out.push({ key: folderKey(node.path), kind: "folder", path: node.path });
|
||||
return;
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
for (const child of root.kind === "folder" ? root.children : [root]) walk(child);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The first walkable row inside `path`, i.e. where the highlight lands when
|
||||
* Enter expands that folder. */
|
||||
export function firstRowUnder(
|
||||
root: TreeNode,
|
||||
collapsed: ReadonlySet<string>,
|
||||
path: string,
|
||||
): NavRow | undefined {
|
||||
const found = findFolder(root, path);
|
||||
if (!found) return undefined;
|
||||
return navRows({ ...found, name: "" }, collapsed)[0];
|
||||
}
|
||||
|
||||
function findFolder(node: TreeNode, path: string): Extract<TreeNode, { kind: "folder" }> | null {
|
||||
if (node.kind === "leaf") return null;
|
||||
if (node.path === path && node.name) return node;
|
||||
for (const child of node.children) {
|
||||
const hit = findFolder(child, path);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Structured form of `abbreviateFolderName`: a deep path shows its leading
|
||||
* segments as initials so the tail stays readable in a narrow sidebar. */
|
||||
export function splitFolderLabel(name: string): { stem: string | null; last: string } {
|
||||
const parts = name.split("/");
|
||||
if (parts.length <= 2) return { stem: null, last: name };
|
||||
const last = parts[parts.length - 1];
|
||||
const stem = parts
|
||||
.slice(0, -1)
|
||||
.map((p) => (p === "~" ? "~" : (Array.from(p)[0] ?? "")))
|
||||
.join("/");
|
||||
return { stem, last };
|
||||
}
|
||||
|
||||
/** Case-insensitive substring filter over a session's name, command and cwd —
|
||||
* what the sidebar's own search field narrows the tree with. */
|
||||
export function filterSessions(sessions: Session[], query: string): Session[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return sessions;
|
||||
return sessions.filter((s) => {
|
||||
const cwd = sessionCwd(s) ?? "";
|
||||
return (
|
||||
sessionName(s).toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q) ||
|
||||
cwd.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Motion for the activity rail (docs/SIDEBAR.md): a hard blink while
|
||||
AwaitingInput, a fast flash while Error. Active and Idle are solid and
|
||||
still — colour alone is the signal. Kept as real CSS rather than Tailwind
|
||||
utilities because both need @keyframes of their own. */
|
||||
|
||||
@keyframes maestro-rail-blink {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
50.01%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes maestro-rail-flash {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
45% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rail-blink {
|
||||
animation: maestro-rail-blink 1.2s steps(1) infinite;
|
||||
}
|
||||
|
||||
.rail-flash {
|
||||
animation: maestro-rail-flash 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rail-blink,
|
||||
.rail-flash {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** A clock that ticks at least every `intervalMs` while the page is visible.
|
||||
* docs/SIDEBAR.md asks for a re-render at least every 30s so the duration's
|
||||
* cutover points land within one tick. */
|
||||
export function useNow(intervalMs = 30_000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
setNow(Date.now());
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApprovalView } from "./ApprovalView.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const ALL_OPTIONS = [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
|
||||
{ optionId: "r1", name: "Reject", kind: "reject_once" as const },
|
||||
];
|
||||
|
||||
function buttons(): string[] {
|
||||
return [...document.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("ApprovalView", () => {
|
||||
it("asks the operator with the three decisions the daemon exposes", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ toolCall: { title: "Write(a.ts)", locations: [{ path: "/repo/a.ts" }] }, options: ALL_OPTIONS }}
|
||||
pending
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(buttons()).toEqual(["Allow once", "Allow for session", "Deny"]);
|
||||
expect(screen.getByText("Write(a.ts)")).toBeTruthy();
|
||||
expect(screen.getByText("/repo/a.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("submits the decision the operator picked", async () => {
|
||||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
|
||||
fireEvent.click(screen.getByText("Deny"));
|
||||
await waitFor(() => expect(onDecide).toHaveBeenCalledWith("deny"));
|
||||
});
|
||||
|
||||
it("disables every button once one is pressed, so a decision is sent once", () => {
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={() => new Promise(() => {})} />);
|
||||
fireEvent.click(screen.getByText("Allow once"));
|
||||
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
|
||||
expect(all.every((b) => b.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it("restores the controls when the decision failed and this approval is still pending", async () => {
|
||||
const onDecide = vi.fn().mockRejectedValue(new Error("nope"));
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
|
||||
fireEvent.click(screen.getByText("Allow once"));
|
||||
await waitFor(() => {
|
||||
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
|
||||
expect(all.every((b) => !b.disabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("offers no button for a decision the request cannot back", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ options: [{ optionId: "a1", name: "Allow once", kind: "allow_once" }] }}
|
||||
pending
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(buttons()).toEqual(["Allow once"]);
|
||||
});
|
||||
|
||||
it("says so when the request offers no decision at all", () => {
|
||||
render(<ApprovalView request={{ options: [] }} pending onDecide={() => Promise.resolve()} />);
|
||||
expect(screen.getByText("no decision offered")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a resolved approval as a one-liner, with no controls", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ toolCall: { title: "Write(a.ts)" }, options: ALL_OPTIONS }}
|
||||
pending={false}
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Write(a.ts) — approved")).toBeTruthy();
|
||||
expect(document.querySelector(".st-approval-acts")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// An `Approval` row. The daemon exposes exactly three decisions
|
||||
// (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
|
||||
// them. A decision the request offers no matching ACP option for is not shown
|
||||
// — its button would "allow" something that silently resolves `Cancelled`.
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AcpPermissionOption, AcpToolCallLike, StructuredApprovalDecision } from "../../types.js";
|
||||
import { offeredDecisions, toolKindLabel } from "./model.js";
|
||||
|
||||
const SPECS: [StructuredApprovalDecision, string, string][] = [
|
||||
["allow_once", "Allow once", "primary"],
|
||||
["allow_for_session", "Allow for session", ""],
|
||||
["deny", "Deny", "deny"],
|
||||
];
|
||||
|
||||
export interface ApprovalViewProps {
|
||||
/** The `RequestPermissionRequest` the row carries — serialized without its
|
||||
* nonce, which never leaves the daemon's live wire. */
|
||||
request: { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
/** Whether this is the one live approval. A restored/answered row is not. */
|
||||
pending: boolean;
|
||||
/** Resolves once the decision reached the daemon; rejects only while this
|
||||
* approval is still the pending one, so the buttons can come back. */
|
||||
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ApprovalView({ request, pending, onDecide }: ApprovalViewProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toolCall = request.toolCall ?? {};
|
||||
const what = toolCall.title ?? toolKindLabel(toolCall.kind);
|
||||
|
||||
// Resolved approvals render as a compact one-liner inside a tool group (or
|
||||
// are dropped entirely when standalone — see model.ts's transcriptItems).
|
||||
if (!pending) {
|
||||
return <div className="st-row st-row-approval st-approval-compact">{`${what} — approved`}</div>;
|
||||
}
|
||||
|
||||
const offered = offeredDecisions(request.options ?? []);
|
||||
const path = toolCall.locations?.[0]?.path;
|
||||
|
||||
const decide = (decision: StructuredApprovalDecision) => {
|
||||
setBusy(true);
|
||||
onDecide(decision).catch(() => {
|
||||
// Rejects only while this approval is still pending — restore the
|
||||
// controls so the operator can retry. The error itself is surfaced by
|
||||
// the pane's banner.
|
||||
setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="st-row st-row-approval">
|
||||
<div className="st-approval">
|
||||
<div className="st-approval-title">
|
||||
<span aria-hidden="true">⚠</span> permission requested
|
||||
</div>
|
||||
<div className="st-approval-what">{what}</div>
|
||||
{path && <div className="st-approval-path">{path}</div>}
|
||||
{offered.length > 0 ? (
|
||||
<div className="st-approval-acts">
|
||||
{SPECS.filter(([decision]) => offered.includes(decision)).map(([decision, label, cls]) => (
|
||||
<button
|
||||
key={decision}
|
||||
type="button"
|
||||
className={cls ? `act ${cls}` : "act"}
|
||||
disabled={busy}
|
||||
onClick={() => decide(decision)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="st-approval-resolved">no decision offered</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// The composer: draft, attachments, `/` command autocomplete off the live
|
||||
// `available_commands_update` notices, the session-settings menu, and the
|
||||
// send/cancel pair. Sending is the pane's job — this only hands it a draft and
|
||||
// the files that go with it.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent,
|
||||
type DragEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
|
||||
import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js";
|
||||
import {
|
||||
applyCommand,
|
||||
commandTokenAt,
|
||||
filterCommands,
|
||||
orderConfigOptions,
|
||||
type AvailableCommand,
|
||||
} from "./model.js";
|
||||
|
||||
/** Per-message ceilings the daemon enforces on uploads
|
||||
* (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */
|
||||
export const MAX_ATTACHMENTS = 10;
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
/** Pasted text at or above either bound becomes a `.txt` attachment instead of
|
||||
* going inline. */
|
||||
const PASTE_TO_FILE_CHARS = 4_000;
|
||||
const PASTE_TO_FILE_LINES = 40;
|
||||
|
||||
export interface ComposerProps {
|
||||
/** Files chosen but not yet sent. Owned by the pane so a failed send can put
|
||||
* them back. */
|
||||
attachments: File[];
|
||||
onAttachmentsChange(files: File[]): void;
|
||||
commands: AvailableCommand[];
|
||||
configOptions: AcpSessionConfigOption[];
|
||||
/** A turn is in flight — the cancel control replaces nothing, it appears
|
||||
* beside send (a queued message is still allowed). */
|
||||
turnActive: boolean;
|
||||
/** An upload/POST is in flight: the attachment controls are frozen. */
|
||||
uploading: boolean;
|
||||
configBusy: boolean;
|
||||
/** Restored into the box when a send fails. */
|
||||
draft: string;
|
||||
onDraftChange(draft: string): void;
|
||||
onSubmit(text: string, attachments: File[]): void;
|
||||
onCancel(): void;
|
||||
onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void;
|
||||
onError(message: string): void;
|
||||
menuId: string;
|
||||
}
|
||||
|
||||
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
|
||||
return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice]));
|
||||
}
|
||||
|
||||
function currentConfigLabel(option: AcpSessionConfigOption): string {
|
||||
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
|
||||
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
|
||||
?? String(option.currentValue);
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
attachments,
|
||||
onAttachmentsChange,
|
||||
commands,
|
||||
configOptions,
|
||||
turnActive,
|
||||
uploading,
|
||||
configBusy,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onConfigChange,
|
||||
onError,
|
||||
menuId,
|
||||
}: ComposerProps) {
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const configRef = useRef<HTMLDivElement | null>(null);
|
||||
const [caret, setCaret] = useState(0);
|
||||
const [activeCommand, setActiveCommand] = useState(0);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
/** Escape closes the menu until the draft or caret moves again. */
|
||||
const [menuDismissed, setMenuDismissed] = useState(false);
|
||||
const pasteCount = useRef(0);
|
||||
|
||||
// Grow with the draft, up to the CSS max-height; past that it scrolls.
|
||||
useLayoutEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.style.height = "auto";
|
||||
input.style.overflowY = "hidden";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
if (input.scrollHeight > 160) input.style.overflowY = "auto";
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configOpen) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [configOpen]);
|
||||
|
||||
const token = commandTokenAt(draft, caret, caret);
|
||||
const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : [];
|
||||
const menuOpen = matches.length > 0 && !menuDismissed;
|
||||
const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1;
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (uploading) return;
|
||||
const next = [...attachments];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||
} else if (next.length >= MAX_ATTACHMENTS) {
|
||||
onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`);
|
||||
} else {
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
onAttachmentsChange(next);
|
||||
},
|
||||
[attachments, onAttachmentsChange, onError, uploading],
|
||||
);
|
||||
|
||||
const syncCaret = () => {
|
||||
setMenuDismissed(false);
|
||||
setCaret(inputRef.current?.selectionStart ?? 0);
|
||||
};
|
||||
|
||||
const chooseCommand = (command: AvailableCommand) => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd);
|
||||
if (!current) return;
|
||||
const applied = applyCommand(draft, current, command.name);
|
||||
onDraftChange(applied.value);
|
||||
setActiveCommand(0);
|
||||
// The caret has to move after React has written the new value.
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
input.setSelectionRange(applied.cursor, applied.cursor);
|
||||
setCaret(applied.cursor);
|
||||
});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if ((!draft.trim() && attachments.length === 0) || uploading) return;
|
||||
onSubmit(draft, attachments);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (menuOpen) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||||
setActiveCommand((index) => (index + direction + matches.length) % matches.length);
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
chooseCommand(matches[activeIndex]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMenuDismissed(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard || uploading) return;
|
||||
if (clipboard.files.length) {
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(clipboard.files));
|
||||
return;
|
||||
}
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return;
|
||||
// No slot for it, or too big to upload: leave it inline rather than
|
||||
// dropping it on the floor.
|
||||
if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return;
|
||||
event.preventDefault();
|
||||
addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]);
|
||||
};
|
||||
|
||||
const ordered = orderConfigOptions(configOptions);
|
||||
const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
|
||||
|
||||
return (
|
||||
<form
|
||||
className="structured-composer"
|
||||
onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
onDragOver={(event: DragEvent) => {
|
||||
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
||||
}}
|
||||
onDrop={(event: DragEvent) => {
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(event.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="structured-attach"
|
||||
aria-label="Attach files"
|
||||
title="Attach files (up to 20 MiB each)"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
className="structured-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
disabled={uploading}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="structured-input-wrap">
|
||||
<div className="structured-attachments" aria-label="Attachments" aria-live="polite" hidden={attachments.length === 0}>
|
||||
{attachments.map((file, index) => (
|
||||
<span key={`${file.name}-${index}`} className="structured-attachment">
|
||||
<span title={`${file.name} (${file.size.toLocaleString()} bytes)`}>{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
disabled={uploading}
|
||||
onClick={() => onAttachmentsChange(attachments.filter((_, i) => i !== index))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div id={menuId} className="structured-command-menu" role="listbox" hidden={!menuOpen}>
|
||||
{matches.map((command, index) => (
|
||||
<div
|
||||
key={command.name}
|
||||
id={`${menuId}-option-${index}`}
|
||||
className="structured-command-option"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => chooseCommand(command)}
|
||||
>
|
||||
<div className="structured-command-title">
|
||||
<span className="structured-command-name">{`/${command.name}`}</span>
|
||||
<span className="structured-command-hint">{command.input?.hint ?? ""}</span>
|
||||
</div>
|
||||
<div className="structured-command-description">
|
||||
{command.description.replace(/\s+/g, " ").trim()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="structured-input"
|
||||
rows={1}
|
||||
placeholder="Reply, or / for commands…"
|
||||
autoComplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls={menuId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
onDraftChange(event.target.value);
|
||||
setActiveCommand(0);
|
||||
setMenuDismissed(false);
|
||||
setCaret(event.target.selectionStart ?? event.target.value.length);
|
||||
}}
|
||||
onClick={syncCaret}
|
||||
onSelect={syncCaret}
|
||||
onKeyUp={(event) => {
|
||||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) syncCaret();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
</div>
|
||||
<div className="structured-config" ref={configRef} hidden={ordered.length === 0}>
|
||||
<button
|
||||
type="button"
|
||||
className="structured-config-trigger"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={configOpen}
|
||||
aria-label={`Session settings. ${summary.replaceAll("\n", ". ")}`}
|
||||
title={summary}
|
||||
disabled={configBusy}
|
||||
onClick={() => setConfigOpen((open) => !open)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="structured-config-menu"
|
||||
role="group"
|
||||
aria-label="Session settings"
|
||||
hidden={!configOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setConfigOpen(false);
|
||||
configRef.current?.querySelector<HTMLButtonElement>(".structured-config-trigger")?.focus();
|
||||
}}
|
||||
>
|
||||
{ordered.map((option) => (
|
||||
<label key={option.id} className="structured-config-row" title={option.description ?? undefined}>
|
||||
<span className="structured-config-name">{option.name}</span>
|
||||
{option.type === "select" ? (
|
||||
<select
|
||||
className="structured-config-select"
|
||||
disabled={configBusy}
|
||||
value={String(option.currentValue)}
|
||||
onChange={(event) => onConfigChange(option, event.target.value)}
|
||||
>
|
||||
{configChoices(option).map((choice) => (
|
||||
<option key={choice.value} value={choice.value} title={choice.description ?? ""}>
|
||||
{choice.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : option.type === "boolean" ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="structured-config-toggle"
|
||||
disabled={configBusy}
|
||||
checked={option.currentValue === true}
|
||||
onChange={(event) => onConfigChange(option, event.target.checked)}
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="structured-send">
|
||||
<span>Send</span>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="m7 5 5 5-5 5M12 10H3" />
|
||||
</svg>
|
||||
</button>
|
||||
{turnActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="structured-cancel"
|
||||
aria-label="Cancel turn"
|
||||
title="Cancel turn"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<svg className="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="50 14" strokeLinecap="round" />
|
||||
</svg>
|
||||
<svg className="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// docs/STRUCTURED-VIEW.md: the web client's user, agent, thought, tool-detail
|
||||
// and notice text blocks copy their complete source text when clicked or
|
||||
// keyboard-activated. The visible node is whatever it wraps; this only adds
|
||||
// the affordance and the "copied"/"error" state the CSS reads.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
|
||||
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand?.("copy") ?? false;
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error("clipboard unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CopyableProps {
|
||||
/** The complete source text — not whatever the DOM happens to show. */
|
||||
text: string;
|
||||
/** Names the thing in the "Copy …" label ("message", "diff", "path", …). */
|
||||
label: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
/** Extra attributes the row builders set (`data-row-id`, `lang`, …). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Wrapping a node in a copy affordance. Empty `text` renders the node plain —
|
||||
* a control that copies nothing is worse than no control. */
|
||||
export function Copyable({
|
||||
text,
|
||||
label,
|
||||
as: Tag = "div",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: CopyableProps) {
|
||||
const [copyState, setCopyState] = useState<"copied" | "error" | null>(null);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current !== null) window.clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
void writeClipboard(text).then(
|
||||
() => {
|
||||
setCopyState("copied");
|
||||
if (timer.current !== null) window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopyState(null), 1200);
|
||||
},
|
||||
() => setCopyState("error"),
|
||||
);
|
||||
}, [text]);
|
||||
|
||||
if (!text) {
|
||||
return (
|
||||
<Tag className={className} {...rest}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const ariaLabel = copyState === "copied" ? `${label} copied` : `Copy ${label}`;
|
||||
return (
|
||||
<Tag
|
||||
className={className ? `${className} st-copyable` : "st-copyable"}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={ariaLabel}
|
||||
title={`Copy ${label}`}
|
||||
data-copy-state={copyState ?? undefined}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
// A link inside the block is a link, not a copy target.
|
||||
if ((event.target as Element | null)?.closest("a")) return;
|
||||
copy();
|
||||
}}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
copy();
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import { toolDiffMeta } from "./model.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function lines(kind: string): string[] {
|
||||
return [...document.querySelectorAll(`.st-ln-${kind}`)].map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("DiffView", () => {
|
||||
it("marks insertions and deletions with their column prefixes", () => {
|
||||
render(<DiffView diff={{ path: "src/a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
|
||||
expect(lines("del")).toEqual(["- two"]);
|
||||
expect(lines("add")).toEqual(["+ TWO", "+ three"]);
|
||||
expect(lines("ctx")).toEqual([" one"]);
|
||||
expect(screen.getByText("src/a.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("carries the +added −removed counts the tool head shows", () => {
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
|
||||
const body = document.querySelector<HTMLElement>(".st-diff")!;
|
||||
expect(body.dataset.added).toBe("2");
|
||||
expect(body.dataset.removed).toBe("1");
|
||||
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
|
||||
.toBe("+2 −1");
|
||||
});
|
||||
|
||||
it("renders every line of a new file as added", () => {
|
||||
render(<DiffView diff={{ path: "new.ts", newText: "alpha\nbeta\n" }} />);
|
||||
expect(lines("add")).toEqual(["+ alpha", "+ beta"]);
|
||||
expect(lines("del")).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses a long unchanged run to a hunk summary", () => {
|
||||
const filler = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n");
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: `head\n${filler}\n`, newText: `HEAD\n${filler}\n` }} />);
|
||||
expect(lines("hunk")).toEqual(["⋯ 17 unchanged lines ⋯"]);
|
||||
});
|
||||
|
||||
it("copies the whole diff, prefixes included", () => {
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: "one\n", newText: "two\n" }} />);
|
||||
const body = document.querySelector<HTMLElement>(".st-diff")!;
|
||||
expect(body.getAttribute("aria-label")).toBe("Copy diff");
|
||||
expect(body.textContent).toBe("- one+ two");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
|
||||
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
|
||||
// computed here (structuredDiff.ts) rather than parsed out of a patch.
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
|
||||
import type { AcpDiff } from "../../types.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
|
||||
/** Column prefix by line kind. A `hunk` summary and an `eof` marker are their
|
||||
* own complete text, so neither takes one. */
|
||||
const PREFIX: Record<DiffLine["kind"], string> = {
|
||||
add: "+ ",
|
||||
del: "- ",
|
||||
ctx: " ",
|
||||
hunk: "",
|
||||
eof: "",
|
||||
};
|
||||
|
||||
export function diffLineText(line: DiffLine): string {
|
||||
return PREFIX[line.kind] + line.text;
|
||||
}
|
||||
|
||||
export interface DiffViewProps {
|
||||
diff: AcpDiff;
|
||||
}
|
||||
|
||||
export function DiffView({ diff }: DiffViewProps) {
|
||||
const lines = useMemo(() => computeDiffLines(diff.oldText, diff.newText), [diff.oldText, diff.newText]);
|
||||
const stats = useMemo(() => diffStats(lines), [lines]);
|
||||
const plain = useMemo(() => lines.map(diffLineText).join("\n"), [lines]);
|
||||
|
||||
return (
|
||||
<div className="st-diff-block">
|
||||
<Copyable className="st-diff-path" text={diff.path} label="path" title={diff.path}>
|
||||
{diff.path}
|
||||
</Copyable>
|
||||
<Copyable
|
||||
className="st-diff"
|
||||
text={plain}
|
||||
label="diff"
|
||||
data-added={stats.added}
|
||||
data-removed={stats.removed}
|
||||
>
|
||||
<pre>
|
||||
{lines.map((line, index) => (
|
||||
<span key={index} className={`st-ln st-ln-${line.kind}`}>
|
||||
{diffLineText(line)}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
</Copyable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../api.js")>("../../api.js");
|
||||
return {
|
||||
...actual,
|
||||
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
|
||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
uploadStructuredFile,
|
||||
} from "../../api.js";
|
||||
import { INITIAL_RECONNECT_MS } from "../../reconnect.js";
|
||||
import type { ReducedState, StructuredEvent, TranscriptRow } from "../../types.js";
|
||||
import { StructuredPane } from "./StructuredPane.js";
|
||||
|
||||
/** Just enough of a WebSocket for the pane to drive (mirrors structured.test.ts's
|
||||
* own fake, which the pre-React connection used). */
|
||||
class FakeSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
open(): void {
|
||||
act(() => this.onopen?.());
|
||||
}
|
||||
emit(event: StructuredEvent): void {
|
||||
act(() => this.onmessage?.({ data: JSON.stringify(event) }));
|
||||
}
|
||||
drop(code = 1006, reason = ""): void {
|
||||
act(() => this.onclose?.({ code, reason }));
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
const busy: ReducedState = { turn: { responding: true, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
function chunk(id: string, kind: "user" | "agent_text" | "agent_thought", text: string): TranscriptRow {
|
||||
return row(id, kind, { content: { type: "text", text } });
|
||||
}
|
||||
|
||||
const APPROVAL_REQUEST = {
|
||||
toolCall: { title: "Write(secrets.txt)", kind: "edit", locations: [{ path: "/repo/secrets.txt" }] },
|
||||
options: [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
|
||||
{ optionId: "r1", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
};
|
||||
|
||||
function mount(): { socket: FakeSocket; sockets: FakeSocket[] } {
|
||||
const sockets: FakeSocket[] = [];
|
||||
render(
|
||||
<StructuredPane
|
||||
sessionId="s1"
|
||||
socketFactory={() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as WebSocket;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
sockets[0].open();
|
||||
return { socket: sockets[0], sockets };
|
||||
}
|
||||
|
||||
function snapshot(socket: FakeSocket, rows: TranscriptRow[], state: ReducedState = idle): void {
|
||||
socket.emit({ type: "transcript_snapshot", snapshot: { cursor: rows.length, rows } });
|
||||
socket.emit({ type: "reduced_state", state });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("the eight row kinds", () => {
|
||||
it("renders user, agent, thought, tool, diff, approval and notice rows; never a plan", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(
|
||||
socket,
|
||||
[
|
||||
chunk("user:1", "user", "please edit it"),
|
||||
chunk("agent:1", "agent_text", "**bold** answer"),
|
||||
chunk("thought:1", "agent_thought", "considering"),
|
||||
row("tool:1", "tool_call", { title: "Read(src/main.ts)", kind: "read", status: "completed" }),
|
||||
row("tool:2", "tool_diff", {
|
||||
title: "Edit(a.ts)",
|
||||
kind: "edit",
|
||||
status: "completed",
|
||||
content: [{ type: "diff", path: "a.ts", oldText: "one\n", newText: "one\ntwo\n" }],
|
||||
}),
|
||||
row("approval:1", "approval", APPROVAL_REQUEST),
|
||||
row("plan", "plan", { entries: [{ content: "step", priority: "high", status: "pending" }] }),
|
||||
row("notice:1", "notice", { sessionUpdate: "mystery_update" }),
|
||||
],
|
||||
{ ...idle, pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("please edit it")).toBeTruthy();
|
||||
expect(document.querySelector(".st-row-agent strong")?.textContent).toBe("bold");
|
||||
expect(document.querySelector(".st-row-thought")?.textContent).toContain("considering");
|
||||
expect(screen.getByText("src/main.ts")).toBeTruthy();
|
||||
// The diff row's head carries the line counts, not a fabricated duration.
|
||||
expect(document.querySelector('[data-row-id="tool:2"] .st-tool-meta')?.textContent).toBe("+1 −0");
|
||||
expect(screen.getByText("permission requested")).toBeTruthy();
|
||||
expect(screen.getByText("[notice]")).toBeTruthy();
|
||||
expect(document.querySelector('[data-row-id="plan"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the state-only notices it consumes for `/` autocomplete", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
row("notice:1", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "compact", description: "Compact the conversation" }],
|
||||
}),
|
||||
row("notice:2", "notice", { sessionUpdate: "session_info_update" }),
|
||||
]);
|
||||
expect(screen.queryByText("[notice]")).toBeNull();
|
||||
|
||||
// …and offers them in the composer instead.
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "/comp" } });
|
||||
expect(screen.getByText("/compact")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a completed turn's footer inline, and retires it once the next turn starts", () => {
|
||||
const { socket } = mount();
|
||||
const footer = row("turn_meta:1", "notice", {
|
||||
maestroTurnMeta: { elapsedMs: 65_000, outcome: "completed", model: "sonnet", contextDelta: 1200 },
|
||||
});
|
||||
snapshot(socket, [chunk("user:1", "user", "go"), footer]);
|
||||
expect(screen.getByText("1m 5s · +1.2k context tokens · sonnet")).toBeTruthy();
|
||||
|
||||
socket.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "append", row: chunk("user:2", "user", "again") },
|
||||
state: idle,
|
||||
});
|
||||
expect(screen.queryByText(/1m 5s/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool rows and activity groups", () => {
|
||||
it("collapses two or more tools in a turn into one group, opened on click", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
chunk("user:1", "user", "go"),
|
||||
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||
chunk("agent:1", "agent_text", "still working"),
|
||||
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
||||
chunk("agent:2", "agent_text", "the answer"),
|
||||
]);
|
||||
|
||||
const head = screen.getByText("2 tool calls").closest("button")!;
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
// The final answer stays outside the group; the intermediate text is in it.
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
|
||||
|
||||
fireEvent.click(head);
|
||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("discloses a tool's output only once its head is opened", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
row("tool:1", "tool_call", {
|
||||
title: "Execute(ls)",
|
||||
kind: "execute",
|
||||
status: "completed",
|
||||
content: [{ type: "content", content: { type: "text", text: "a.ts\nb.ts" } }],
|
||||
rawInput: { command: "ls" },
|
||||
rawOutput: { exit_code: 0 },
|
||||
}),
|
||||
]);
|
||||
expect(document.querySelector(".st-tool-text")).toBeNull();
|
||||
|
||||
fireEvent.click(document.querySelector<HTMLButtonElement>(".st-tool-head")!);
|
||||
expect(document.querySelector(".st-tool-text")?.textContent).toBe("a.ts\nb.ts");
|
||||
expect(screen.getByText("Exit code")).toBeTruthy();
|
||||
expect(document.querySelector(".st-tool-exit-ok")?.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("patches a tool row in place rather than appending a second one", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "in_progress" })]);
|
||||
expect(document.querySelector(".st-tool-meta")?.textContent).toBe("running");
|
||||
|
||||
socket.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||
},
|
||||
state: idle,
|
||||
});
|
||||
expect(document.querySelectorAll('[data-row-id="tool:1"]')).toHaveLength(1);
|
||||
expect(document.querySelector(".st-tool-meta")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvals", () => {
|
||||
it("answers with the live nonce, offering only the decisions the request backs", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never },
|
||||
});
|
||||
|
||||
expect(screen.getByText("/repo/secrets.txt")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Allow for session"));
|
||||
await waitFor(() =>
|
||||
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "nonce-1", "allow_for_session"),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides a decision the request offers no ACP option for", () => {
|
||||
const { socket } = mount();
|
||||
const request = { toolCall: { title: "Read(a.ts)" }, options: [{ optionId: "r", name: "No", kind: "reject_once" }] };
|
||||
snapshot(socket, [row("approval:1", "approval", request)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-2", request: request as never },
|
||||
});
|
||||
expect(screen.getByText("Deny")).toBeTruthy();
|
||||
expect(screen.queryByText("Allow once")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats only the newest approval as pending, and hides resolved standalone ones", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(
|
||||
socket,
|
||||
[row("approval:1", "approval", APPROVAL_REQUEST), row("approval:2", "approval", APPROVAL_REQUEST)],
|
||||
{ ...idle, pending_approval: { nonce: "nonce-3", request: APPROVAL_REQUEST as never } },
|
||||
);
|
||||
expect(screen.getAllByText("permission requested")).toHaveLength(1);
|
||||
expect(screen.queryByText(/approved/)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the controls once the approval is no longer pending", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-4", request: APPROVAL_REQUEST as never },
|
||||
});
|
||||
expect(screen.getByText("Allow once")).toBeTruthy();
|
||||
|
||||
socket.emit({ type: "reduced_state", state: idle });
|
||||
expect(screen.queryByText("Allow once")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt and cancel", () => {
|
||||
it("sends a draft, clearing the composer", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "do the thing" } });
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"));
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("uploads attachments and names them in the prompt", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
|
||||
fireEvent.change(document.querySelector<HTMLInputElement>(".structured-file-input")!, {
|
||||
target: { files: [file] },
|
||||
});
|
||||
expect(screen.getByText("notes.txt")).toBeTruthy();
|
||||
|
||||
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
|
||||
target: { value: "read this" },
|
||||
});
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(uploadStructuredFile).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith(
|
||||
"s1",
|
||||
'read this\n\nAttached file "notes.txt": "/uploads/pasted.txt"',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("queues a second message while a turn is in flight, and sends it when the turn ends", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [], busy);
|
||||
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
|
||||
target: { value: "next up" },
|
||||
});
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".st-row-queued")?.textContent).toBe("next up");
|
||||
|
||||
socket.emit({ type: "reduced_state", state: idle });
|
||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
|
||||
});
|
||||
|
||||
it("hands a failed send's draft back to the operator", async () => {
|
||||
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "keep me" } });
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(document.querySelector(".structured-error")?.textContent).toContain("boom"));
|
||||
expect(input.value).toBe("keep me");
|
||||
});
|
||||
|
||||
it("shows the cancel control only while a turn is live", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
expect(document.querySelector(".structured-cancel")).toBeNull();
|
||||
|
||||
socket.emit({ type: "reduced_state", state: busy });
|
||||
fireEvent.click(document.querySelector<HTMLButtonElement>(".structured-cancel")!);
|
||||
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
|
||||
it("reports live turn activity above the composer", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [], {
|
||||
turn: {
|
||||
responding: true,
|
||||
thinking: false,
|
||||
active_tool_calls: ["t1", "t2"],
|
||||
started_at: new Date(Date.now() - 5_000).toISOString(),
|
||||
},
|
||||
usage: { used: 12_000, size: 200_000 },
|
||||
});
|
||||
const activity = document.querySelector<HTMLElement>(".structured-activity")!;
|
||||
expect(activity.hidden).toBe(false);
|
||||
expect(activity.textContent).toContain("Running 2 tools");
|
||||
expect(activity.textContent).toContain("12k context / 200k context");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the socket", () => {
|
||||
it("replaces the transcript wholesale on every snapshot, never merging", async () => {
|
||||
const { socket, sockets } = mount();
|
||||
snapshot(socket, [chunk("user:1", "user", "first")]);
|
||||
socket.drop(1006);
|
||||
|
||||
// The retry is on reconnect.ts's backoff schedule, not immediate.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, INITIAL_RECONNECT_MS + 20));
|
||||
});
|
||||
const replacement = sockets[1];
|
||||
expect(replacement).toBeDefined();
|
||||
replacement.open();
|
||||
snapshot(replacement, [chunk("user:2", "user", "second")]);
|
||||
expect(screen.queryByText("first")).toBeNull();
|
||||
expect(screen.getByText("second")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports a daemon rejection instead of retrying it", () => {
|
||||
const status = vi.fn();
|
||||
const sockets: FakeSocket[] = [];
|
||||
render(
|
||||
<StructuredPane
|
||||
sessionId="s1"
|
||||
onStatusChange={status}
|
||||
socketFactory={() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as WebSocket;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
sockets[0].open();
|
||||
sockets[0].drop(4004, "not a structured session");
|
||||
expect(sockets).toHaveLength(1);
|
||||
expect(status).toHaveBeenCalledWith("error", "not a structured session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
// React port of structured.ts (#246): a live structured session — the
|
||||
// transcript from `WS /api/structured/:id`, the composer that drives
|
||||
// `.../prompt` and `.../cancel`, and the approval controls that answer
|
||||
// `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
|
||||
//
|
||||
// Like TerminalPane, the pane draws the body only: pane chrome (title,
|
||||
// disconnect, sessions drawer) belongs to whatever composes it, which reads
|
||||
// the connection state through `onStatusChange`.
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
setStructuredConfig,
|
||||
uploadStructuredFile,
|
||||
} from "../../api.js";
|
||||
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
|
||||
import type {
|
||||
AcpSessionConfigOption,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow as Row,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
import { Composer } from "./Composer.js";
|
||||
import {
|
||||
commandsFromTranscript,
|
||||
pendingApprovalRowId as pendingApprovalRowIdOf,
|
||||
transcriptItems,
|
||||
turnIsActive,
|
||||
} from "./model.js";
|
||||
import { ToolGroup } from "./ToolGroup.js";
|
||||
import { TranscriptRow } from "./TranscriptRow.js";
|
||||
import { useStructuredSession, type StructuredStatus, type StructuredSocketFactory } from "./useStructuredSession.js";
|
||||
import "./structured.css";
|
||||
|
||||
/** Within this much of the bottom, wheel and touch imprecision should still
|
||||
* count as following. */
|
||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
interface QueuedPrompt {
|
||||
text: string;
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
export interface StructuredPaneProps {
|
||||
sessionId: Uuid;
|
||||
/** Grabs the composer once mounted. A sidebar preview passes `false`. */
|
||||
focus?: boolean;
|
||||
shouldReconnect?: (code: number) => boolean;
|
||||
onClosed?: () => void;
|
||||
onStatusChange?: (status: StructuredStatus, detail?: string) => void;
|
||||
/** Test seam: drive the socket without a daemon. */
|
||||
socketFactory?: StructuredSocketFactory;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface StructuredPaneHandle {
|
||||
/** Put the keyboard in the composer — the structured pane's terminal. */
|
||||
focusComposer(): void;
|
||||
}
|
||||
|
||||
export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPaneProps>(function StructuredPane(
|
||||
{ sessionId, focus = true, shouldReconnect, onClosed, onStatusChange, socketFactory, className },
|
||||
ref,
|
||||
) {
|
||||
const session = useStructuredSession(sessionId, { shouldReconnect, onClosed, socketFactory });
|
||||
const { rows, state, status, detail, socketGeneration } = session;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<HTMLDivElement | null>(null);
|
||||
const followingBottom = useRef(true);
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<ReadonlySet<string>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [queue, setQueue] = useState<QueuedPrompt[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [configBusy, setConfigBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
/** Server path → object URL, so a user row can preview an image it uploaded. */
|
||||
const imageUrls = useRef(new Map<string, string>());
|
||||
|
||||
// Submission bookkeeping, ported from structured.ts: a prompt is "sending"
|
||||
// from the POST until the projection shows the turn it claimed, and an
|
||||
// acceptance that straddles a reconnect forces one more reconnect so the
|
||||
// snapshot cannot predate the 204.
|
||||
const submission = useRef(0);
|
||||
const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null);
|
||||
const optimisticStart = useRef<number | null>(null);
|
||||
const promptPending = useRef(false);
|
||||
const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusComposer: () => composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.(status, detail ?? undefined);
|
||||
}, [status, detail, onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus) return;
|
||||
composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus();
|
||||
}, [focus]);
|
||||
|
||||
const urls = imageUrls.current;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const url of urls.values()) URL.revokeObjectURL(url);
|
||||
urls.clear();
|
||||
},
|
||||
[urls],
|
||||
);
|
||||
|
||||
const turnActive = turnIsActive(state);
|
||||
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
||||
const items = useMemo(
|
||||
() => transcriptItems(rows, pendingRowId, turnActive),
|
||||
[rows, pendingRowId, turnActive],
|
||||
);
|
||||
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
||||
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
||||
|
||||
const active = turnActive || awaitingBegin !== null;
|
||||
|
||||
// Stop saying "Sending" once the turn the submission was waiting for has
|
||||
// begun — or, when it was accepted but a replacement socket has
|
||||
// re-snapshotted since, once that newer snapshot has landed. Without the
|
||||
// second case a prompt accepted across a reconnect would say "Sending"
|
||||
// forever.
|
||||
useEffect(() => {
|
||||
if (turnActive) {
|
||||
setAwaitingBegin(null);
|
||||
} else if (
|
||||
awaitingBegin !== null &&
|
||||
accepted.current?.generation === awaitingBegin &&
|
||||
socketGeneration > accepted.current.socketGeneration
|
||||
) {
|
||||
setAwaitingBegin(null);
|
||||
}
|
||||
}, [turnActive, awaitingBegin, socketGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) optimisticStart.current = null;
|
||||
}, [active]);
|
||||
|
||||
// Keep the pane pinned to the newest row unless the operator scrolled away.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !followingBottom.current) return;
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
}, [items, active, queue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
followingBottom.current = el.scrollHeight - el.clientHeight - el.scrollTop <= FOLLOW_BOTTOM_THRESHOLD_PX;
|
||||
};
|
||||
|
||||
const decide = useCallback(
|
||||
async (rowId: string, decision: StructuredApprovalDecision): Promise<void> => {
|
||||
const nonce = state.pending_approval?.nonce;
|
||||
// Already resolved elsewhere between the click and here. A stale nonce
|
||||
// is a no-op, not an error (docs/STRUCTURED-VIEW.md: any nonce answers
|
||||
// 204), and the row's buttons are already gone — resolve quietly.
|
||||
if (!nonce || rowId !== pendingRowId) return;
|
||||
try {
|
||||
await resolveStructuredApproval(sessionId, nonce, decision);
|
||||
} catch (e: unknown) {
|
||||
// Propagate — so the row re-enables its buttons — only while this is
|
||||
// still the pending approval. If it moved on in flight, reviving the
|
||||
// buttons would revive a decision that no longer applies.
|
||||
setError(String(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
||||
);
|
||||
|
||||
const dispatchPrompt = useCallback(
|
||||
(text: string, files: File[]) => {
|
||||
const generation = ++submission.current;
|
||||
const submissionSocket = socketGeneration;
|
||||
setAwaitingBegin(generation);
|
||||
optimisticStart.current = Date.now();
|
||||
accepted.current = null;
|
||||
promptPending.current = true;
|
||||
setUploading(files.length > 0);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
|
||||
const send = async () => {
|
||||
const references: string[] = [];
|
||||
for (const file of files) {
|
||||
const { path } = await uploadStructuredFile(sessionId, file);
|
||||
if (file.type.startsWith("image/")) imageUrls.current.set(path, URL.createObjectURL(file));
|
||||
references.push(`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
|
||||
}
|
||||
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
|
||||
};
|
||||
|
||||
void send().then(
|
||||
() => {
|
||||
promptPending.current = false;
|
||||
if (generation !== submission.current) return;
|
||||
setUploading(false);
|
||||
setAttachments((current) => (current === files ? [] : current));
|
||||
accepted.current = { generation, socketGeneration };
|
||||
// A replacement socket may have captured idle before the POST
|
||||
// claimed the prompt. Reconnect so its snapshot cannot predate the
|
||||
// 204.
|
||||
if (socketGeneration > submissionSocket) session.reconnect();
|
||||
},
|
||||
(e: unknown) => {
|
||||
promptPending.current = false;
|
||||
if (generation !== submission.current) return;
|
||||
setAwaitingBegin((current) => (current === generation ? null : current));
|
||||
optimisticStart.current = null;
|
||||
setUploading(false);
|
||||
// The draft and its attachments are the operator's work — a failed
|
||||
// send hands both back rather than eating them.
|
||||
setAttachments((current) => (current.length === 0 ? files : current));
|
||||
setDraft((current) => (current === "" ? text : current));
|
||||
setError(String(e));
|
||||
},
|
||||
);
|
||||
},
|
||||
[sessionId, socketGeneration, session],
|
||||
);
|
||||
|
||||
// A queued message goes out as soon as the projected turn is idle — the
|
||||
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
|
||||
// client-local (docs/STRUCTURED-VIEW.md).
|
||||
useEffect(() => {
|
||||
if (active || promptPending.current || queue.length === 0) return;
|
||||
const [next, ...rest] = queue;
|
||||
setQueue(rest);
|
||||
dispatchPrompt(next.text, next.attachments);
|
||||
}, [active, queue, dispatchPrompt]);
|
||||
|
||||
const submit = (text: string, files: File[]) => {
|
||||
if (active || queue.length > 0) {
|
||||
setQueue((current) => [...current, { text, attachments: files }]);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
dispatchPrompt(text, files);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setError(null);
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => setError(String(e)));
|
||||
};
|
||||
|
||||
const changeConfig = (option: AcpSessionConfigOption, value: string | boolean) => {
|
||||
setConfigBusy(true);
|
||||
setError(null);
|
||||
setStructuredConfig(sessionId, { config_id: option.id, value })
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof Error ? e.message : "Could not change the session setting"),
|
||||
)
|
||||
.finally(() => setConfigBusy(false));
|
||||
};
|
||||
|
||||
const elapsedMs = () => {
|
||||
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
|
||||
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticStart.current;
|
||||
return startedAt === null ? 0 : Math.max(0, now - startedAt);
|
||||
};
|
||||
|
||||
const activityLabel = () => {
|
||||
const tools = state.turn.active_tool_calls.length;
|
||||
const label = awaitingBegin !== null
|
||||
? "Sending"
|
||||
: state.turn.thinking
|
||||
? "Thinking"
|
||||
: tools > 0
|
||||
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
|
||||
: "Responding";
|
||||
const parts = [label, formatElapsed(elapsedMs())];
|
||||
if (typeof state.usage?.used === "number") {
|
||||
const usage = formatContextTokens(state.usage.used);
|
||||
parts.push(
|
||||
typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage,
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
const setRowExpanded = (id: string, expanded: boolean) =>
|
||||
setExpandedRows((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const setGroupExpanded = (id: string, expanded: boolean) =>
|
||||
setExpandedGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const renderRow = (row: Row) => (
|
||||
<TranscriptRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pendingApproval={row.id === pendingRowId}
|
||||
onDecide={(decision) => decide(row.id, decision)}
|
||||
expanded={expandedRows.has(row.id)}
|
||||
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
||||
imageUrls={imageUrls.current}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className ? `structured-pane ${className}` : "structured-pane"}>
|
||||
<div className="structured-body">
|
||||
<div className="structured-main">
|
||||
<div className="structured-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="structured-rows">
|
||||
{items.map((item) =>
|
||||
item.type === "row" ? (
|
||||
renderRow(item.row)
|
||||
) : (
|
||||
<ToolGroup
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
toolCount={item.group.toolCount}
|
||||
expanded={expandedGroups.has(item.id)}
|
||||
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
||||
>
|
||||
{item.group.rows.map(renderRow)}
|
||||
</ToolGroup>
|
||||
),
|
||||
)}
|
||||
{queue.map((queued, index) => (
|
||||
<div key={`queued-${index}`} className="st-row st-row-queued">
|
||||
<div className="st-body">
|
||||
<div className="st-user-text">{queued.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="structured-activity" role="status" aria-live="polite" hidden={!active}>
|
||||
<span className="structured-activity-pulse" aria-hidden="true" />
|
||||
<span className="structured-activity-text">{active ? activityLabel() : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
{error !== null && <div className="structured-error">{error}</div>}
|
||||
<div ref={composerRef}>
|
||||
<Composer
|
||||
attachments={attachments}
|
||||
onAttachmentsChange={setAttachments}
|
||||
commands={commands}
|
||||
configOptions={configOptions}
|
||||
turnActive={active}
|
||||
uploading={uploading}
|
||||
configBusy={configBusy}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={submit}
|
||||
onCancel={cancel}
|
||||
onConfigChange={changeConfig}
|
||||
onError={setError}
|
||||
menuId={`structured-command-menu-${sessionId}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// A turn's collapsed activity block: two or more tool rows after the same
|
||||
// `User` row, plus the narration absorbed between them. The header counts tool
|
||||
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
|
||||
// chronological order.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ToolGroupProps {
|
||||
id: string;
|
||||
toolCount: number;
|
||||
expanded: boolean;
|
||||
onExpandedChange(expanded: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
|
||||
return (
|
||||
<div className="st-tool-group" data-tool-group-id={id}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool-group-head"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<span className="st-tool-group-glyph" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
|
||||
</button>
|
||||
<div
|
||||
className={
|
||||
expanded ? "st-tool-group-body st-disclosure is-expanded" : "st-tool-group-body st-disclosure"
|
||||
}
|
||||
aria-hidden={!expanded}
|
||||
inert={!expanded}
|
||||
>
|
||||
<div className="st-disclosure-clip">
|
||||
<div className="st-disclosure-inner">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// The glyph column of a tool row's head. Recognition is narrow on purpose: an
|
||||
// unrecognised or absent `ToolKind` gets the generic bullet, never a blank
|
||||
// column and never a mark that stands for the wrong kind of work.
|
||||
|
||||
import type { AcpToolKind } from "../../types.js";
|
||||
|
||||
/** Glyph shown in a tool row's head, by `ToolKind`. */
|
||||
const KIND_GLYPH: Record<string, string> = {
|
||||
read: "≡",
|
||||
edit: "±",
|
||||
delete: "✕",
|
||||
move: "→",
|
||||
search: "⌕",
|
||||
execute: "$",
|
||||
think: "…",
|
||||
fetch: "⇣",
|
||||
switch_mode: "⇄",
|
||||
};
|
||||
|
||||
export function toolGlyph(kind: string | undefined): string {
|
||||
return (kind && KIND_GLYPH[kind]) || "•";
|
||||
}
|
||||
|
||||
export interface ToolIconProps {
|
||||
kind: AcpToolKind | string | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolIcon({ kind, className = "st-tool-glyph" }: ToolIconProps) {
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{toolGlyph(kind)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// One transcript row, by kind. docs/STRUCTURED-VIEW.md fixes the eight kinds
|
||||
// and leaves every rendering decision here: markdown for AgentText, an inline
|
||||
// italic stream for AgentThought, a tool card for ToolCall/ToolDiff, a diff
|
||||
// view for ToolDiff's content, an approval card for Approval, and an opaque
|
||||
// collapsed line for a Notice nobody has taught the client about. `Plan` is
|
||||
// deliberately not drawn — the web client has no plan rail.
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpToolCallContent,
|
||||
AcpToolCallLike,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow as Row,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { ApprovalView } from "./ApprovalView.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import {
|
||||
contentBlockText,
|
||||
isStateOnlyNotice,
|
||||
splitToolTitle,
|
||||
statusMeta,
|
||||
toolDiffMeta,
|
||||
turnMeta,
|
||||
} from "./model.js";
|
||||
import { safeMediaSource, toolPayload } from "./toolPayload.js";
|
||||
import { ToolIcon } from "./ToolIcon.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// content rows: user / agent / thought
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
|
||||
* the DOM. Tables get their own scroller and links open out of the app. */
|
||||
function Prose({ html }: { html: string }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const container = ref.current;
|
||||
if (!container) return;
|
||||
for (const table of Array.from(container.querySelectorAll("table"))) {
|
||||
if (table.parentElement?.classList.contains("st-markdown-table-wrap")) continue;
|
||||
const scroller = document.createElement("div");
|
||||
scroller.className = "st-markdown-table-wrap";
|
||||
table.replaceWith(scroller);
|
||||
scroller.appendChild(table);
|
||||
}
|
||||
for (const link of container.querySelectorAll("a")) {
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
}
|
||||
}, [html]);
|
||||
return <div ref={ref} className="st-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
export interface ContentRowProps {
|
||||
row: Row;
|
||||
variant: "user" | "agent" | "thought";
|
||||
/** Object URLs for images this prompt uploaded, keyed by the server path the
|
||||
* row's text names. */
|
||||
imageUrls?: Map<string, string>;
|
||||
}
|
||||
|
||||
/** `Attached file "name": "path"` references the composer appends on send. */
|
||||
const ATTACHED_FILE_RE = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
||||
|
||||
function attachedImages(text: string, imageUrls: Map<string, string>): string[] {
|
||||
const urls: string[] = [];
|
||||
ATTACHED_FILE_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = ATTACHED_FILE_RE.exec(text)) !== null) {
|
||||
let serverPath: string;
|
||||
try {
|
||||
serverPath = JSON.parse(`"${match[1]}"`) as string;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const url = imageUrls.get(serverPath);
|
||||
if (url) urls.push(url);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
export function ContentRow({ row, variant, imageUrls }: ContentRowProps) {
|
||||
const data = row.data as { content?: AcpContentBlock } | undefined;
|
||||
const text = contentBlockText(data?.content);
|
||||
const displayText = variant === "thought" ? text.trim() : text;
|
||||
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
|
||||
|
||||
let body: ReactNode;
|
||||
if (variant === "user") {
|
||||
const images = imageUrls && imageUrls.size > 0 ? attachedImages(displayText, imageUrls) : [];
|
||||
// With previews in hand the path references are noise — the image is the
|
||||
// attachment. Without them the text is all the operator has, so it stays.
|
||||
const shown =
|
||||
images.length > 0
|
||||
? displayText.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim()
|
||||
: displayText;
|
||||
body = (
|
||||
<div className="st-body">
|
||||
{shown && <div className="st-user-text">{shown}</div>}
|
||||
{images.map((src) => (
|
||||
<img key={src} className="st-user-image" src={src} alt="Uploaded image" loading="lazy" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (variant === "agent") {
|
||||
body = <Prose html={renderMarkdown(displayText)} />;
|
||||
} else {
|
||||
body = <div className="st-body" dangerouslySetInnerHTML={{ __html: renderInlineMarkdown(displayText) }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Copyable className={`st-row st-row-${variant}`} text={displayText} label={label} data-row-id={row.id}>
|
||||
{body}
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tool rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToolDetailBlock({ label, text }: { label: string; text: string }) {
|
||||
return (
|
||||
<div className="st-tool-detail">
|
||||
<div className="st-tool-detail-label">{label}</div>
|
||||
<Copyable as="pre" text={text} label={label.toLowerCase()}>
|
||||
{text}
|
||||
</Copyable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentBlockView({ block }: { block: AcpContentBlock }) {
|
||||
if (block.type === "text") {
|
||||
if (!block.text) return null;
|
||||
return (
|
||||
<Copyable className="st-tool-text" text={block.text} label="output">
|
||||
{block.text}
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
if (block.type === "image") {
|
||||
const source = safeMediaSource(block.mimeType, block.data, "image");
|
||||
if (source) return <img className="st-tool-image" src={source} alt="Tool output" loading="lazy" />;
|
||||
return <ToolDetailBlock label="Image" text={block.uri ?? block.mimeType ?? "Unavailable"} />;
|
||||
}
|
||||
if (block.type === "audio") {
|
||||
const source = safeMediaSource(block.mimeType, block.data, "audio");
|
||||
if (source) return <audio className="st-tool-audio" controls src={source} />;
|
||||
return <ToolDetailBlock label="Audio" text={block.mimeType ?? "Unavailable"} />;
|
||||
}
|
||||
if (block.type === "resource_link") {
|
||||
return (
|
||||
<div className="st-tool-resource">
|
||||
<span className="st-tool-resource-name">{block.title ?? block.name ?? "Resource"}</span>
|
||||
{block.uri && (
|
||||
<Copyable as="code" className="st-tool-resource-uri" text={block.uri} label="resource URI">
|
||||
{block.uri}
|
||||
</Copyable>
|
||||
)}
|
||||
{block.description && <span className="st-tool-resource-description">{block.description}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const resource = block.resource;
|
||||
if (typeof resource?.text === "string") {
|
||||
return (
|
||||
<ToolDetailBlock label={resource.uri ? `Resource · ${resource.uri}` : "Resource"} text={resource.text} />
|
||||
);
|
||||
}
|
||||
const printed = resource === undefined ? "" : JSON.stringify(resource, null, 2);
|
||||
return printed ? <ToolDetailBlock label="Resource" text={printed} /> : null;
|
||||
}
|
||||
|
||||
function ToolBody({ tool, content }: { tool: AcpToolCallLike; content: AcpToolCallContent[] }) {
|
||||
const payload = toolPayload(tool, content);
|
||||
const hasRaw = payload.raw.input !== null || payload.raw.output !== null;
|
||||
return (
|
||||
<>
|
||||
{content.map((block, index) => {
|
||||
if (block.type === "content") return <ContentBlockView key={index} block={block.content} />;
|
||||
if (block.type === "diff") return <DiffView key={index} diff={block} />;
|
||||
return (
|
||||
<div key={index} className="st-tool-terminal">
|
||||
{block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{payload.details.map((detail, index) => (
|
||||
<ToolDetailBlock key={`${detail.label}-${index}`} label={detail.label} text={detail.text} />
|
||||
))}
|
||||
{payload.exitCode !== null && (
|
||||
<div className="st-tool-fact">
|
||||
<span className="st-tool-fact-label">Exit code</span>
|
||||
<code className={payload.exitCode === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed"}>
|
||||
{payload.exitCode}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{hasRaw && (
|
||||
<details className="st-tool-raw">
|
||||
<summary>Raw JSON</summary>
|
||||
<div className="st-tool-raw-body">
|
||||
{payload.raw.input !== null && <ToolDetailBlock label="Input" text={payload.raw.input} />}
|
||||
{payload.raw.output !== null && <ToolDetailBlock label="Output" text={payload.raw.output} />}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a tool call has anything at all to disclose — a head with nothing
|
||||
* behind it stays a plain line rather than an empty disclosure. */
|
||||
function hasToolBody(tool: AcpToolCallLike, content: AcpToolCallContent[]): boolean {
|
||||
if (content.length > 0) return true;
|
||||
const payload = toolPayload(tool, content);
|
||||
return payload.details.length > 0 || payload.exitCode !== null || payload.raw.input !== null || payload.raw.output !== null;
|
||||
}
|
||||
|
||||
export interface ToolRowProps {
|
||||
row: Row;
|
||||
expanded: boolean;
|
||||
onExpandedChange(expanded: boolean): void;
|
||||
}
|
||||
|
||||
export function ToolRow({ row, expanded, onExpandedChange }: ToolRowProps) {
|
||||
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 meta = statusMeta(tool.status, hasDiff);
|
||||
const metaParts: string[] = [];
|
||||
if (hasDiff) metaParts.push(toolDiffMeta(content));
|
||||
if (meta) metaParts.push(meta.text);
|
||||
const disclosable = hasToolBody(tool, content);
|
||||
const open = expanded && disclosable;
|
||||
|
||||
return (
|
||||
<div className="st-row st-row-tool" data-row-id={row.id}>
|
||||
<div className="st-tool">
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool-head"
|
||||
aria-expanded={open}
|
||||
onClick={() => disclosable && onExpandedChange(!open)}
|
||||
>
|
||||
<ToolIcon kind={tool.kind} />
|
||||
<span className="st-tool-name">{label}</span>
|
||||
<span className="st-tool-arg" title={arg}>
|
||||
{arg}
|
||||
</span>
|
||||
{metaParts.length > 0 && (
|
||||
<span className={meta ? `st-tool-meta st-tool-meta-${meta.kind}` : "st-tool-meta"}>
|
||||
{metaParts.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{disclosable && (
|
||||
<div
|
||||
className={open ? "st-tool-out st-disclosure is-expanded" : "st-tool-out st-disclosure"}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
>
|
||||
<div className="st-disclosure-clip">
|
||||
<div className="st-disclosure-inner">
|
||||
{/* Built only once opened: a long transcript would otherwise
|
||||
render every tool's whole output up front. */}
|
||||
{open && <ToolBody tool={tool} content={content} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// notices and turn footers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function compactNumber(value: number): string {
|
||||
if (Math.abs(value) < 1_000) return String(value);
|
||||
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
|
||||
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
|
||||
}
|
||||
|
||||
export function TurnMetaRow({ meta }: { meta: TurnMeta }) {
|
||||
const parts = [formatElapsed(meta.elapsedMs)];
|
||||
if (typeof meta.contextDelta === "number") {
|
||||
const sign = meta.contextDelta > 0 ? "+" : "";
|
||||
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
|
||||
} else if (typeof meta.contextTokens === "number") {
|
||||
parts.push(`${compactNumber(meta.contextTokens)} context`);
|
||||
}
|
||||
if (meta.model) parts.push(meta.model);
|
||||
if (meta.outcome !== "completed") parts.push(meta.outcome);
|
||||
const text = parts.join(" · ");
|
||||
return (
|
||||
<Copyable
|
||||
className={`st-turn-meta st-turn-meta-${meta.outcome}`}
|
||||
text={text}
|
||||
label="turn details"
|
||||
aria-label={`Turn ${meta.outcome}`}
|
||||
>
|
||||
<span className="st-turn-meta-dot" aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
/** The floor presentation docs/STRUCTURED-VIEW.md allows for a `Notice` the
|
||||
* client has nothing better to do with: an opaque, collapsed line. */
|
||||
export function NoticeRow({ row }: { row: Row }) {
|
||||
const text = `[${row.kind}]`;
|
||||
return (
|
||||
<Copyable className="st-row st-row-notice" text={text} label="notice" data-row-id={row.id}>
|
||||
<div className="st-body">{text}</div>
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TranscriptRowProps {
|
||||
row: Row;
|
||||
/** This row is the one live approval (docs/STRUCTURED-VIEW.md's nonce
|
||||
* rules). Every other approval row is history. */
|
||||
pendingApproval?: boolean;
|
||||
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
imageUrls?: Map<string, string>;
|
||||
}
|
||||
|
||||
/** One transcript row, or `null` for the rows the web client omits (`plan`,
|
||||
* and the state-only notices it consumes for `/` autocomplete). */
|
||||
export function TranscriptRow({
|
||||
row,
|
||||
pendingApproval = false,
|
||||
onDecide,
|
||||
expanded = false,
|
||||
onExpandedChange,
|
||||
imageUrls,
|
||||
}: TranscriptRowProps) {
|
||||
switch (row.kind) {
|
||||
case "user":
|
||||
return <ContentRow row={row} variant="user" imageUrls={imageUrls} />;
|
||||
case "agent_text":
|
||||
return <ContentRow row={row} variant="agent" />;
|
||||
case "agent_thought":
|
||||
return <ContentRow row={row} variant="thought" />;
|
||||
case "tool_call":
|
||||
case "tool_diff":
|
||||
return (
|
||||
<ToolRow row={row} expanded={expanded} onExpandedChange={(next) => onExpandedChange?.(next)} />
|
||||
);
|
||||
case "approval": {
|
||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
return (
|
||||
<ApprovalView
|
||||
request={request}
|
||||
pending={pendingApproval}
|
||||
onDecide={onDecide ?? (() => Promise.resolve())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "plan":
|
||||
return null;
|
||||
case "notice": {
|
||||
if (isStateOnlyNotice(row)) return null;
|
||||
const meta = turnMeta(row);
|
||||
return meta ? <TurnMetaRow meta={meta} /> : <NoticeRow row={row} />;
|
||||
}
|
||||
default:
|
||||
return <NoticeRow row={row} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { StructuredPane, type StructuredPaneHandle, type StructuredPaneProps } from "./StructuredPane.js";
|
||||
export { TranscriptRow, type TranscriptRowProps } from "./TranscriptRow.js";
|
||||
export { DiffView, type DiffViewProps } from "./DiffView.js";
|
||||
export { ApprovalView, type ApprovalViewProps } from "./ApprovalView.js";
|
||||
export { ToolGroup, type ToolGroupProps } from "./ToolGroup.js";
|
||||
export { ToolIcon, toolGlyph, type ToolIconProps } from "./ToolIcon.js";
|
||||
export { Composer, type ComposerProps } from "./Composer.js";
|
||||
export {
|
||||
useStructuredSession,
|
||||
type StructuredSession,
|
||||
type StructuredSocketFactory,
|
||||
type StructuredStatus,
|
||||
} from "./useStructuredSession.js";
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ReducedState, TranscriptRow } from "../../types.js";
|
||||
import {
|
||||
activityGroupsByTurn,
|
||||
applyCommand,
|
||||
commandsFromTranscript,
|
||||
commandTokenAt,
|
||||
contentBlockText,
|
||||
filterCommands,
|
||||
isStateOnlyNotice,
|
||||
offeredDecisions,
|
||||
orderConfigOptions,
|
||||
pendingApprovalRowId,
|
||||
splitToolTitle,
|
||||
statusMeta,
|
||||
toolDiffMeta,
|
||||
toolKindLabel,
|
||||
transcriptItems,
|
||||
turnMeta,
|
||||
} from "./model.js";
|
||||
|
||||
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
function tool(id: string): TranscriptRow {
|
||||
return row(id, "tool_call", { title: `Read(${id})`, status: "completed" });
|
||||
}
|
||||
|
||||
function text(id: string, kind: "user" | "agent_text" | "agent_thought", body: string): TranscriptRow {
|
||||
return row(id, kind, { content: { type: "text", text: body } });
|
||||
}
|
||||
|
||||
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
describe("row taxonomy", () => {
|
||||
it("splits an adapter's Name(argument) title, and falls back to the kind", () => {
|
||||
expect(splitToolTitle("Read(src/main.ts)", null, "read")).toEqual({ label: "Read", arg: "src/main.ts" });
|
||||
expect(splitToolTitle("something opaque", null, "search")).toEqual({
|
||||
label: "Search",
|
||||
arg: "something opaque",
|
||||
});
|
||||
expect(splitToolTitle(undefined, null, undefined)).toEqual({ label: "Tool", arg: "" });
|
||||
});
|
||||
|
||||
it("labels an unknown tool kind rather than leaving it blank", () => {
|
||||
expect(toolKindLabel("execute")).toBe("Execute");
|
||||
expect(toolKindLabel("no_such_kind")).toBe("Tool");
|
||||
expect(toolKindLabel(undefined)).toBe("Tool");
|
||||
});
|
||||
|
||||
it("names non-text content blocks instead of dropping them", () => {
|
||||
expect(contentBlockText({ type: "text", text: "hi" })).toBe("hi");
|
||||
expect(contentBlockText({ type: "image" })).toBe("[image]");
|
||||
expect(contentBlockText({ type: "resource_link", name: "spec.md" })).toBe("[resource: spec.md]");
|
||||
expect(contentBlockText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("counts a tool call's diff lines", () => {
|
||||
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
|
||||
.toBe("+2 −1");
|
||||
expect(toolDiffMeta([])).toBe("+0 −0");
|
||||
});
|
||||
|
||||
it("shows a status only while it is worth showing", () => {
|
||||
expect(statusMeta("in_progress", false)).toEqual({ text: "running", kind: "running" });
|
||||
expect(statusMeta("failed", true)).toEqual({ text: "failed", kind: "failed" });
|
||||
expect(statusMeta("pending", true)).toBeNull(); // the diff counts already say it
|
||||
expect(statusMeta("completed", false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notices", () => {
|
||||
it("reads a maestroTurnMeta footer, and only a well-formed one", () => {
|
||||
const meta = { elapsedMs: 4200, outcome: "completed", model: "sonnet" };
|
||||
expect(turnMeta(row("n1", "notice", { maestroTurnMeta: meta }))).toEqual(meta);
|
||||
expect(turnMeta(row("n2", "notice", { maestroTurnMeta: { outcome: "completed" } }))).toBeNull();
|
||||
expect(turnMeta(row("n3", "notice", { sessionUpdate: "session_info_update" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the state-only notices the client consumes rather than shows", () => {
|
||||
expect(isStateOnlyNotice(row("n1", "notice", { sessionUpdate: "available_commands_update" }))).toBe(true);
|
||||
expect(isStateOnlyNotice(row("n2", "notice", { sessionUpdate: "session_info_update" }))).toBe(true);
|
||||
expect(isStateOnlyNotice(row("n3", "notice", { sessionUpdate: "something_else" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvals", () => {
|
||||
it("offers only decisions the request actually backs", () => {
|
||||
expect(
|
||||
offeredDecisions([
|
||||
{ optionId: "a", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "r", name: "Reject", kind: "reject_once" },
|
||||
]),
|
||||
).toEqual(["allow_once", "deny"]);
|
||||
expect(offeredDecisions([{ optionId: "a", name: "Always", kind: "allow_always" }])).toEqual([
|
||||
"allow_for_session",
|
||||
]);
|
||||
expect(offeredDecisions([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("points a live pending approval at the newest approval row", () => {
|
||||
const rows = [row("approval:1", "approval"), tool("tool:1"), row("approval:2", "approval")];
|
||||
const pending: ReducedState = {
|
||||
...idle,
|
||||
pending_approval: { nonce: "n", request: {} as never },
|
||||
};
|
||||
expect(pendingApprovalRowId(rows, pending)).toBe("approval:2");
|
||||
expect(pendingApprovalRowId(rows, idle)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity grouping", () => {
|
||||
it("groups a turn's tools once there are two of them", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
|
||||
const groups = activityGroupsByTurn(rows, null, false);
|
||||
expect([...groups.keys()]).toEqual(["turn:user:1"]);
|
||||
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 });
|
||||
});
|
||||
|
||||
it("leaves a single tool call standalone", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1")];
|
||||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||||
});
|
||||
|
||||
it("absorbs intermediate narration but keeps the final answer outside", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
text("agent:1", "agent_text", "working"),
|
||||
tool("tool:2"),
|
||||
text("agent:2", "agent_text", "done"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
expect(group.toolCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the newest thought outside while the turn is still live", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
tool("tool:2"),
|
||||
text("thought:1", "agent_thought", "hmm"),
|
||||
text("thought:2", "agent_thought", "aha"),
|
||||
];
|
||||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
|
||||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
||||
});
|
||||
|
||||
it("absorbs a resolved approval, never the pending one", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
row("approval:1", "approval"),
|
||||
tool("tool:2"),
|
||||
row("approval:2", "approval"),
|
||||
tool("tool:3"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcript render list", () => {
|
||||
it("renders a group once, at its first member's position", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2"), text("agent:1", "agent_text", "done")];
|
||||
const items = transcriptItems(rows, null, false);
|
||||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
|
||||
"user:1",
|
||||
"turn:user:1",
|
||||
"agent:1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits plan rows, state-only notices and standalone resolved approvals", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
row("plan", "plan", { entries: [] }),
|
||||
row("notice:1", "notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
|
||||
row("approval:1", "approval"),
|
||||
text("agent:1", "agent_text", "done"),
|
||||
];
|
||||
const items = transcriptItems(rows, null, false);
|
||||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual(["user:1", "agent:1"]);
|
||||
});
|
||||
|
||||
it("keeps a turn footer only while it is the final item", () => {
|
||||
const footer = row("turn_meta:1", "notice", { maestroTurnMeta: { elapsedMs: 10, outcome: "completed" } });
|
||||
expect(transcriptItems([text("user:1", "user", "go"), footer], null, false)).toHaveLength(2);
|
||||
const later = [text("user:1", "user", "go"), footer, text("user:2", "user", "again")];
|
||||
expect(transcriptItems(later, null, false).map((i) => (i.type === "row" ? i.row.id : i.id))).toEqual([
|
||||
"user:1",
|
||||
"user:2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer commands", () => {
|
||||
const notice = row("notice:9", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "compact", description: "Compact the\n conversation" },
|
||||
{ name: "cost", description: "Show cost", input: { hint: "[period]" } },
|
||||
{ name: 42 },
|
||||
],
|
||||
});
|
||||
|
||||
it("takes the newest available_commands_update, skipping malformed entries", () => {
|
||||
const stale = row("notice:1", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "old", description: "gone" }],
|
||||
});
|
||||
expect(commandsFromTranscript([stale, notice]).map((c) => c.name)).toEqual(["compact", "cost"]);
|
||||
expect(commandsFromTranscript([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("only matches a /token under the caret", () => {
|
||||
expect(commandTokenAt("/comp", 5, 5)).toEqual({ start: 0, end: 5, query: "comp" });
|
||||
expect(commandTokenAt("hello /co there", 9, 9)).toEqual({ start: 6, end: 9, query: "co" });
|
||||
expect(commandTokenAt("no slash", 3, 3)).toBeNull();
|
||||
expect(commandTokenAt("/comp", 2, 5)).toBeNull(); // a selection is not a caret
|
||||
});
|
||||
|
||||
it("ranks prefix matches ahead of substring matches", () => {
|
||||
const commands = commandsFromTranscript([notice]);
|
||||
expect(filterCommands(commands, "co").map((c) => c.name)).toEqual(["compact", "cost"]);
|
||||
expect(filterCommands(commands, "act").map((c) => c.name)).toEqual(["compact"]);
|
||||
});
|
||||
|
||||
it("replaces only the token under the caret", () => {
|
||||
const token = commandTokenAt("say /co now", 7, 7)!;
|
||||
expect(applyCommand("say /co now", token, "compact")).toEqual({ value: "say /compact now", cursor: 13 });
|
||||
const trailing = commandTokenAt("/co", 3, 3)!;
|
||||
expect(applyCommand("/co", trailing, "compact")).toEqual({ value: "/compact ", cursor: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("session config", () => {
|
||||
it("orders the menu by category, unknown categories last", () => {
|
||||
const options = [
|
||||
{ id: "d", category: null },
|
||||
{ id: "b", category: "thought_level" },
|
||||
{ id: "a", category: "model" },
|
||||
{ id: "c", category: "mode" },
|
||||
];
|
||||
expect(orderConfigOptions(options).map((o) => o.id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
// Pure transcript logic for the structured pane (#246) — the decisions
|
||||
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
|
||||
// they can be tested without a DOM or a socket. Ported from structured.ts's
|
||||
// private helpers and the parts of structuredRender.ts that were data, not
|
||||
// markup.
|
||||
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpToolCallContent,
|
||||
ReducedState,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow,
|
||||
TranscriptRowKind,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
|
||||
|
||||
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
|
||||
* never a blank label. */
|
||||
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",
|
||||
};
|
||||
|
||||
export 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. */
|
||||
export 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 };
|
||||
}
|
||||
|
||||
/** Content-block text, or a labelled placeholder for anything that isn't
|
||||
* text — never dropped silently, never fabricated. */
|
||||
export 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
/** `+added −removed` across every diff block in a tool call's content — real
|
||||
* derived data, never a fabricated duration. */
|
||||
export 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}`;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a completed,
|
||||
* cancelled or failed turn's durable footer — `null` for every other notice. */
|
||||
export function turnMeta(row: TranscriptRow): TurnMeta | null {
|
||||
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
|
||||
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const meta = value as Partial<TurnMeta>;
|
||||
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
|
||||
return meta as TurnMeta;
|
||||
}
|
||||
|
||||
/** Notices the web client consumes as state and omits from the visible
|
||||
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
|
||||
export function isStateOnlyNotice(row: TranscriptRow): boolean {
|
||||
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
|
||||
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
|
||||
return update === "available_commands_update" || update === "session_info_update";
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** The row id a live `pending_approval` names: the newest `Approval` row.
|
||||
* Only one approval is ever live (docs/STRUCTURED-VIEW.md, "One live
|
||||
* approval at a time"), so the last one is the pending one. */
|
||||
export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState): string | null {
|
||||
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
||||
}
|
||||
|
||||
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function turnIsActive(state: ReducedState): boolean {
|
||||
return state.turn.responding || state.turn.thinking;
|
||||
}
|
||||
|
||||
export interface ActivityGroup {
|
||||
/** Header label count — tool rows only, never absorbed text. */
|
||||
toolCount: number;
|
||||
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||
rows: TranscriptRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tool rows (and intermediate agent text / resolved approvals between
|
||||
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
|
||||
* row follows it in the same turn — it gets absorbed into the group so the
|
||||
* collapsed block preserves chronological order. Resolved approval rows (not
|
||||
* the currently pending one) are absorbed the same way. The *last* agent text
|
||||
* with no subsequent tool stays standalone (the prominent final answer).
|
||||
*
|
||||
* Returns groups keyed by `turn:<userId>`.
|
||||
*/
|
||||
export function activityGroupsByTurn(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
): Map<string, ActivityGroup> {
|
||||
// First: count tools per turn to know which turns qualify for grouping.
|
||||
const toolCounts = new Map<string, number>();
|
||||
let turnId = "before-first-message";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||
const gid = `turn:${turnId}`;
|
||||
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||
}
|
||||
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||
const groups = new Map<string, ActivityGroup>();
|
||||
turnId = "before-first-message";
|
||||
// Pending rows that might be intermediate (not yet confirmed by a following
|
||||
// tool). If no tool follows, they stay standalone.
|
||||
let pending: TranscriptRow[] = [];
|
||||
let currentGid = `turn:${turnId}`;
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") {
|
||||
turnId = row.id;
|
||||
currentGid = `turn:${turnId}`;
|
||||
pending = [];
|
||||
continue;
|
||||
}
|
||||
const count = toolCounts.get(currentGid) ?? 0;
|
||||
if (count < 2) continue;
|
||||
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
|
||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
||||
if (isIntermediateText || isResolvedApproval) {
|
||||
pending.push(row);
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
|
||||
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||
group.rows.push(...pending);
|
||||
pending = [];
|
||||
group.rows.push(row);
|
||||
groups.set(currentGid, group);
|
||||
}
|
||||
}
|
||||
// A final agent text is the answer, so it stays outside. Thought rows are
|
||||
// working commentary, though: when a turn already has a disclosure they
|
||||
// belong with its activity even if the next tool call has not arrived yet.
|
||||
// While the turn is still live the most recent one stays outside as visible
|
||||
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
|
||||
const lastGroup = groups.get(currentGid);
|
||||
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
||||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
||||
lastGroup.rows.push(...absorb);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** One entry of the flattened render list: either a standalone row or a whole
|
||||
* activity group. */
|
||||
export type TranscriptItem =
|
||||
| { type: "row"; row: TranscriptRow }
|
||||
| { type: "group"; id: string; group: ActivityGroup };
|
||||
|
||||
/**
|
||||
* The transcript in render order: rows, with each turn's tool activity folded
|
||||
* into a single group item where one exists. Resolved approvals not absorbed
|
||||
* into a group are dropped — the tool row itself already identifies the
|
||||
* action. `plan` rows and state-only notices are dropped too (both clients
|
||||
* omit `Plan` from the transcript). A turn footer only survives as the final
|
||||
* item, so old footers stop reading like transcript content.
|
||||
*/
|
||||
export function transcriptItems(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
): TranscriptItem[] {
|
||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
|
||||
const grouped = new Set<string>();
|
||||
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id);
|
||||
|
||||
const items: TranscriptItem[] = [];
|
||||
let turnId = "before-first-message";
|
||||
const emitted = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
const groupId = `turn:${turnId}`;
|
||||
const group = groups.get(groupId);
|
||||
if (group && grouped.has(row.id)) {
|
||||
// The group renders once, at its first member's position.
|
||||
if (!emitted.has(groupId)) {
|
||||
emitted.add(groupId);
|
||||
items.push({ type: "group", id: groupId, group });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "plan") continue;
|
||||
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
||||
// A resolved approval outside a group adds nothing the tool row does not
|
||||
// already say. A pending one always renders.
|
||||
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
||||
items.push({ type: "row", row });
|
||||
}
|
||||
|
||||
return items.filter(
|
||||
(item, index) =>
|
||||
!(item.type === "row" && turnMeta(item.row) !== null) || index === items.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composer: `/` command autocomplete off `available_commands_update` notices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AvailableCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
input?: { hint?: string };
|
||||
}
|
||||
|
||||
/** The live command list is whatever the most recent
|
||||
* `available_commands_update` notice carries (docs/STRUCTURED-VIEW.md). */
|
||||
export function commandsFromTranscript(rows: TranscriptRow[]): AvailableCommand[] {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
if (rows[i].kind !== "notice") continue;
|
||||
const data = rows[i].data as { sessionUpdate?: string; availableCommands?: unknown[] } | undefined;
|
||||
if (data?.sessionUpdate !== "available_commands_update") continue;
|
||||
return (data.availableCommands ?? []).flatMap((value) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const command = value as Partial<AvailableCommand>;
|
||||
if (typeof command.name !== "string" || typeof command.description !== "string") return [];
|
||||
return [{ name: command.name, description: command.description, input: command.input }];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface CommandToken {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** The whitespace-delimited token under the caret, when it is a `/command`
|
||||
* being typed. Completing one replaces only that token. */
|
||||
export function commandTokenAt(
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): CommandToken | null {
|
||||
if (selectionStart !== selectionEnd) return null;
|
||||
let start = selectionStart;
|
||||
while (start > 0 && !/\s/.test(value[start - 1])) start--;
|
||||
let end = selectionStart;
|
||||
while (end < value.length && !/\s/.test(value[end])) end++;
|
||||
const match = /^\/([^\s/]*)$/.exec(value.slice(start, selectionStart));
|
||||
return match ? { start, end, query: match[1].toLocaleLowerCase() } : null;
|
||||
}
|
||||
|
||||
/** Substring match, prefix matches first, then alphabetical. */
|
||||
export function filterCommands(commands: AvailableCommand[], query: string): AvailableCommand[] {
|
||||
return commands
|
||||
.filter((command) => command.name.toLocaleLowerCase().includes(query))
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.name.toLocaleLowerCase().startsWith(query);
|
||||
const bStarts = b.name.toLocaleLowerCase().startsWith(query);
|
||||
return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace the token under the caret with `/name`, returning the new draft and
|
||||
* where the caret lands. A trailing space is added only when the completion
|
||||
* ends the draft. */
|
||||
export function applyCommand(
|
||||
value: string,
|
||||
token: CommandToken,
|
||||
name: string,
|
||||
): { value: string; cursor: number } {
|
||||
const before = value.slice(0, token.start);
|
||||
const after = value.slice(token.end);
|
||||
let replacement = `/${name}`;
|
||||
let cursor = before.length + replacement.length;
|
||||
if (after.length === 0) {
|
||||
replacement += " ";
|
||||
cursor++;
|
||||
} else if (/^[ \t]/.test(after)) {
|
||||
cursor++;
|
||||
}
|
||||
return { value: before + replacement + after, cursor };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session config menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Category order the config menu sorts by; anything else lands after. */
|
||||
const CONFIG_CATEGORY_ORDER = ["model", "thought_level", "mode", "model_config"];
|
||||
|
||||
export function orderConfigOptions<T extends { category?: string | null }>(options: T[]): T[] {
|
||||
return [...options].sort((a, b) => {
|
||||
const rank = (category?: string | null) => CONFIG_CATEGORY_ORDER.indexOf(category ?? "");
|
||||
const aRank = rank(a.category);
|
||||
const bRank = rank(b.category);
|
||||
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
// What a tool row shows below its head, derived from `rawInput`/`rawOutput`.
|
||||
// The adapters emit a handful of recognisable payload shapes (a shell command,
|
||||
// an MCP call, a search, a plain function call); anything else falls through to
|
||||
// the raw JSON disclosure rather than being guessed at.
|
||||
|
||||
import type { AcpContentBlock, AcpToolCallContent, AcpToolCallLike } from "../../types.js";
|
||||
|
||||
/** One labelled `<pre>` block under a tool head. */
|
||||
export interface ToolDetail {
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolPayload {
|
||||
details: ToolDetail[];
|
||||
/** A shell command's exit code, when the output carried one. */
|
||||
exitCode: number | null;
|
||||
raw: { input: string | null; output: string | null };
|
||||
}
|
||||
|
||||
function printable(value: unknown): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonPrintable(value: unknown): string | null {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? null;
|
||||
} catch {
|
||||
return printable(value);
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Push a detail unless it is empty, or repeats text the content blocks above
|
||||
* already show verbatim. */
|
||||
function push(details: ToolDetail[], label: string, value: unknown, duplicates?: Set<string>): void {
|
||||
const text = printable(value);
|
||||
if (text !== null && text !== "" && !duplicates?.has(text)) details.push({ label, text });
|
||||
}
|
||||
|
||||
/** The text of every `content` block, so a `rawOutput` echoing it is not shown
|
||||
* a second time. */
|
||||
function contentTexts(content: AcpToolCallContent[]): Set<string> {
|
||||
return new Set(
|
||||
content
|
||||
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
|
||||
.filter((block) => block.content.type === "text")
|
||||
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
|
||||
);
|
||||
}
|
||||
|
||||
export function toolPayload(tool: AcpToolCallLike, content: AcpToolCallContent[]): ToolPayload {
|
||||
const details: ToolDetail[] = [];
|
||||
let exitCode: number | null = null;
|
||||
const input = record(tool.rawInput);
|
||||
const texts = contentTexts(content);
|
||||
|
||||
if (input) {
|
||||
if (typeof input.command === "string") {
|
||||
details.push({ label: "Command", text: input.command });
|
||||
push(details, "Working directory", input.cwd);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) {
|
||||
push(details, "Output", result.formatted_output ?? result.stdout ?? result.output, texts);
|
||||
if (typeof result.exit_code === "number") exitCode = result.exit_code;
|
||||
}
|
||||
} else if (typeof input.server === "string" && typeof input.tool === "string" && "arguments" in input) {
|
||||
push(details, "Arguments", input.arguments);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) {
|
||||
push(details, "Result", result.result, texts);
|
||||
push(details, "Error", result.error);
|
||||
}
|
||||
} else if (typeof input.query === "string" && (tool.kind === "search" || input.type === "webSearch")) {
|
||||
details.push({ label: "Query", text: input.query });
|
||||
push(details, "Action", input.action);
|
||||
} else if ("arguments" in input) {
|
||||
push(details, "Arguments", input.arguments);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) push(details, "Result", result.output, texts);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
details,
|
||||
exitCode,
|
||||
raw: { input: jsonPrintable(tool.rawInput), output: jsonPrintable(tool.rawOutput) },
|
||||
};
|
||||
}
|
||||
|
||||
/** A `data:` URL for an inline image/audio block, or `null` when the mime type
|
||||
* is absent or does not name that family — never a URL built from an
|
||||
* unvalidated string. */
|
||||
export function safeMediaSource(
|
||||
mimeType: string | undefined,
|
||||
data: string | undefined,
|
||||
family: "image" | "audio",
|
||||
): string | null {
|
||||
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
|
||||
return `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// The `WS /api/structured/:id` half of the pane (#246). Every connect — fresh
|
||||
// or reconnect — resends the whole transcript rather than resuming a cursor
|
||||
// (docs/STRUCTURED-VIEW.md), so a snapshot replaces state wholesale instead of
|
||||
// merging into what came before.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { structuredWsUrl } from "../../routes.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
||||
import type {
|
||||
ReducedState,
|
||||
StructuredEvent,
|
||||
TranscriptDelta,
|
||||
TranscriptRow,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
|
||||
export type StructuredStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
||||
|
||||
/** Injectable so tests can drive the socket without a daemon — the same seam
|
||||
* structured.ts used. */
|
||||
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
|
||||
|
||||
const idleState = (): ReducedState => ({
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
});
|
||||
|
||||
function applyDelta(rows: TranscriptRow[], delta: TranscriptDelta): TranscriptRow[] {
|
||||
if (delta.type === "append") {
|
||||
// A patch can arrive for a row this connection has never seen (its
|
||||
// snapshot predates it); treating an unknown id as an append keeps the
|
||||
// transcript complete rather than dropping the row.
|
||||
const at = rows.findIndex((row) => row.id === delta.row.id);
|
||||
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
|
||||
}
|
||||
if (delta.type === "patch") {
|
||||
const at = rows.findIndex((row) => row.id === delta.row.id);
|
||||
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
|
||||
}
|
||||
return rows.filter((row) => row.id !== delta.id);
|
||||
}
|
||||
|
||||
export interface StructuredSession {
|
||||
rows: TranscriptRow[];
|
||||
state: ReducedState;
|
||||
status: StructuredStatus;
|
||||
/** A daemon-supplied close reason (code ≥ 4000), when there is one. */
|
||||
detail: string | null;
|
||||
/** The number of sockets this pane has opened, newest last. A submission
|
||||
* that straddles a reconnect uses it to notice its acceptance may predate
|
||||
* the current snapshot. */
|
||||
socketGeneration: number;
|
||||
/** Drop the socket and reconnect, so the next snapshot cannot predate a
|
||||
* POST that has already been accepted. */
|
||||
reconnect(): void;
|
||||
}
|
||||
|
||||
export interface UseStructuredSessionOptions {
|
||||
shouldReconnect?: (code: number) => boolean;
|
||||
onClosed?: () => void;
|
||||
socketFactory?: StructuredSocketFactory;
|
||||
}
|
||||
|
||||
/** Application-defined close codes (daemon rejection) and the clean-close code
|
||||
* — retrying either would only loop. */
|
||||
function defaultShouldReconnect(code: number): boolean {
|
||||
return code < 4000 && code !== 1000;
|
||||
}
|
||||
|
||||
export function useStructuredSession(
|
||||
sessionId: Uuid,
|
||||
{
|
||||
shouldReconnect = defaultShouldReconnect,
|
||||
onClosed,
|
||||
socketFactory = (id) => new WebSocket(structuredWsUrl(id)),
|
||||
}: UseStructuredSessionOptions = {},
|
||||
): StructuredSession {
|
||||
const [rows, setRows] = useState<TranscriptRow[]>([]);
|
||||
const [state, setState] = useState<ReducedState>(idleState);
|
||||
const [status, setStatus] = useState<StructuredStatus>("connecting");
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const [socketGeneration, setSocketGeneration] = useState(0);
|
||||
|
||||
// Read through refs so a changed callback never tears the socket down.
|
||||
const shouldReconnectRef = useRef(shouldReconnect);
|
||||
const onClosedRef = useRef(onClosed);
|
||||
const factoryRef = useRef(socketFactory);
|
||||
shouldReconnectRef.current = shouldReconnect;
|
||||
onClosedRef.current = onClosed;
|
||||
factoryRef.current = socketFactory;
|
||||
|
||||
const reconnectRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let timer: number | null = null;
|
||||
|
||||
const closeSocket = () => {
|
||||
const existing = socket;
|
||||
socket = null;
|
||||
if (!existing) return;
|
||||
existing.onopen = null;
|
||||
existing.onmessage = null;
|
||||
existing.onerror = null;
|
||||
existing.onclose = null;
|
||||
try {
|
||||
existing.close();
|
||||
} catch {
|
||||
// ignore — an already-closing socket has nothing to close
|
||||
}
|
||||
};
|
||||
|
||||
const cancelTimer = () => {
|
||||
if (timer === null) return;
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
if (disposed) return;
|
||||
cancelTimer();
|
||||
closeSocket();
|
||||
const current = factoryRef.current(sessionId);
|
||||
socket = current;
|
||||
setSocketGeneration((n) => n + 1);
|
||||
|
||||
current.onopen = () => {
|
||||
if (disposed || socket !== current) return;
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setDetail(null);
|
||||
setStatus("open");
|
||||
};
|
||||
|
||||
current.onmessage = (ev) => {
|
||||
if (disposed || socket !== current) 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":
|
||||
setRows([...msg.snapshot.rows]);
|
||||
break;
|
||||
case "reduced_state":
|
||||
setState(msg.state);
|
||||
break;
|
||||
case "projection_update":
|
||||
if (msg.transcript) {
|
||||
const delta = msg.transcript;
|
||||
setRows((previous) => applyDelta(previous, delta));
|
||||
}
|
||||
setState(msg.state);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
current.onerror = () => {
|
||||
if (disposed || socket !== current) return;
|
||||
setStatus("error");
|
||||
};
|
||||
|
||||
current.onclose = (ev) => {
|
||||
if (disposed || socket !== current) return;
|
||||
socket = null;
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason) {
|
||||
setDetail(reason);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (shouldReconnectRef.current(ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setStatus("reconnecting");
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
if (disposed) return;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
setDetail(String(ev.code));
|
||||
setStatus("closed");
|
||||
onClosedRef.current?.();
|
||||
};
|
||||
}
|
||||
|
||||
reconnectRef.current = () => {
|
||||
if (disposed) return;
|
||||
setStatus("reconnecting");
|
||||
connect();
|
||||
};
|
||||
|
||||
setStatus("connecting");
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
reconnectRef.current = () => {};
|
||||
cancelTimer();
|
||||
closeSocket();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const reconnect = useCallback(() => reconnectRef.current(), []);
|
||||
|
||||
return useMemo(
|
||||
() => ({ rows, state, status, detail, socketGeneration, reconnect }),
|
||||
[rows, state, status, detail, socketGeneration, reconnect],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
|
||||
// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
|
||||
// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
|
||||
// folds into the very next key, tapped here or typed on the soft keyboard,
|
||||
// then disarms.
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
|
||||
|
||||
type Modifier = "ctrl" | "alt";
|
||||
/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
|
||||
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
||||
|
||||
type KeyDef =
|
||||
| { label: string; mod: Modifier }
|
||||
| { label: string; cursor: CursorFinal }
|
||||
| { label: string; tilde: number }
|
||||
| { label: string; char: string }
|
||||
| { label: string; seq: string };
|
||||
|
||||
/**
|
||||
* Row-major into an 8-column / 2-row grid (#7):
|
||||
*
|
||||
* Esc Tab Home ↑ End PgUp - /
|
||||
* Ctrl Alt ← ↓ → PgDn | ~
|
||||
*/
|
||||
const KEY_DEFS: KeyDef[] = [
|
||||
{ label: "Esc", seq: "\x1b" },
|
||||
{ label: "Tab", seq: "\t" },
|
||||
{ label: "Home", cursor: "H" },
|
||||
{ label: "↑", cursor: "A" },
|
||||
{ label: "End", cursor: "F" },
|
||||
{ label: "PgUp", tilde: 5 },
|
||||
{ label: "-", char: "-" },
|
||||
{ label: "/", char: "/" },
|
||||
{ label: "Ctrl", mod: "ctrl" },
|
||||
{ label: "Alt", mod: "alt" },
|
||||
{ label: "←", cursor: "D" },
|
||||
{ label: "↓", cursor: "B" },
|
||||
{ label: "→", cursor: "C" },
|
||||
{ label: "PgDn", tilde: 6 },
|
||||
{ label: "|", char: "|" },
|
||||
{ label: "~", char: "~" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
|
||||
* character before it reaches the PTY — used by the terminal's own `onData`
|
||||
* handler, not by this component's own button clicks.
|
||||
*/
|
||||
export function useKeyBarModifiers() {
|
||||
const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
|
||||
const [, setTick] = useState(0);
|
||||
const rerender = useCallback(() => setTick((n) => n + 1), []);
|
||||
|
||||
const setMod = useCallback(
|
||||
(mod: Modifier, on: boolean) => {
|
||||
armedRef.current[mod] = on;
|
||||
rerender();
|
||||
},
|
||||
[rerender],
|
||||
);
|
||||
|
||||
const clearMods = useCallback(() => {
|
||||
if (!armedRef.current.ctrl && !armedRef.current.alt) return;
|
||||
armedRef.current = { ctrl: false, alt: false };
|
||||
rerender();
|
||||
}, [rerender]);
|
||||
|
||||
const consume = useCallback((data: string): string => {
|
||||
const { ctrl, alt } = armedRef.current;
|
||||
if ((ctrl || alt) && data.length === 1) {
|
||||
const out = applyModsToChar(data, ctrl, alt);
|
||||
armedRef.current = { ctrl: false, alt: false };
|
||||
return out;
|
||||
}
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
return { armed: armedRef.current, setMod, clearMods, consume };
|
||||
}
|
||||
|
||||
export interface KeyBarProps {
|
||||
armed: { ctrl: boolean; alt: boolean };
|
||||
onToggleMod(mod: Modifier): void;
|
||||
onClearMods(): void;
|
||||
onSend(data: string): void;
|
||||
}
|
||||
|
||||
export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Special keys"
|
||||
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
|
||||
>
|
||||
{KEY_DEFS.map((def) => {
|
||||
if ("mod" in def) {
|
||||
return (
|
||||
<button
|
||||
key={def.label}
|
||||
type="button"
|
||||
aria-pressed={armed[def.mod]}
|
||||
aria-label={`${def.label} modifier`}
|
||||
className="min-w-8 rounded px-2 py-1 text-xs aria-pressed:bg-emerald-500/40"
|
||||
onClick={() => onToggleMod(def.mod)}
|
||||
>
|
||||
{def.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const bytes = () => {
|
||||
if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
|
||||
if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
|
||||
if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
|
||||
return def.seq;
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={def.label}
|
||||
type="button"
|
||||
className="min-w-8 rounded px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
onSend(bytes());
|
||||
onClearMods();
|
||||
}}
|
||||
>
|
||||
{def.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
||||
import { attachWsUrl } from "../../routes.js";
|
||||
import type { ClientControl } from "../../types.js";
|
||||
|
||||
class FakeTerminal {
|
||||
static instances: FakeTerminal[] = [];
|
||||
cols = 80;
|
||||
rows = 24;
|
||||
element: HTMLElement | undefined = undefined;
|
||||
textarea: HTMLTextAreaElement | null = null;
|
||||
disposed = false;
|
||||
focus = vi.fn();
|
||||
private dataCb: ((d: string) => void) | null = null;
|
||||
|
||||
constructor(public opts: unknown) {
|
||||
FakeTerminal.instances.push(this);
|
||||
}
|
||||
loadAddon(): void {}
|
||||
open(container: HTMLElement): void {
|
||||
container.appendChild(document.createElement("div"));
|
||||
}
|
||||
onData(cb: (d: string) => void) {
|
||||
this.dataCb = cb;
|
||||
return { dispose: vi.fn() };
|
||||
}
|
||||
emitData(d: string): void {
|
||||
this.dataCb?.(d);
|
||||
}
|
||||
onResize() {
|
||||
return { dispose: vi.fn() };
|
||||
}
|
||||
onSelectionChange() {
|
||||
return { dispose: vi.fn() };
|
||||
}
|
||||
write(_data: Uint8Array, cb?: () => void): void {
|
||||
cb?.();
|
||||
}
|
||||
scrollToBottom(): void {}
|
||||
resize(cols: number, rows: number): void {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
}
|
||||
clearTextureAtlas(): void {}
|
||||
getSelection(): string {
|
||||
return "";
|
||||
}
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFitAddon {
|
||||
fit = vi.fn();
|
||||
}
|
||||
|
||||
class FakeWebglAddon {
|
||||
static instances: FakeWebglAddon[] = [];
|
||||
disposed = false;
|
||||
private lossCb: (() => void) | null = null;
|
||||
constructor() {
|
||||
FakeWebglAddon.instances.push(this);
|
||||
}
|
||||
onContextLoss(cb: () => void): void {
|
||||
this.lossCb = cb;
|
||||
}
|
||||
triggerLoss(): void {
|
||||
this.lossCb?.();
|
||||
}
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const policyEnabled = vi.fn(() => true);
|
||||
const policyNoteLoss = vi.fn();
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
|
||||
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
|
||||
vi.mock("../../webglRenderer.js", () => ({
|
||||
WebglPolicy: vi.fn().mockImplementation(() => ({
|
||||
enabled: policyEnabled,
|
||||
noteContextLoss: policyNoteLoss,
|
||||
})),
|
||||
}));
|
||||
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
binaryType = "";
|
||||
sent: (string | ArrayBufferView)[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: unknown }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
||||
|
||||
constructor(public url: string) {
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
send(data: string | ArrayBufferView): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close(): void {
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
}
|
||||
open(): void {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
drop(code = 1006, reason = ""): void {
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.onclose?.({ code, reason });
|
||||
}
|
||||
lastControl(): ClientControl {
|
||||
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
FakeTerminal.instances = [];
|
||||
FakeWebglAddon.instances = [];
|
||||
FakeWebSocket.instances = [];
|
||||
policyEnabled.mockReturnValue(true);
|
||||
policyEnabled.mockClear();
|
||||
policyNoteLoss.mockClear();
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
|
||||
const { TerminalPane } = await import("./TerminalPane.js");
|
||||
|
||||
describe("TerminalPane lifecycle", () => {
|
||||
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
|
||||
expect(FakeTerminal.instances).toHaveLength(1);
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
|
||||
});
|
||||
|
||||
it("sends the terminal size once the socket opens", () => {
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
const sock = FakeWebSocket.instances[0];
|
||||
|
||||
sock.open();
|
||||
|
||||
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
|
||||
});
|
||||
|
||||
it("disposes the terminal and closes the socket on unmount", () => {
|
||||
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
||||
const term = FakeTerminal.instances[0];
|
||||
const sock = FakeWebSocket.instances[0];
|
||||
|
||||
unmount();
|
||||
|
||||
expect(term.disposed).toBe(true);
|
||||
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
});
|
||||
|
||||
it("reconnects to a fresh socket for a new sessionId", () => {
|
||||
const { rerender } = render(<TerminalPane sessionId="s1" />);
|
||||
rerender(<TerminalPane sessionId="s2" />);
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
|
||||
expect(FakeTerminal.instances[0].disposed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalPane reconnect backoff", () => {
|
||||
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
|
||||
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
|
||||
const expectStillWaiting = (ms: number, countBefore: number) => {
|
||||
vi.advanceTimersByTime(ms - 1);
|
||||
expect(FakeWebSocket.instances).toHaveLength(countBefore);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
|
||||
};
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
drop();
|
||||
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
|
||||
drop();
|
||||
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
|
||||
drop();
|
||||
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
|
||||
drop();
|
||||
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
|
||||
drop();
|
||||
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
|
||||
drop();
|
||||
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
|
||||
drop();
|
||||
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
|
||||
});
|
||||
|
||||
it("resets the backoff once a reconnected socket opens", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
|
||||
FakeWebSocket.instances[0].drop(1006, "");
|
||||
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
|
||||
FakeWebSocket.instances[1].open();
|
||||
FakeWebSocket.instances[1].drop(1006, "");
|
||||
|
||||
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(FakeWebSocket.instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not retry a clean close, and reports it closed", () => {
|
||||
vi.useFakeTimers();
|
||||
const onClosed = vi.fn();
|
||||
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
||||
|
||||
FakeWebSocket.instances[0].drop(1000, "");
|
||||
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
expect(onClosed).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not retry an application-level rejection, and writes it into the terminal", () => {
|
||||
vi.useFakeTimers();
|
||||
const onClosed = vi.fn();
|
||||
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
||||
const term = FakeTerminal.instances[0];
|
||||
const writeSpy = vi.spyOn(term, "write");
|
||||
|
||||
FakeWebSocket.instances[0].drop(4004, "unknown session");
|
||||
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
expect(onClosed).not.toHaveBeenCalled();
|
||||
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
|
||||
});
|
||||
|
||||
it("honors a caller-supplied shouldReconnect policy", () => {
|
||||
vi.useFakeTimers();
|
||||
const onClosed = vi.fn();
|
||||
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
|
||||
|
||||
FakeWebSocket.instances[0].drop(1006, "");
|
||||
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
expect(onClosed).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalPane WebGL context budget", () => {
|
||||
it("acquires a WebGL context on mount, shared policy allowing", () => {
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
|
||||
expect(policyEnabled).toHaveBeenCalled();
|
||||
expect(FakeWebglAddon.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("never creates a context once the shared budget is exhausted", () => {
|
||||
policyEnabled.mockReturnValue(false);
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
|
||||
expect(FakeWebglAddon.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("releases its context on unmount, handing the budget back", () => {
|
||||
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
||||
const addon = FakeWebglAddon.instances[0];
|
||||
|
||||
unmount();
|
||||
|
||||
expect(addon.disposed).toBe(true);
|
||||
});
|
||||
|
||||
it("counts a context loss and re-acquires while still mounted", () => {
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
const first = FakeWebglAddon.instances[0];
|
||||
|
||||
first.triggerLoss();
|
||||
|
||||
expect(policyNoteLoss).toHaveBeenCalledOnce();
|
||||
expect(first.disposed).toBe(true);
|
||||
expect(FakeWebglAddon.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not re-acquire a lost context once the budget is spent", () => {
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
const first = FakeWebglAddon.instances[0];
|
||||
policyEnabled.mockReturnValue(false);
|
||||
|
||||
first.triggerLoss();
|
||||
|
||||
expect(FakeWebglAddon.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalPane imperative handle", () => {
|
||||
it("sendInput writes bytes to the PTY only once the socket is open", () => {
|
||||
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
||||
render(<TerminalPane sessionId="s1" ref={ref} />);
|
||||
const sock = FakeWebSocket.instances[0];
|
||||
|
||||
ref.current!.sendInput("nope");
|
||||
expect(sock.sent).toHaveLength(0);
|
||||
|
||||
sock.open();
|
||||
sock.sent.length = 0; // drop the resize control frame from open()
|
||||
ref.current!.sendInput("hi");
|
||||
|
||||
expect(sock.sent).toHaveLength(1);
|
||||
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
|
||||
});
|
||||
|
||||
it("focusTerminal focuses the underlying xterm instance", () => {
|
||||
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
||||
render(<TerminalPane sessionId="s1" ref={ref} />);
|
||||
|
||||
ref.current!.focusTerminal();
|
||||
|
||||
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("routes typed keystrokes through to the socket", () => {
|
||||
render(<TerminalPane sessionId="s1" />);
|
||||
const sock = FakeWebSocket.instances[0];
|
||||
sock.open();
|
||||
sock.sent.length = 0;
|
||||
|
||||
FakeTerminal.instances[0].emitData("q");
|
||||
|
||||
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
// React port of ../../attach.ts: an xterm terminal bound to the daemon's
|
||||
// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
|
||||
// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
|
||||
// button, sessions drawer) stays out of scope here — this is the thin
|
||||
// ref-based xterm wrapper the issue asked for; a header composes around it.
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
||||
import { attachWsUrl } from "../../routes.js";
|
||||
import type { ClientControl, Uuid } from "../../types.js";
|
||||
import { WebglPolicy } from "../../webglRenderer.js";
|
||||
import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
|
||||
|
||||
/** One budget for the whole page — every mounted `TerminalPane` shares it, the
|
||||
* same reasoning as attach.ts's own module-level policy. */
|
||||
const webglPolicy = new WebglPolicy();
|
||||
|
||||
export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
||||
|
||||
/** Application-defined close codes (daemon rejection) and the clean-close
|
||||
* code — retrying either would only loop. Mirrors reconnect.ts's
|
||||
* `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
|
||||
function defaultShouldReconnect(code: number): boolean {
|
||||
return code < 4000 && code !== 1000;
|
||||
}
|
||||
|
||||
export interface TerminalPaneProps {
|
||||
sessionId: Uuid;
|
||||
/** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
|
||||
focus?: boolean;
|
||||
/** Asked on every close: should the socket be retried? */
|
||||
shouldReconnect?: (code: number) => boolean;
|
||||
/** The socket closed for good (retry declined, or the daemon rejected the
|
||||
* session) — not called on an ordinary unmount. */
|
||||
onClosed?: () => void;
|
||||
/** Connection status changes, for a header/status badge to render. */
|
||||
onStatusChange?: (status: TerminalStatus, detail?: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TerminalPaneHandle {
|
||||
focusTerminal(): void;
|
||||
sendInput(data: string): void;
|
||||
}
|
||||
|
||||
export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
||||
function TerminalPane(
|
||||
{ sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
|
||||
ref,
|
||||
) {
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const sendInputRef = useRef<(data: string) => void>(() => {});
|
||||
const focusTerminalRef = useRef<() => void>(() => {});
|
||||
const keyBar = useKeyBarModifiers();
|
||||
const [keybarActive, setKeybarActive] = useState(false);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusTerminal: () => focusTerminalRef.current(),
|
||||
sendInput: (data: string) => sendInputRef.current(data),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const body = bodyRef.current;
|
||||
if (!body) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 13,
|
||||
convertEol: false,
|
||||
theme: {
|
||||
background: "#121419",
|
||||
foreground: "#e6e8ec",
|
||||
cursor: "#5fe3a1",
|
||||
cursorAccent: "#121419",
|
||||
selectionBackground: "#2b6b4f",
|
||||
},
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(body);
|
||||
|
||||
let webgl: WebglAddon | null = null;
|
||||
let dprMedia: MediaQueryList | null = null;
|
||||
|
||||
const onDprChange = () => {
|
||||
try {
|
||||
term.clearTextureAtlas();
|
||||
} catch {
|
||||
// ignore — nothing to rebuild if the renderer already went away
|
||||
}
|
||||
watchDpr();
|
||||
};
|
||||
|
||||
function watchDpr(): void {
|
||||
dprMedia?.removeEventListener("change", onDprChange);
|
||||
dprMedia = null;
|
||||
try {
|
||||
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
|
||||
dprMedia?.addEventListener("change", onDprChange);
|
||||
} catch {
|
||||
// ignore — a browser without `resolution` media queries just keeps
|
||||
// the atlas it has
|
||||
}
|
||||
}
|
||||
|
||||
function releaseWebgl(): void {
|
||||
dprMedia?.removeEventListener("change", onDprChange);
|
||||
dprMedia = null;
|
||||
if (!webgl) return;
|
||||
const addon = webgl;
|
||||
webgl = null;
|
||||
try {
|
||||
addon.dispose();
|
||||
} catch {
|
||||
// ignore — a lost context can throw on the way out
|
||||
}
|
||||
}
|
||||
|
||||
function acquireWebgl(): void {
|
||||
if (webgl || disposed || !webglPolicy.enabled()) return;
|
||||
try {
|
||||
const addon = new WebglAddon();
|
||||
addon.onContextLoss(() => {
|
||||
webglPolicy.noteContextLoss();
|
||||
releaseWebgl();
|
||||
if (mounted) acquireWebgl();
|
||||
});
|
||||
term.loadAddon(addon);
|
||||
webgl = addon;
|
||||
watchDpr();
|
||||
} catch {
|
||||
webglPolicy.noteContextLoss();
|
||||
webgl = null;
|
||||
}
|
||||
}
|
||||
|
||||
let mounted = false;
|
||||
const tryFit = () => {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
const el2 = term.element;
|
||||
const parent = el2?.parentElement;
|
||||
const core = (
|
||||
term as unknown as {
|
||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
|
||||
}
|
||||
)._core;
|
||||
const cell = core?._renderService?.dimensions?.css?.cell;
|
||||
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
|
||||
fit.fit();
|
||||
return;
|
||||
}
|
||||
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
|
||||
let availW: number;
|
||||
let availH: number;
|
||||
if (vp && vp.clientWidth > 0) {
|
||||
availW = vp.clientWidth;
|
||||
availH = vp.clientHeight;
|
||||
} else {
|
||||
const ps = getComputedStyle(parent);
|
||||
const ts = getComputedStyle(el2);
|
||||
availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
|
||||
availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
|
||||
}
|
||||
const cols = Math.max(2, Math.floor(availW / cell.width));
|
||||
const rows = Math.max(1, Math.floor(availH / cell.height));
|
||||
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
|
||||
} catch {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore — fit fails if the container is detached or zero-sized
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let ws: WebSocket;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
let awaitingReplay = true;
|
||||
let disposed = false;
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify(msg));
|
||||
};
|
||||
|
||||
const sendInput = (data: string) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
};
|
||||
sendInputRef.current = sendInput;
|
||||
focusTerminalRef.current = () => term.focus();
|
||||
|
||||
const onResize = () => {
|
||||
tryFit();
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
awaitingReplay = true;
|
||||
ws = new WebSocket(attachWsUrl(sessionId));
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
onStatusChange?.("open");
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
if (mounted && focus) term.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (typeof ev.data === "string") {
|
||||
try {
|
||||
JSON.parse(ev.data) as ClientControl;
|
||||
} catch {
|
||||
// ignore unknown control frames
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
const replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(ev.data), () => {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
} else if (ev.data instanceof Blob) {
|
||||
void ev.data.arrayBuffer().then((b) => {
|
||||
const replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(b), () => {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
onStatusChange?.("error");
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason && !disposed) {
|
||||
onStatusChange?.("error", reason);
|
||||
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
if (disposed) return;
|
||||
|
||||
if (shouldReconnect(ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
onStatusChange?.("reconnecting");
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
onStatusChange?.("closed", String(ev.code));
|
||||
onClosed?.();
|
||||
};
|
||||
}
|
||||
|
||||
onStatusChange?.("connecting");
|
||||
connect();
|
||||
|
||||
const dataDisp = term.onData((data) => {
|
||||
sendInput(keyBar.consume(data));
|
||||
});
|
||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||
sendControl({ type: "Resize", cols, rows });
|
||||
});
|
||||
const selectionDisp = term.onSelectionChange(() => {
|
||||
const text = term.getSelection();
|
||||
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
||||
});
|
||||
|
||||
const ta = term.textarea;
|
||||
const onTaFocus = () => {
|
||||
setKeybarActive(true);
|
||||
requestAnimationFrame(tryFit);
|
||||
};
|
||||
const onTaBlur = () => {
|
||||
setKeybarActive(false);
|
||||
requestAnimationFrame(tryFit);
|
||||
};
|
||||
ta?.addEventListener("focus", onTaFocus);
|
||||
ta?.addEventListener("blur", onTaBlur);
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
const visual = window.visualViewport ?? null;
|
||||
visual?.addEventListener("resize", onResize);
|
||||
|
||||
const ro = new ResizeObserver(() => tryFit());
|
||||
ro.observe(body);
|
||||
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
|
||||
|
||||
mounted = true;
|
||||
acquireWebgl();
|
||||
requestAnimationFrame(() => {
|
||||
tryFit();
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
if (focus && ws.readyState === WebSocket.OPEN) term.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
mounted = false;
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", onResize);
|
||||
visual?.removeEventListener("resize", onResize);
|
||||
ta?.removeEventListener("focus", onTaFocus);
|
||||
ta?.removeEventListener("blur", onTaBlur);
|
||||
dataDisp.dispose();
|
||||
resizeDisp.dispose();
|
||||
selectionDisp.dispose();
|
||||
releaseWebgl();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
term.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
|
||||
// a session change is a fresh connection, not a prop-driven re-render
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
|
||||
<div ref={bodyRef} className="min-h-0 flex-1" />
|
||||
{keybarActive && (
|
||||
<KeyBar
|
||||
armed={keyBar.armed}
|
||||
onToggleMod={(mod) => keyBar.setMod(mod, !keyBar.armed[mod])}
|
||||
onClearMods={keyBar.clearMods}
|
||||
onSend={(data) => sendInputRef.current(data)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -27,6 +27,7 @@ function fakeConn(kind: string, id: string) {
|
||||
sendInput(data: string) {
|
||||
sent.push({ id, data });
|
||||
},
|
||||
updateInfo() {},
|
||||
dispose() {
|
||||
disposed.push(`${kind}:${id}`);
|
||||
},
|
||||
|
||||
@@ -110,6 +110,7 @@ export class ConnectionPool {
|
||||
} else {
|
||||
this.cache.delete(id);
|
||||
this.cache.set(id, conn); // LRU refresh
|
||||
if (info) conn.updateInfo(info);
|
||||
}
|
||||
} else {
|
||||
conn = this.create(id, info);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { PALETTES, ThemeProvider, useTheme } from "./ThemeContext";
|
||||
|
||||
// happy-dom's vitest environment ships neither localStorage nor matchMedia;
|
||||
// back both with plain in-memory fakes so persistence and system-theme
|
||||
// reactivity are observable (mirrors ../theme.test.ts's localStorage fake).
|
||||
let store = new Map<string, string>();
|
||||
let systemPrefersLight = false;
|
||||
let mediaListeners: Array<(e: MediaQueryListEvent) => void> = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
value: (query: string) => ({
|
||||
matches: query.includes("light") ? systemPrefersLight : false,
|
||||
media: query,
|
||||
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
|
||||
mediaListeners.push(cb);
|
||||
},
|
||||
removeEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
|
||||
mediaListeners = mediaListeners.filter((l) => l !== cb);
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
function fireSystemThemeChange(matches: boolean): void {
|
||||
systemPrefersLight = matches;
|
||||
for (const cb of mediaListeners) {
|
||||
cb({ matches } as MediaQueryListEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
const { theme, palette, setTheme, toggleTheme, setPalette } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="palette">{palette}</span>
|
||||
<button onClick={() => setTheme("light")}>pin-light</button>
|
||||
<button onClick={toggleTheme}>toggle</button>
|
||||
<button onClick={() => setPalette("ember")}>set-ember</button>
|
||||
<button onClick={() => setPalette("graphite")}>set-graphite</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ThemeProvider / useTheme", () => {
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
mediaListeners = [];
|
||||
systemPrefersLight = false;
|
||||
delete document.documentElement.dataset.theme;
|
||||
delete document.documentElement.dataset.palette;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("defaults to graphite and the system theme, no data-palette attribute", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("theme").textContent).toBe("dark");
|
||||
expect(screen.getByTestId("palette").textContent).toBe("graphite");
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows a system theme change while unpinned", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
act(() => fireSystemThemeChange(true));
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
});
|
||||
|
||||
it("setTheme pins, persists, and stops following the system", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
act(() => screen.getByText("pin-light").click());
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
expect(localStorage.getItem("maestro-theme")).toBe("light");
|
||||
|
||||
act(() => fireSystemThemeChange(true));
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
});
|
||||
|
||||
it("toggleTheme flips the resolved theme", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
act(() => screen.getByText("toggle").click());
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
act(() => screen.getByText("toggle").click());
|
||||
expect(screen.getByTestId("theme").textContent).toBe("dark");
|
||||
});
|
||||
|
||||
it("setPalette stamps data-palette and persists; graphite clears it", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
act(() => screen.getByText("set-ember").click());
|
||||
expect(document.documentElement.dataset.palette).toBe("ember");
|
||||
expect(localStorage.getItem("maestro-palette")).toBe("ember");
|
||||
|
||||
act(() => screen.getByText("set-graphite").click());
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a fresh mount re-applies a stored palette", () => {
|
||||
localStorage.setItem("maestro-palette", "periwinkle");
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("palette").textContent).toBe("periwinkle");
|
||||
expect(document.documentElement.dataset.palette).toBe("periwinkle");
|
||||
});
|
||||
|
||||
it("exposes every palette in picker order", () => {
|
||||
expect(PALETTES).toEqual([
|
||||
"graphite",
|
||||
"ice-cyan",
|
||||
"periwinkle",
|
||||
"amber-phosphor",
|
||||
"ember",
|
||||
"orchid",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when used outside a provider", () => {
|
||||
const Bare = () => {
|
||||
useTheme();
|
||||
return null;
|
||||
};
|
||||
expect(() => render(<Bare />)).toThrow(
|
||||
"useTheme must be used within a ThemeProvider",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// React port of ../theme.ts. Same storage keys and `data-theme`/`data-palette`
|
||||
// attributes on <html>, so this can sit alongside the vanilla shell (which
|
||||
// still owns theme.ts) without either one fighting the other over state.
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const THEME_KEY = "maestro-theme";
|
||||
|
||||
/** Every `data-palette` value, in picker order. Graphite is the default. */
|
||||
export const PALETTES = [
|
||||
"graphite",
|
||||
"ice-cyan",
|
||||
"periwinkle",
|
||||
"amber-phosphor",
|
||||
"ember",
|
||||
"orchid",
|
||||
] as const;
|
||||
export type Palette = (typeof PALETTES)[number];
|
||||
const PALETTE_KEY = "maestro-palette";
|
||||
|
||||
function storedTheme(): Theme | null {
|
||||
const v = localStorage.getItem(THEME_KEY);
|
||||
return v === "light" || v === "dark" ? v : null;
|
||||
}
|
||||
|
||||
function storedPalette(): Palette | null {
|
||||
const v = localStorage.getItem(PALETTE_KEY);
|
||||
return (PALETTES as readonly string[]).includes(v ?? "")
|
||||
? (v as Palette)
|
||||
: null;
|
||||
}
|
||||
|
||||
function systemPrefersLight(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
}
|
||||
|
||||
// Dev-only `?theme=` override for visual review, same as theme.ts's — never
|
||||
// persisted, just seeds the initial pin for this session.
|
||||
function devThemeOverride(): Theme | null {
|
||||
if (!import.meta.env.DEV) return null;
|
||||
const q = new URLSearchParams(location.search).get("theme");
|
||||
return q === "light" || q === "dark" ? q : null;
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
palette: Palette;
|
||||
setTheme: (t: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
setPalette: (p: Palette) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [pinnedTheme, setPinnedTheme] = useState<Theme | null>(
|
||||
() => devThemeOverride() ?? storedTheme(),
|
||||
);
|
||||
const [systemIsLight, setSystemIsLight] = useState(systemPrefersLight);
|
||||
const [palette, setPaletteState] = useState<Palette>(
|
||||
() => storedPalette() ?? "graphite",
|
||||
);
|
||||
|
||||
const theme: Theme = pinnedTheme ?? (systemIsLight ? "light" : "dark");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mql = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const onChange = (e: MediaQueryListEvent) => setSystemIsLight(e.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
// Pinning is one-way (no UI un-pins back to "follow system"), so this only
|
||||
// ever needs to set the attribute — never clear it — same as theme.ts.
|
||||
useLayoutEffect(() => {
|
||||
if (pinnedTheme) document.documentElement.dataset.theme = pinnedTheme;
|
||||
}, [pinnedTheme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (palette === "graphite") delete document.documentElement.dataset.palette;
|
||||
else document.documentElement.dataset.palette = palette;
|
||||
}, [palette]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
setPinnedTheme(t);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setTheme]);
|
||||
|
||||
const setPalette = useCallback((p: Palette) => {
|
||||
localStorage.setItem(PALETTE_KEY, p);
|
||||
setPaletteState(p);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, palette, setTheme, toggleTheme, setPalette }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -130,6 +130,31 @@ describe("DaemonLink", () => {
|
||||
expect(link.status().state).toBe("online");
|
||||
});
|
||||
|
||||
it("rechecks immediately when the browser returns from the background", async () => {
|
||||
const sched = manual();
|
||||
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => {
|
||||
if (!reply) throw new Error("down");
|
||||
return reply;
|
||||
},
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
await link.start();
|
||||
|
||||
reply = null;
|
||||
link.noteDisconnected();
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
expect(link.status().state).toBe("down");
|
||||
|
||||
reply = info("aaa", "i1");
|
||||
link.recover();
|
||||
await Promise.resolve();
|
||||
expect(link.status().state).toBe("online");
|
||||
});
|
||||
|
||||
it("reloads the page when the daemon comes back on a new commit", async () => {
|
||||
const sched = manual();
|
||||
const reload = vi.fn();
|
||||
|
||||
@@ -148,6 +148,17 @@ export class DaemonLink {
|
||||
void this.probe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recheck liveness after the browser resumes from the background. Mobile
|
||||
* browsers may suspend both WebSocket events and timers while a PWA is
|
||||
* hidden, leaving a stale `down` state after the daemon is already back.
|
||||
*/
|
||||
recover(): void {
|
||||
if (this.state === "updating") return;
|
||||
this.noteDisconnected();
|
||||
void this.probe();
|
||||
}
|
||||
|
||||
/**
|
||||
* One `/api/info` round-trip. Resolves `true` when the daemon answered.
|
||||
*
|
||||
|
||||
@@ -132,6 +132,21 @@ describe("CatalogStore reducer", () => {
|
||||
});
|
||||
|
||||
describe("CatalogStore reconnect", () => {
|
||||
it("replaces a suspended socket immediately", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const { store } = storeWith([first, second]);
|
||||
store.start();
|
||||
|
||||
store.reconnect();
|
||||
|
||||
expect(first.closed).toBe(true);
|
||||
second.open();
|
||||
// A late close from the old socket is deliberately ignored.
|
||||
first.drop();
|
||||
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
|
||||
});
|
||||
|
||||
it("doubles the delay per attempt and caps it", () => {
|
||||
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
|
||||
const { store, scheduler } = storeWith(sockets);
|
||||
|
||||
@@ -59,6 +59,23 @@ export class CatalogStore {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/** Replace a socket that may have gone stale while the browser was suspended. */
|
||||
reconnect(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
this.scheduler.clearTimer(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
// A close event from the superseded socket must not schedule a second
|
||||
// connection alongside the new one.
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/** Current reconnect delay, for tests asserting the backoff schedule. */
|
||||
backoff(): number {
|
||||
return this.backoffMs;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./main.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("missing #root element");
|
||||
}
|
||||
|
||||
createRoot(container).render(<App />);
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
@@ -107,6 +107,19 @@ void link.start();
|
||||
|
||||
store.start();
|
||||
|
||||
// Installed mobile browsers suspend timers and WebSockets in the background.
|
||||
// Replacing the socket on return avoids leaving the restart overlay up after a
|
||||
// transient lost connection has already recovered.
|
||||
function recoverFromBackground(): void {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
link.recover();
|
||||
store.reconnect();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", recoverFromBackground);
|
||||
window.addEventListener("pageshow", recoverFromBackground);
|
||||
window.addEventListener("online", recoverFromBackground);
|
||||
|
||||
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
|
||||
// without a running daemon. Stripped from production builds by Vite.
|
||||
if (import.meta.env.DEV && location.search.includes("mock")) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// transcript (structured.ts).
|
||||
|
||||
import type { Uuid } from "./types.js";
|
||||
import type { AttachInfo } from "./paneTitle.js";
|
||||
|
||||
export interface PaneConnection {
|
||||
readonly sessionId: Uuid;
|
||||
@@ -21,6 +22,8 @@ export interface PaneConnection {
|
||||
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
|
||||
* has no PTY, so this is a no-op there. */
|
||||
sendInput(data: string): void;
|
||||
/** Refresh the pane header after the catalog renames a live session. */
|
||||
updateInfo(info: AttachInfo): void;
|
||||
/** Fully disconnect and remove the element. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user