Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ea463f6cf | ||
|
|
7c27a897ac | ||
|
|
a9717d01c8 | ||
|
|
35a6ff9d0f | ||
|
|
474000935a | ||
|
|
5f8cc2a140 | ||
|
|
432967fe64 | ||
|
|
2512b8be42 | ||
|
|
e3556bd847 | ||
|
|
ce0e4e6820 | ||
|
|
fa3d6c82b2 | ||
|
|
ebe25a1a55 | ||
|
|
923911f631 | ||
|
|
8cbe8510ac | ||
|
|
cd5bbcfcb1 | ||
|
|
cbbf526fc4 | ||
|
|
9f5b71d563 | ||
|
|
de603ccd20 | ||
|
|
c6af78e9dd | ||
|
|
7ab503e4f5 | ||
|
|
eaf11a7696 | ||
|
|
4738233580 | ||
|
|
cb25320ea0 | ||
|
|
ff52dd9812 | ||
|
|
f3a5cad789 | ||
|
|
fffd2a55f3 | ||
|
|
563a35d2ec | ||
|
|
9dead5652e | ||
|
|
cd2184b128 | ||
|
|
1b2e5f5f05 | ||
|
|
2c8956e634 | ||
|
|
f2c0882c36 | ||
|
|
c170cd9d60 | ||
|
|
ebf943bf30 | ||
|
|
645aa2dea0 | ||
|
|
3a2513c78d | ||
|
|
7cb52927f2 | ||
|
|
2724447a51 | ||
|
|
142e9a4870 | ||
|
|
e5c17606d0 | ||
|
|
979bfe2fbe | ||
|
|
8f96027878 | ||
|
|
b6f60bfbae | ||
|
|
a8a335eadb | ||
|
|
048e273ff9 | ||
|
|
beef2049ed | ||
|
|
c30592e6b6 | ||
|
|
abae81a0ce | ||
|
|
66a568818a | ||
|
|
f3d6146884 | ||
|
|
35ae01db93 | ||
|
|
8a05ef2eb7 | ||
|
|
ad71147bd9 | ||
|
|
e9c3882cea | ||
|
|
c9562ab5eb | ||
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 | ||
|
|
e0edbc09d2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
1b7ec1f54c | ||
|
|
ee61cea64f | ||
|
|
7e21371e47 | ||
|
|
52ae636dbb | ||
|
|
9d93e6e60e | ||
|
|
babdc678e0 | ||
|
|
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 | ||
|
|
3529947497 | ||
|
|
4851ad9101 | ||
|
|
3cb4bebd1e | ||
|
|
0e20baf7c2 | ||
|
|
af8acbfbf2 | ||
|
|
1ad7845f51 |
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -15,3 +15,6 @@ worktrees/
|
||||
|
||||
# `nix run .#dev` scratch state (tmux socket)
|
||||
/.dev/
|
||||
|
||||
# Per-machine Claude Code tool permissions
|
||||
.claude/settings.local.json
|
||||
|
||||
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
|
||||
|
||||
+446
-54
@@ -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,80 @@ 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";
|
||||
/// Item on the updates channel. Notifications and turn-done markers share
|
||||
/// the same FIFO so the event pump processes every notification for a turn
|
||||
/// before seeing its done signal.
|
||||
#[derive(Debug)]
|
||||
pub enum AcpUpdate {
|
||||
/// A raw `session/update` notification from the adapter.
|
||||
Notification(Box<SessionNotification>),
|
||||
/// The prompt response for `turn` arrived. All notifications for this
|
||||
/// turn precede this marker on the wire, and therefore in this channel.
|
||||
/// The event pump must fire `done` after persisting every preceding
|
||||
/// notification.
|
||||
TurnDone {
|
||||
turn: u64,
|
||||
result: Result<(), String>,
|
||||
done: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// ACP adapter selected from the structured session's resolved command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AcpAdapter {
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
}
|
||||
|
||||
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 +128,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 +213,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 +224,7 @@ impl SpawnConfig {
|
||||
env,
|
||||
handshake_timeout: HANDSHAKE_TIMEOUT,
|
||||
load_session: None,
|
||||
adapter: AcpAdapter::ClaudeCode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +233,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 +253,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 +303,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>),
|
||||
@@ -226,7 +372,7 @@ pub struct AcpSession {
|
||||
pid: u32,
|
||||
commands: mpsc::UnboundedSender<Command>,
|
||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
|
||||
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
||||
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||
exit: watch::Receiver<Option<ExitStatus>>,
|
||||
}
|
||||
@@ -265,9 +411,9 @@ impl AcpSession {
|
||||
wait_exit(&self.exit).await
|
||||
}
|
||||
|
||||
/// The stream of raw `session/update` notifications, handed out once.
|
||||
/// Notifications that arrived before the take are still queued on it.
|
||||
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
|
||||
/// The stream of updates (notifications + turn-done markers), handed out
|
||||
/// once. Items that arrived before the take are still queued.
|
||||
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<AcpUpdate>> {
|
||||
self.updates.lock().expect("updates mutex").take()
|
||||
}
|
||||
|
||||
@@ -301,6 +447,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 +504,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 +534,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 +563,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 +600,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 +635,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 +655,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 +663,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 +673,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,11 +692,105 @@ 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>,
|
||||
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
||||
config: ConnectionConfig,
|
||||
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||
@@ -534,6 +807,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 +818,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(AcpUpdate::Notification(Box::new(notification)));
|
||||
Ok(())
|
||||
},
|
||||
agent_client_protocol::on_receive_notification!(),
|
||||
@@ -589,11 +863,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 +877,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 +893,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 +905,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(AcpUpdate::Notification(Box::new(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(AcpUpdate::Notification(Box::new(config_notification(
|
||||
&session_id,
|
||||
config_options.lock().expect("config options").clone(),
|
||||
))));
|
||||
}
|
||||
let _ = ready_tx.send(Ok(session_id.clone()));
|
||||
|
||||
loop {
|
||||
@@ -666,16 +973,23 @@ async fn run_connection<T>(
|
||||
let done = Arc::new(Mutex::new(Some(done)));
|
||||
let callback_done = done.clone();
|
||||
let callback_turn = permission_turn.clone();
|
||||
let callback_updates = updates_tx.clone();
|
||||
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
||||
let callback_done = callback_done.clone();
|
||||
let callback_turn = callback_turn.clone();
|
||||
let callback_updates = callback_updates.clone();
|
||||
async move {
|
||||
callback_turn
|
||||
.lock()
|
||||
.expect("permission turn state")
|
||||
.finish(turn);
|
||||
// Route through the same channel as notifications so the
|
||||
// event pump sees this after every chunk for the turn.
|
||||
let result = result.map(|_| ()).map_err(|e| e.to_string());
|
||||
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
||||
let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string()));
|
||||
if callback_updates.send(AcpUpdate::TurnDone { turn, result, done }).is_err() {
|
||||
// Channel closed — nowhere left to signal.
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -689,6 +1003,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(AcpUpdate::Notification(Box::new(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(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
||||
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(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(AcpUpdate::Notification(Box::new(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 +1097,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();
|
||||
@@ -166,6 +193,9 @@ async fn session_updates_reach_the_taken_receiver() {
|
||||
.await
|
||||
.expect("update within 5s")
|
||||
.expect("channel open");
|
||||
let maestro_acp::AcpUpdate::Notification(first) = first else {
|
||||
panic!("expected Notification");
|
||||
};
|
||||
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
||||
match first.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
||||
@@ -180,11 +210,71 @@ 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");
|
||||
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||
continue;
|
||||
};
|
||||
if let SessionUpdate::ConfigOptionUpdate(options) = n.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 +315,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 +374,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}");
|
||||
}
|
||||
@@ -306,7 +396,10 @@ async fn drop_terminates_and_reaps_the_child() {
|
||||
fake.wait_for_exit().await;
|
||||
}
|
||||
|
||||
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
||||
fn chunk_text(update: &maestro_acp::AcpUpdate) -> String {
|
||||
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||
panic!("expected Notification, got {update:?}");
|
||||
};
|
||||
match &n.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||
ContentBlock::Text(t) => t.text.clone(),
|
||||
@@ -320,7 +413,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 +449,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 +467,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 +494,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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -125,7 +133,7 @@ pub struct KillArgs {
|
||||
/// Session ID to kill.
|
||||
pub session_id: Uuid,
|
||||
/// End the session even when its worktree holds unsaved work (#81). The
|
||||
/// worktree is left on disk — never removed.
|
||||
/// worktree is removed along with the session.
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||
@@ -51,16 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
};
|
||||
|
||||
let body = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
view: spawn_view(&args),
|
||||
cwd: args.cwd.clone(),
|
||||
command: args.command.clone(),
|
||||
env,
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve: false,
|
||||
};
|
||||
client.create_session(&body).await
|
||||
}
|
||||
|
||||
/// The view a `maestro spawn` asks for: structured whenever the command
|
||||
/// resolves to an agent with an ACP adapter, since that is the richer session
|
||||
/// and the one both other clients now default to. `--terminal` opts out;
|
||||
/// `--structured` forces the request through even for a command this side
|
||||
/// does not recognise, leaving the refusal to the daemon.
|
||||
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
||||
if args.terminal {
|
||||
return SessionView::Terminal;
|
||||
}
|
||||
if args.structured || supports_structured_view(&args.command) {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
}
|
||||
}
|
||||
|
||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||
/// the work went.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -199,6 +228,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
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).
|
||||
@@ -239,6 +269,62 @@ mod tests {
|
||||
|
||||
use super::DaemonInfo;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::cli::{Cli, Command};
|
||||
|
||||
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
||||
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
||||
argv.extend_from_slice(extra);
|
||||
let cli = Cli::try_parse_from(argv).expect("parses");
|
||||
let Command::Spawn(args) = cli.command else {
|
||||
panic!("expected spawn")
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
/// The CLI follows the two interactive clients: a command with an ACP
|
||||
/// adapter spawns structured unless `--terminal` says otherwise, and
|
||||
/// `--structured` still forces the request for a command this side does
|
||||
/// not recognise.
|
||||
#[test]
|
||||
fn spawn_defaults_to_structured_for_supported_agents() {
|
||||
use super::spawn_view;
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["claude"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["npx codex exec"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
||||
SessionView::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_refuses_both_view_flags_at_once() {
|
||||
let err = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"claude",
|
||||
"--structured",
|
||||
"--terminal",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("--terminal"), "{err}");
|
||||
}
|
||||
|
||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||
Session {
|
||||
view: Default::default(),
|
||||
@@ -259,6 +345,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, SessionError, 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,7 +180,7 @@ 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
|
||||
@@ -430,17 +437,58 @@ 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::InFlight) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"turn_in_flight",
|
||||
"the structured session already has an active turn",
|
||||
match s.manager.queue_acp_prompt(id, body.prompt, None).await {
|
||||
Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
|
||||
Ok(QueueAcpPrompt::Full) => error_response(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"queue_full",
|
||||
"the prompt queue has reached its capacity",
|
||||
),
|
||||
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
@@ -472,6 +520,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)>,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -83,26 +84,47 @@ impl Fake {
|
||||
}
|
||||
|
||||
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
|
||||
panic!("unexpected daemon event")
|
||||
};
|
||||
match ¬ification.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||
ContentBlock::Text(text) => &text.text,
|
||||
other => panic!("unexpected content {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected update {other:?}"),
|
||||
match &event.event {
|
||||
maestro_daemon::sidecar::PersistedEvent::Notification(notification) => {
|
||||
match ¬ification.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||
ContentBlock::Text(text) => &text.text,
|
||||
other => panic!("unexpected content {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected update {other:?}"),
|
||||
}
|
||||
}
|
||||
maestro_daemon::sidecar::PersistedEvent::User { row } => row
|
||||
.data
|
||||
.pointer("/content/text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("user row text"),
|
||||
maestro_daemon::sidecar::PersistedEvent::Turn { row } => row
|
||||
.data
|
||||
.pointer("/maestroTurnMeta/outcome")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("[turn]"),
|
||||
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
|
||||
}
|
||||
}
|
||||
|
||||
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 +142,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 +169,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 +199,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,16 +264,17 @@ 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
|
||||
.queue_acp_prompt(session.id, "fail".into())
|
||||
.queue_acp_prompt(session.id, "fail".into(), None)
|
||||
.await
|
||||
.unwrap(),
|
||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||
));
|
||||
assert_eq!(
|
||||
fx.manager
|
||||
@@ -248,10 +298,10 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
||||
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
.queue_acp_prompt(session.id, "succeed".into())
|
||||
.queue_acp_prompt(session.id, "succeed".into(), None)
|
||||
.await
|
||||
.unwrap(),
|
||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||
));
|
||||
assert_eq!(
|
||||
fx.manager
|
||||
@@ -281,11 +331,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 +400,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(), None)
|
||||
.await
|
||||
.expect("queue prompt"),
|
||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||
));
|
||||
|
||||
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 +480,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,18 +524,19 @@ 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
|
||||
.queue_acp_prompt(session.id, "first".into())
|
||||
.queue_acp_prompt(session.id, "first".into(), None)
|
||||
.await
|
||||
.unwrap(),
|
||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||
));
|
||||
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
||||
let second = fx
|
||||
.manager
|
||||
.queue_acp_prompt(session.id, "second".into())
|
||||
.queue_acp_prompt(session.id, "second".into(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -446,21 +563,22 @@ 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
|
||||
.queue_acp_prompt(session.id, "first".into())
|
||||
.queue_acp_prompt(session.id, "first".into(), None)
|
||||
.await
|
||||
.unwrap(),
|
||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||
));
|
||||
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
||||
assert!(matches!(
|
||||
fx.manager
|
||||
.queue_acp_prompt(session.id, "second".into())
|
||||
.queue_acp_prompt(session.id, "second".into(), None)
|
||||
.await
|
||||
.unwrap(),
|
||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
||||
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||
));
|
||||
|
||||
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
@@ -505,11 +623,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
|
||||
@@ -548,25 +667,18 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
||||
revived.status(session.id).await,
|
||||
Some(SessionStatus::Running)
|
||||
);
|
||||
assert_eq!(
|
||||
revived
|
||||
.snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == session.id)
|
||||
.unwrap()
|
||||
.activity,
|
||||
ActivityState::Idle
|
||||
);
|
||||
let log = fake.log();
|
||||
assert!(
|
||||
log.contains(&format!("recv session/load sessionId={acp_id}")),
|
||||
"{log}"
|
||||
);
|
||||
// Recovery adds: a continue-prompt User row, the adapter's post-load
|
||||
// notification, and a turn-completed footer — 5 events total before the
|
||||
// next manual tick.
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
revived
|
||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||
.is_ok_and(|events| events.len() == 3)
|
||||
.is_ok_and(|events| events.len() == 5)
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -575,7 +687,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
||||
.await
|
||||
.expect("replay read")
|
||||
.expect("recovered event subscription");
|
||||
assert_eq!(replay.len(), 3);
|
||||
assert_eq!(replay.len(), 5);
|
||||
assert!(live.try_recv().is_err(), "replay duplicated onto live");
|
||||
let new_pid = fake.pid();
|
||||
assert_ne!(new_pid, old_pid);
|
||||
@@ -586,16 +698,23 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
||||
.await
|
||||
.expect("live event after recovery")
|
||||
.expect("broadcast open");
|
||||
assert_eq!(event.seq, 4);
|
||||
assert_eq!(event.seq, 6);
|
||||
assert_eq!(event_text(&event), "tick 1");
|
||||
|
||||
let stored = revived
|
||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||
.expect("recovered log");
|
||||
assert_eq!(stored.len(), 4, "session/load replay must not be appended");
|
||||
assert_eq!(stored.len(), 6, "session/load replay must not be appended");
|
||||
assert_eq!(
|
||||
stored.iter().map(event_text).collect::<Vec<_>>(),
|
||||
["hello from fake", "tick 1", "hello after load", "tick 1"]
|
||||
[
|
||||
"hello from fake",
|
||||
"tick 1",
|
||||
"The daemon restarted. Continue where you left off.",
|
||||
"hello after load",
|
||||
"completed",
|
||||
"tick 1",
|
||||
]
|
||||
);
|
||||
|
||||
revived.kill(session.id, false).await.expect("kill");
|
||||
@@ -621,7 +740,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 +748,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 +802,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 +836,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 +900,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 +943,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 +979,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 +1037,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 +1071,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 +1115,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 +1316,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 +1347,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();
|
||||
|
||||
@@ -11,14 +11,14 @@ use common::TmuxFixture;
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::SessionManager;
|
||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Fixture {
|
||||
manager: SessionManager,
|
||||
sidecar: SidecarStore,
|
||||
_tmux: TmuxFixture,
|
||||
tmux: TmuxFixture,
|
||||
_state_dir: TempDir,
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
|
||||
Fixture {
|
||||
manager,
|
||||
sidecar,
|
||||
_tmux: tmux,
|
||||
tmux,
|
||||
_state_dir: state_dir,
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -170,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
|
||||
let live = fx.sidecar.read(id).unwrap().unwrap();
|
||||
assert_eq!(display::name_source(&live), NameSource::Generated);
|
||||
}
|
||||
|
||||
/// A session that dies naturally (liveness watcher marks it dead) must not
|
||||
/// overwrite a haiku rename that landed before the death. Regression test for
|
||||
/// the stale-clone sidecar write in `mark_session_dead`.
|
||||
#[tokio::test]
|
||||
async fn session_death_preserves_the_haiku_name() {
|
||||
let fx = fixture();
|
||||
let id = spawn(&fx.manager, None).await;
|
||||
|
||||
fx.manager.claim_autoname(id).await.unwrap();
|
||||
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
||||
|
||||
// Verify the sidecar has Haiku before the session dies.
|
||||
let before = fx.sidecar.read(id).unwrap().unwrap();
|
||||
assert_eq!(display::name_source(&before), NameSource::Haiku);
|
||||
|
||||
// Kill the tmux session directly so the liveness watcher picks it up.
|
||||
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
|
||||
fx.manager
|
||||
.start_liveness_watcher(std::time::Duration::from_millis(25));
|
||||
|
||||
common::wait_for_async(std::time::Duration::from_secs(3), || async {
|
||||
fx.manager.status(id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
let after = fx.sidecar.read(id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
display::name_source(&after),
|
||||
NameSource::Haiku,
|
||||
"mark_session_dead must not overwrite a haiku rename in the sidecar"
|
||||
);
|
||||
assert_eq!(display::custom_name(&after), Some("haiku-name"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
|
||||
assert!(
|
||||
html.contains(r#"id="app""#),
|
||||
html.contains(r#"id="root""#),
|
||||
"served index is missing the SPA mount point; got:\n{html}"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("maestro is running"),
|
||||
"served index is the placeholder, not the built bundle"
|
||||
);
|
||||
// Stronger signal than `id="app"` (which appears in both source and built
|
||||
// Stronger signal than `id="root"` (which appears in both source and built
|
||||
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
||||
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
||||
// both an empty/placeholder embed and the staleness case.
|
||||
|
||||
@@ -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) {
|
||||
@@ -235,7 +239,61 @@ async fn structured_ws_sends_snapshot_then_live_projection() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
||||
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_queues_during_active_turn_and_cancel_clears_it() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let mut env = fake.env();
|
||||
@@ -252,6 +310,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
// A second prompt while a turn is active is accepted into the queue.
|
||||
let second = client
|
||||
.post(url(
|
||||
h.addr,
|
||||
@@ -261,9 +320,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT);
|
||||
let error: ApiError = second.json().await.unwrap();
|
||||
assert_eq!(error.error.code, "turn_in_flight");
|
||||
assert_eq!(second.status(), reqwest::StatusCode::ACCEPTED);
|
||||
|
||||
let cancel = client
|
||||
.post(url(
|
||||
@@ -274,16 +331,8 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
let next = client
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{id}/structured/prompt"),
|
||||
))
|
||||
.json(&serde_json::json!({"prompt": "next"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
// After cancel + settlement, the queued "second" prompt drains. Wait for
|
||||
// both the first and the drained prompt to reach ACP.
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if fake.log().matches("recv session/prompt").count() == 2 {
|
||||
@@ -293,7 +342,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("both prompt commands reach ACP");
|
||||
.expect("first and drained queued prompt both reach ACP");
|
||||
let log = fake.log();
|
||||
let first_prompt = log.find("recv session/prompt").unwrap();
|
||||
let cancel = log.find("recv session/cancel").unwrap();
|
||||
@@ -493,6 +542,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 +676,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 +819,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 +994,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 +1011,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 +1048,7 @@ async fn patch_session_renames_it() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1028,6 +1092,7 @@ async fn post_focus_stamps_last_focused_at() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1062,6 +1127,7 @@ async fn delete_owned_session_kills_it() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1084,6 +1150,7 @@ async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -1198,6 +1265,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 +1319,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 +1350,7 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1383,6 +1453,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");
|
||||
@@ -1428,6 +1499,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();
|
||||
@@ -1495,6 +1572,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");
|
||||
|
||||
@@ -34,6 +34,8 @@ fn sample(id: Uuid) -> Session {
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
view: SessionView::Terminal,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
|
||||
.map(|v| v.starts_with("text/html")),
|
||||
Some(true)
|
||||
);
|
||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
||||
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
||||
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||
}
|
||||
|
||||
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
||||
|
||||
@@ -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
|
||||
@@ -768,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||
}
|
||||
|
||||
// Force overrides the refusal and nothing else: session gone, tree kept.
|
||||
// Force overrides the refusal: session gone, tree removed.
|
||||
client.kill_session_forced(s.id).await.expect("forced kill");
|
||||
|
||||
assert!(
|
||||
@@ -780,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
||||
.any(|x| x.id == s.id),
|
||||
"the forced kill ends the session"
|
||||
);
|
||||
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
|
||||
assert!(!wt.is_dir(), "a forced kill removes the worktree");
|
||||
assert!(
|
||||
git_worktree_list(base.path())
|
||||
!git_worktree_list(base.path())
|
||||
.iter()
|
||||
.any(|l| l.contains("feature/local")),
|
||||
"the kept tree stays registered in git"
|
||||
"the removed tree is unregistered from git"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -286,6 +744,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,38 +957,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
|
||||
@@ -132,6 +132,16 @@ pub struct Session {
|
||||
/// the two are minted by different processes with different lifetimes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub acp_session_id: Option<String>,
|
||||
/// When `true` the daemon auto-approves every ACP permission request for
|
||||
/// this session instead of surfacing them to the client. Set at spawn time
|
||||
/// from the preset or the `CreateSessionRequest`; persisted so it survives
|
||||
/// a daemon restart.
|
||||
#[serde(default)]
|
||||
pub auto_approve: bool,
|
||||
/// Prompts queued on the daemon while a turn was in flight or the adapter
|
||||
/// was still booting. Persisted so the queue survives a daemon restart.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub queued_prompts: Vec<String>,
|
||||
}
|
||||
|
||||
/// A session's process shape (#133): a plain terminal, or a structured agent
|
||||
@@ -175,6 +185,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 +201,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 +240,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 +399,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 +542,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 +849,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 +880,12 @@ 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>,
|
||||
/// Prompts queued on the daemon while a turn is in flight. Drained as
|
||||
/// a single concatenated prompt when the current turn completes.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub queued_prompts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Server messages sent over a structured-session WebSocket.
|
||||
@@ -871,6 +911,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")]
|
||||
@@ -1021,6 +1068,8 @@ mod tests {
|
||||
last_focused_at: None,
|
||||
view: SessionView::Terminal,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1233,6 +1282,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};
|
||||
@@ -49,6 +50,8 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +63,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 +97,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 +184,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 +282,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>) {
|
||||
@@ -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;
|
||||
@@ -2120,6 +2216,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
@@ -2303,6 +2401,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
@@ -2332,6 +2432,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3009,6 +3111,7 @@ mod tests {
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
};
|
||||
app.submit_spawn(req).await;
|
||||
|
||||
@@ -3053,17 +3156,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;
|
||||
@@ -3490,6 +3596,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: focused,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
};
|
||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
@@ -3770,6 +3878,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::KillHighlighted);
|
||||
@@ -3807,6 +3917,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,6 +225,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -811,6 +811,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+276
-16
@@ -41,7 +41,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::{abbreviate_dir, command_tool};
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||
};
|
||||
@@ -91,7 +91,7 @@ pub enum DraftField {
|
||||
/// Disclosure collapsing the optional view and worktree controls below.
|
||||
/// Closed by default; Enter / Space toggles.
|
||||
Advanced,
|
||||
/// ACP-backed transcript instead of a terminal. Claude Code only.
|
||||
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
|
||||
StructuredToggle,
|
||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||
WorktreeToggle,
|
||||
@@ -255,9 +255,14 @@ pub struct DraftPane {
|
||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||
/// take the default target.
|
||||
pub advanced_collapsed: bool,
|
||||
/// ACP-backed structured transcript. Off by default and supported only
|
||||
/// when the resolved command is Claude Code.
|
||||
/// ACP-backed structured transcript. Supported only when the resolved
|
||||
/// command is Claude Code or Codex, and on by default whenever it is —
|
||||
/// until the operator works the toggle themselves.
|
||||
pub structured_view: bool,
|
||||
/// Set once the operator toggles structured view by hand. From then on a
|
||||
/// command change never re-enables what they turned off, the same guard
|
||||
/// `name_touched` gives the name field.
|
||||
pub structured_view_touched: bool,
|
||||
/// Enclosing repository of the chosen target — from the project scan for
|
||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||
pub base_repo: Option<PathBuf>,
|
||||
@@ -298,7 +303,7 @@ impl Default for DraftPane {
|
||||
// while its data loads, and stay usable when the daemon reports none.
|
||||
let mut presets = builtin_presets();
|
||||
presets.push(custom_preset());
|
||||
Self {
|
||||
let mut draft = Self {
|
||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
presets,
|
||||
selected_preset: 0,
|
||||
@@ -319,6 +324,7 @@ impl Default for DraftPane {
|
||||
worktree_enabled: true,
|
||||
advanced_collapsed: true,
|
||||
structured_view: false,
|
||||
structured_view_touched: false,
|
||||
base_repo: None,
|
||||
probed_cwd: None,
|
||||
probe_seq: 0,
|
||||
@@ -337,7 +343,11 @@ impl Default for DraftPane {
|
||||
// straight away, and Tab from here reaches the command tiles.
|
||||
focused_field: DraftField::DirPicker,
|
||||
home_dir: String::new(),
|
||||
}
|
||||
};
|
||||
// The built-in tile the form opens on decides whether structured view
|
||||
// starts ticked, exactly as a later preset list would.
|
||||
draft.sync_structured_view();
|
||||
draft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +366,7 @@ impl DraftPane {
|
||||
.position(|p| !p.command.is_empty())
|
||||
.unwrap_or(0);
|
||||
self.apply_seed_command();
|
||||
self.clear_unsupported_structured_view();
|
||||
self.sync_structured_view();
|
||||
}
|
||||
|
||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||
@@ -673,18 +683,24 @@ impl DraftPane {
|
||||
}
|
||||
|
||||
fn supports_structured_view(&self) -> bool {
|
||||
command_tool(self.command()) == Some("claude")
|
||||
supports_structured_view(self.command())
|
||||
}
|
||||
|
||||
fn clear_unsupported_structured_view(&mut self) {
|
||||
/// Re-resolve the toggle after the command changed: an unsupported
|
||||
/// command forces it off, a supported one turns it back on unless the
|
||||
/// operator has already made the choice themselves.
|
||||
fn sync_structured_view(&mut self) {
|
||||
if !self.supports_structured_view() {
|
||||
self.structured_view = false;
|
||||
} else if !self.structured_view_touched {
|
||||
self.structured_view = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_structured_view(&mut self) {
|
||||
if self.supports_structured_view() {
|
||||
self.structured_view = !self.structured_view;
|
||||
self.structured_view_touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +734,10 @@ impl DraftPane {
|
||||
);
|
||||
}
|
||||
let worktree = self.build_worktree()?;
|
||||
let auto_approve = self
|
||||
.presets
|
||||
.get(self.selected_preset)
|
||||
.is_some_and(|p| p.auto_approve);
|
||||
Ok(CreateSessionRequest {
|
||||
view: if self.structured_view && self.supports_structured_view() {
|
||||
SessionView::Structured
|
||||
@@ -729,6 +749,7 @@ impl DraftPane {
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -827,7 +848,7 @@ impl DraftPane {
|
||||
}
|
||||
self.selected_preset =
|
||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||
self.clear_unsupported_structured_view();
|
||||
self.sync_structured_view();
|
||||
}
|
||||
|
||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||
@@ -1130,7 +1151,7 @@ impl DraftPane {
|
||||
DraftField::Preset | DraftField::Submit => {}
|
||||
DraftField::CustomCommand => {
|
||||
self.custom_command.handle_key(other);
|
||||
self.clear_unsupported_structured_view();
|
||||
self.sync_structured_view();
|
||||
}
|
||||
// Typing filters the list; the highlight resets to the top
|
||||
// so a narrowed search always starts at its best match.
|
||||
@@ -1776,6 +1797,7 @@ mod tests {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
}]);
|
||||
assert_eq!(
|
||||
draft.command(),
|
||||
@@ -1798,11 +1820,13 @@ mod tests {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
@@ -1822,6 +1846,7 @@ mod tests {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
}]);
|
||||
assert!(draft.is_custom());
|
||||
assert_eq!(draft.command(), "bash -l");
|
||||
@@ -1888,18 +1913,42 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
|
||||
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
||||
let mut draft = DraftPane {
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
assert!(!draft.structured_view);
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "OpenCode".into(),
|
||||
command: "opencode".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert!(draft.structured_view, "on by default for a supported agent");
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(rendered.contains("[ ] structured view"), "{rendered}");
|
||||
assert!(rendered.contains("[x] structured view"), "{rendered}");
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(draft.structured_view);
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "codex");
|
||||
assert!(
|
||||
draft.structured_view,
|
||||
"Codex keeps the structured selection"
|
||||
);
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert!(
|
||||
@@ -1926,6 +1975,217 @@ mod tests {
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -502,6 +502,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -979,6 +981,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
@@ -1009,6 +1012,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
@@ -1270,6 +1274,8 @@ mod tests {
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
auto_approve: false,
|
||||
queued_prompts: Vec::new(),
|
||||
};
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
|
||||
@@ -90,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
|
||||
@@ -138,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) {
|
||||
@@ -214,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,
|
||||
@@ -264,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,
|
||||
@@ -908,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();
|
||||
@@ -1272,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;
|
||||
@@ -650,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
|
||||
|
||||
+2
-1
@@ -70,7 +70,7 @@ command. `<duration>` follows in the rail's colour when shown. There is no
|
||||
state word and no second badge: the rail exists so the state is still legible
|
||||
when a row is dimmed or the terminal renders the duration in the default fg.
|
||||
|
||||
In the **web client** the rail is a 5px bar pinned to the row's own left
|
||||
In the **web client** the rail is a 7px bar pinned to the row's own left
|
||||
edge, square corners, spanning the full card height edge to edge — it
|
||||
shifts outward with nesting depth along with the row's own padding, since
|
||||
it is positioned relative to the row. It carries motion only for states that
|
||||
@@ -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` |
|
||||
|
||||
|
||||
+125
-50
@@ -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.
|
||||
@@ -164,9 +187,29 @@ choices, not daemon ones.
|
||||
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
||||
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
|
||||
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
||||
from the transcript entirely and render the current plan in a separate
|
||||
plan rail (web) / status line (TUI) instead.
|
||||
- How `responding` vs. `thinking` are distinguished visually, if at all.
|
||||
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 each contiguous run of two or more tool rows into a
|
||||
collapsed activity block. An `AgentText` or `AgentThought` row between
|
||||
tools splits them into separate groups, so the timeline reads
|
||||
text → group → text → group rather than lumping every tool in a turn into
|
||||
one box. Text and thought rows stay outside groups as standalone transcript
|
||||
items — the operator sees all reasoning without expanding. Resolved
|
||||
approval rows between tools within a run are absorbed into the group. The
|
||||
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
||||
argument come from the last tool row's `splitToolTitle` — the operator
|
||||
sees which command is current at a glance. Opening the group reveals tool
|
||||
rows in chronological order.
|
||||
- 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.
|
||||
@@ -197,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
|
||||
@@ -247,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
|
||||
@@ -281,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
|
||||
@@ -289,15 +353,26 @@ thing a callback can be attributed to, so at most one turn may ever be
|
||||
in flight, and a cancelled turn must fully settle before the next prompt is
|
||||
allowed to claim the daemon's idea of "the active turn."
|
||||
|
||||
- A prompt claims the turn and dispatches it to ACP as one operation. While a
|
||||
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
||||
queuing — one active turn per session, never a hidden queue of prompts.
|
||||
- Prompts are accepted at any time: if the session is idle the daemon dispatches
|
||||
immediately (`204`); if a turn is active the prompt is appended to a
|
||||
server-side FIFO (`202 Accepted`, capped at 20 entries, `429` when full).
|
||||
When the active turn completes, all queued prompts are concatenated with
|
||||
`\n\n` and dispatched as the next turn. The queue is persisted in the session
|
||||
sidecar and survives daemon restarts — clients never queue locally.
|
||||
`ReducedState.queued_prompts` contains the current queue so every connected
|
||||
client can render the pending messages.
|
||||
- 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.
|
||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
||||
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
||||
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
|
||||
prompt claiming "the active turn" while the old one is still outstanding
|
||||
would make the next permission callback ambiguous between the two.
|
||||
and ends the turn's client-visible lifecycle immediately. The prompt queue
|
||||
is preserved: after the cancelled turn settles, the queued prompts drain as
|
||||
the next turn. If the session dies instead (settlement timeout), the queue
|
||||
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
||||
its response), the daemon cannot simply forget it: ACP has not confirmed the
|
||||
cancel yet, and a new prompt claiming "the active turn" while the old one is
|
||||
still outstanding would make the next permission callback ambiguous between
|
||||
the two.
|
||||
- So a cancelled-but-still-dispatched turn must **settle within
|
||||
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
|
||||
resolves) or that grace period runs out. If it settles in time, the next
|
||||
@@ -315,8 +390,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
|
||||
|
||||
+10
-25
@@ -75,27 +75,17 @@ The point of refusing rather than cleaning up is that the session stays as the
|
||||
operator's handle on their own unfinished work. A session that is killed while
|
||||
its worktree still matters leaves a directory nobody is looking at.
|
||||
|
||||
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
||||
commits, so `--force` is the ordinary way to end a session, not an exception.
|
||||
That is only safe because force never removes what blocked.
|
||||
|
||||
### `--force`
|
||||
|
||||
`--force` overrides the refusal and nothing else. It ends the session and
|
||||
**leaves the worktree on disk**, untouched. Force never removes a tree that
|
||||
would have blocked. A tree whose guard is clear never needed the override, so a
|
||||
forced kill removes it as usual — force changes what happens to blocked work,
|
||||
not ordinary cleanup.
|
||||
`--force` overrides the refusal and **removes the worktree**, even when it holds
|
||||
unsaved work. The operator sees the breakdown in the confirmation prompt and
|
||||
decides; once confirmed, the tree is gone.
|
||||
|
||||
In the web client the kill button sends the unforced `DELETE` straight away —
|
||||
there is no pre-confirmation, because the daemon is the one that decides. Only
|
||||
the refusal raises a prompt, and that prompt names what is in the way and offers
|
||||
"Force kill", which repeats the request with `force=true`.
|
||||
|
||||
Ending a session and destroying its work are two separate acts. Maestro only
|
||||
performs the first one. Removing a tree it left behind is the operator's own
|
||||
`git worktree remove`, chosen while looking at exactly what would be lost.
|
||||
|
||||
### Exiting on its own
|
||||
|
||||
A session that ends by itself — the command finished, the operator typed `exit` —
|
||||
@@ -113,17 +103,12 @@ once. A removed tree drops off the record, so dismissing the session afterwards
|
||||
does not refuse: there is no guard left to trip.
|
||||
|
||||
A tree that was left alone stays attached to the dead record, where dismissing it
|
||||
later goes through `--force`, which leaves it on disk too. The same holds when
|
||||
the probe itself fails: nothing is destroyed.
|
||||
later goes through `--force`, which removes it. The same holds when the probe
|
||||
itself fails: nothing is destroyed.
|
||||
|
||||
## Nothing is removed on the operator's behalf
|
||||
## Trees left behind by natural exit
|
||||
|
||||
There is no store of worktrees the daemon is holding, no list endpoint, and no
|
||||
reclaim command. A tree left behind by a forced kill or a dirty exit is an
|
||||
ordinary git worktree on disk; `git worktree list` finds it and
|
||||
`git worktree remove` removes it.
|
||||
|
||||
That is deliberate. A tracked list of trees pending deletion is a second place
|
||||
the truth can live, and it goes stale the moment somebody removes a directory by
|
||||
hand. Git already knows which worktrees exist, and removing one is not a
|
||||
decision Maestro is better placed to make.
|
||||
A forced kill always removes the worktree. A natural exit with unsaved work
|
||||
leaves the tree on disk — there is no operator confirmation in that path. Those
|
||||
trees are ordinary git worktrees; `git worktree list` finds them and
|
||||
`git worktree remove` removes them.
|
||||
|
||||
@@ -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-FEWOAtSFvOWHeCNR0RK8JQ/ssW31qmShu1YD2GuqioQ=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@
|
||||
<meta name="apple-mobile-web-app-title" content="maestro" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></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
+459
@@ -0,0 +1,459 @@
|
||||
// The app shell (#253): everything main.ts used to wire by hand, as one React
|
||||
// tree. The stores are the state — a catalog store fed by the events socket, a
|
||||
// layout store holding the tab/split tree — and the hash route is a deep link
|
||||
// into the latter.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/500.css";
|
||||
import "@fontsource/jetbrains-mono/600.css";
|
||||
import "@fontsource/jetbrains-mono/700.css";
|
||||
|
||||
import { createSession, fetchNames, getDaemonInfo, gitRoot, projects as fetchProjects } from "./api.js";
|
||||
import {
|
||||
CommandSet,
|
||||
DEFAULT_LEADER,
|
||||
controlByte,
|
||||
defineCommands,
|
||||
isBarTypingTarget,
|
||||
} from "./commands.js";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext.js";
|
||||
import { CommandBar, SPAWN_DIRECTORY_EVENT, useCommandBar } from "./components/commandBar/index.js";
|
||||
import { DaemonLink, createDaemonLinkStore } from "./components/daemon/DaemonLink.js";
|
||||
import { RestartOverlay } from "./components/daemon/RestartOverlay.js";
|
||||
import { HelpOverlay } from "./components/dialogs/index.js";
|
||||
import { useViewport } from "./components/hooks/useViewport.js";
|
||||
import { Drawer } from "./components/layout/Drawer.js";
|
||||
import { MultiView, type MultiViewHandle } from "./components/layout/MultiView.js";
|
||||
import { Sidebar } from "./components/sidebar/index.js";
|
||||
import {
|
||||
SpawnModal,
|
||||
spawnSimilarProjectSeed,
|
||||
spawnSimilarSeed,
|
||||
useFastForward,
|
||||
type SpawnFormProps,
|
||||
} from "./components/spawn/index.js";
|
||||
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "./fastForward.js";
|
||||
import { useHashRouter } from "./hooks/useHashRouter.js";
|
||||
import { usePreview } from "./hooks/usePreview.js";
|
||||
import { activeTab, findLeaf, openSessions } from "./layout.js";
|
||||
import { createCatalogStore, type CatalogStore } from "./stores/catalogStore.js";
|
||||
import { createLayoutStore, type LayoutStore } from "./stores/layoutStore.js";
|
||||
import type { Project, Session, SpawnPreset, Uuid } from "./types.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
OPEN_SPAWN_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
requestCloseDrawer,
|
||||
requestOpenSpawn,
|
||||
requestToggleDrawer,
|
||||
} from "./uiEvents.js";
|
||||
|
||||
type SpawnSeed = SpawnFormProps["seed"];
|
||||
|
||||
/** Open modal state: `seed` is what the form starts from (undefined for a
|
||||
* plain spawn), `newTab` where the created session lands. */
|
||||
interface SpawnState {
|
||||
seed: SpawnSeed;
|
||||
newTab: boolean;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Shell />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
useViewport();
|
||||
|
||||
// One instance each, for the life of the page.
|
||||
const catalogStore = useMemo(() => createCatalogStore(), []);
|
||||
const layoutStore = useMemo(() => createLayoutStore(), []);
|
||||
const linkStore = useMemo(() => createDaemonLinkStore(), []);
|
||||
|
||||
const sessions = useStore(catalogStore, (s: CatalogStore) => s.sessions);
|
||||
const connected = useStore(catalogStore, (s: CatalogStore) => s.connected);
|
||||
const loaded = useStore(catalogStore, (s: CatalogStore) => s.loaded);
|
||||
const layout = useStore(layoutStore, (s: LayoutStore) => s.layout);
|
||||
|
||||
const [homeDir, setHomeDir] = useState<string | undefined>(undefined);
|
||||
const [leader, setLeader] = useState(DEFAULT_LEADER);
|
||||
const [spawn, setSpawn] = useState<SpawnState | null>(null);
|
||||
|
||||
const { route, navigate } = useHashRouter();
|
||||
const { preview, previewedSessionId, commit: commitPreview, end: endPreview } = usePreview({
|
||||
store: layoutStore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
catalogStore.getState().start();
|
||||
return () => catalogStore.getState().stop();
|
||||
}, [catalogStore]);
|
||||
|
||||
// The daemon's home dir abbreviates cwds to `~/…`; its leader rebinds the
|
||||
// chords hanging off it (#159), so the `?` overlay and the command bar show
|
||||
// the configured chord rather than the default. Absolute paths and the
|
||||
// default leader are fine fallbacks.
|
||||
useEffect(() => {
|
||||
void getDaemonInfo()
|
||||
.then((info) => {
|
||||
setHomeDir(info.home_dir);
|
||||
if (info.leader) setLeader(info.leader);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
const recover = () => {
|
||||
void linkStore.getState().probe();
|
||||
catalogStore.getState().reconnect();
|
||||
};
|
||||
// visibilitychange fires reliably on tab/app switch; guard it so we don't
|
||||
// probe while the page is being hidden.
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
recover();
|
||||
};
|
||||
// pageshow (bfcache restore) and online (network back) indicate the page
|
||||
// is active — fire unconditionally since visibilityState may lag behind on
|
||||
// some mobile browsers.
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
window.addEventListener("pageshow", recover);
|
||||
window.addEventListener("online", recover);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
window.removeEventListener("pageshow", recover);
|
||||
window.removeEventListener("online", recover);
|
||||
};
|
||||
}, [catalogStore, linkStore]);
|
||||
|
||||
// The catalog is the authority on which sessions exist: a pane for a session
|
||||
// that is no longer listed gets closed here, rather than waiting on its own
|
||||
// socket to notice. Only once a list has actually landed — before that,
|
||||
// "not listed" means "not known yet" and would close every restored pane.
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
for (const id of openSessions(layout)) {
|
||||
if (!sessions.has(id)) layoutStore.getState().closeSession(id);
|
||||
}
|
||||
}, [loaded, sessions, layout, layoutStore]);
|
||||
|
||||
const tab = activeTab(layout);
|
||||
const focusedSessionId =
|
||||
(tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)?.sessionId) || null;
|
||||
const focusedSession = focusedSessionId ? sessions.get(focusedSessionId) : undefined;
|
||||
|
||||
/** Open/focus a session, settling any preview of it in place. Shared by the
|
||||
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||
* changing, leaving a click back onto that row silently ignored. */
|
||||
const focusSession = useCallback(
|
||||
(sessionId: Uuid) => {
|
||||
// `commit` cancels a pending preview and opens the session; the layout
|
||||
// store settles a matching preview in place rather than moving it twice.
|
||||
commitPreview(sessionId);
|
||||
navigate({ kind: "attach", sessionId });
|
||||
},
|
||||
[commitPreview, navigate],
|
||||
);
|
||||
|
||||
// A deep link (`#/attach/:id`) opens the session it names. Only the route
|
||||
// *changing* does this: focusing a pane rewrites the hash, and re-opening
|
||||
// from that would fight the layout the operator just arranged.
|
||||
const lastRouteRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const key = route.kind === "attach" ? route.sessionId : "catalog";
|
||||
if (lastRouteRef.current === key) return;
|
||||
lastRouteRef.current = key;
|
||||
if (route.kind === "attach") layoutStore.getState().openSession(route.sessionId);
|
||||
}, [route, layoutStore]);
|
||||
|
||||
/** Show a freshly spawned session: it gets its own pane rather than evicting
|
||||
* whatever the operator was reading. */
|
||||
const mountSpawned = useCallback(
|
||||
(session: Session, opts?: { newTab?: boolean }) => {
|
||||
layoutStore.getState().openSessionBeside(session.id, opts);
|
||||
navigate({ kind: "attach", sessionId: session.id });
|
||||
requestCloseDrawer();
|
||||
},
|
||||
[layoutStore, navigate],
|
||||
);
|
||||
|
||||
const openSpawn = useCallback(
|
||||
(seed: SpawnSeed, newTab: boolean) => {
|
||||
endPreview();
|
||||
setSpawn({ seed, newTab });
|
||||
},
|
||||
[endPreview],
|
||||
);
|
||||
|
||||
/** The plain spawn: the session you were last on preselects its project, so
|
||||
* `n` then Enter spawns another session there without touching the picker.
|
||||
* It lands in a new tab, not a split of the pane you were reading (#100). */
|
||||
const openPlainSpawn = useCallback(() => {
|
||||
openSpawn(focusedSession ? spawnSimilarProjectSeed(focusedSession) : undefined, true);
|
||||
}, [focusedSession, openSpawn]);
|
||||
|
||||
/** The focused session's project — its base repo for a worktree session, its
|
||||
* cwd otherwise (the rule spawnSimilarProjectSeed uses). */
|
||||
const focusedProjectCwd = focusedSession
|
||||
? (focusedSession.worktree?.base_repo ?? focusedSession.cwd)
|
||||
: undefined;
|
||||
|
||||
const fastForward = useFastForward({
|
||||
focusedCwd: focusedProjectCwd,
|
||||
onSpawned: (session) => mountSpawned(session),
|
||||
onFallback: (seed) => openSpawn(seed, false),
|
||||
});
|
||||
|
||||
const bar = useCommandBar({ onOpen: endPreview });
|
||||
|
||||
useEffect(() => {
|
||||
const onOpenSpawnEvent = () => openPlainSpawn();
|
||||
const onSpawnSimilar = (e: Event) => {
|
||||
const s = (e as CustomEvent<Session>).detail;
|
||||
if (s) openSpawn(spawnSimilarSeed(s), false);
|
||||
};
|
||||
const onSessionSelect = (e: Event) => focusSession((e as CustomEvent<Uuid>).detail);
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
|
||||
document.addEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
|
||||
document.addEventListener(SESSION_SELECT_EVENT, onSessionSelect);
|
||||
return () => {
|
||||
document.removeEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
|
||||
document.removeEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, onSessionSelect);
|
||||
};
|
||||
}, [openPlainSpawn, openSpawn, focusSession]);
|
||||
|
||||
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
||||
// fast-forward spawn, aimed at the chosen directory instead of the most
|
||||
// recent project.
|
||||
useEffect(() => {
|
||||
const onSpawnDirectory = (e: Event) => {
|
||||
void spawnInDirectory((e as CustomEvent<string>).detail, mountSpawned, (seed) =>
|
||||
openSpawn(seed, false),
|
||||
);
|
||||
};
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
|
||||
return () => document.removeEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
|
||||
}, [mountSpawned, openSpawn]);
|
||||
|
||||
// Tab in the session list hands the keyboard to the pane; leader-h hands it
|
||||
// back the other way. Handing it to a previewed pane is choosing it: the
|
||||
// preview settles rather than snapping back when the list blurs.
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MultiViewHandle>(null);
|
||||
const focusPane = useCallback(() => {
|
||||
if (previewedSessionId) commitPreview(previewedSessionId);
|
||||
viewRef.current?.focusPane();
|
||||
}, [previewedSessionId, commitPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener(FOCUS_PANE_EVENT, focusPane);
|
||||
return () => document.removeEventListener(FOCUS_PANE_EVENT, focusPane);
|
||||
}, [focusPane]);
|
||||
|
||||
// The global half of the command registry (#148). `n` opens the spawn modal
|
||||
// and `N` fast-forwards a spawn (#67); both are plain keys, so the set's own
|
||||
// `isTypingTarget` keeps them out of form fields and the terminal's hidden
|
||||
// textarea. Leader-h moves the keyboard between the list and the pane — a
|
||||
// chord, because an attached terminal owns every plain keystroke
|
||||
// (docs/COMMAND-BAR.md).
|
||||
useEffect(() => {
|
||||
if (bar.open || spawn) return;
|
||||
const spawnKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"spawn.open": openPlainSpawn,
|
||||
"spawn.fastForward": () => void fastForward(),
|
||||
}),
|
||||
);
|
||||
const focusKeys = new CommandSet(
|
||||
defineCommands(
|
||||
{
|
||||
"focus.toggle": () => {
|
||||
if (sidebarRef.current?.contains(document.activeElement)) {
|
||||
focusPane();
|
||||
} else {
|
||||
// No-op on desktop; on mobile the list is off-canvas until the
|
||||
// drawer opens, so the focus has somewhere to land.
|
||||
requestToggleDrawer();
|
||||
sidebarRef.current?.querySelector<HTMLElement>("[tabindex]")?.focus();
|
||||
}
|
||||
},
|
||||
// The escape hatch: binding ctrl-k takes readline's
|
||||
// kill-to-end-of-line away from every pane, and leader ctrl-k gives
|
||||
// it back as the literal 0x0B byte (docs/COMMAND-BAR.md).
|
||||
"pane.literalCtrlK": () => viewRef.current?.sendInput("\x0b"),
|
||||
// The leader pressed twice, tmux-style: one literal leader byte,
|
||||
// built from the leader itself so a rebound one sends its own (#158).
|
||||
"pane.literalLeader": () => viewRef.current?.sendInput(controlByte(leader)),
|
||||
},
|
||||
leader,
|
||||
),
|
||||
{ leader },
|
||||
);
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (focusKeys.handle(e)) return;
|
||||
if (isBarTypingTarget(e.target)) return;
|
||||
spawnKeys.handle(e);
|
||||
};
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
return () => window.removeEventListener("keydown", onKeydown, true);
|
||||
}, [bar.open, spawn, openPlainSpawn, fastForward, focusPane, leader]);
|
||||
|
||||
const openIds = useMemo(() => openSessions(layout), [layout]);
|
||||
const barSessions = useMemo(() => [...sessions.values()], [sessions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
sidebar={
|
||||
<div ref={sidebarRef} className="contents">
|
||||
<Sidebar
|
||||
store={catalogStore}
|
||||
homeDir={homeDir}
|
||||
activeId={focusedSessionId}
|
||||
openIds={openIds}
|
||||
previewId={previewedSessionId}
|
||||
linkStore={linkStore}
|
||||
onSelect={(id) => focusSession(id)}
|
||||
onPreview={preview}
|
||||
onSpawn={openPlainSpawn}
|
||||
onSpawnDefaults={() => void fastForward()}
|
||||
onSpawnSimilar={(s) => openSpawn(spawnSimilarSeed(s), false)}
|
||||
onFocusPane={focusPane}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MultiView
|
||||
ref={viewRef}
|
||||
store={layoutStore}
|
||||
// Undefined until the first list lands: MultiView's boot
|
||||
// reconciliation prunes against it, and an empty map would take
|
||||
// every restored pane with it.
|
||||
sessions={loaded ? sessions : undefined}
|
||||
onDisconnect={(id) => layoutStore.getState().closeSession(id)}
|
||||
emptyState={<Welcome onSpawn={requestOpenSpawn} />}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<DaemonLink store={linkStore} connected={connected} />
|
||||
<RestartOverlay store={linkStore} />
|
||||
<HelpOverlay />
|
||||
|
||||
{bar.open && (
|
||||
<CommandBar
|
||||
sessions={barSessions}
|
||||
homeDir={homeDir}
|
||||
onOpenSession={(id) => {
|
||||
bar.closeBar();
|
||||
focusSession(id);
|
||||
}}
|
||||
onClose={bar.closeBar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{spawn && (
|
||||
<SpawnModal
|
||||
seed={spawn.seed}
|
||||
onSuccess={(session) => mountSpawned(session, { newTab: spawn.newTab })}
|
||||
onClose={() => setSpawn(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||
try {
|
||||
const info = await getDaemonInfo();
|
||||
if (info.presets?.length) return info.presets;
|
||||
} catch {
|
||||
// keep fallbacks
|
||||
}
|
||||
const { FALLBACK_PRESETS } = await import("./components/spawn/model.js");
|
||||
return FALLBACK_PRESETS;
|
||||
}
|
||||
|
||||
/** A directory the project scan never saw stands in as its own project; one
|
||||
* outside any repo spawns without a worktree, since there is no repo to cut a
|
||||
* branch from. Anything still unresolved opens the pre-filled form. */
|
||||
async function spawnInDirectory(
|
||||
path: string,
|
||||
onSpawned: (session: Session) => void,
|
||||
onFallback: (seed: SpawnSeed) => void,
|
||||
): Promise<void> {
|
||||
const [discovered, handle] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const presets = await spawnPresets();
|
||||
const known = discovered.find((p) => p.path === path);
|
||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||
const resolved = resolveFastForward([project], presets, handle, path);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
const inRepo =
|
||||
known !== undefined ||
|
||||
(await gitRoot(path)
|
||||
.then((r) => !!r.root)
|
||||
.catch(() => false));
|
||||
if (!inRepo) delete req.worktree;
|
||||
try {
|
||||
onSpawned(await createSession(req));
|
||||
return;
|
||||
} catch {
|
||||
// A failed POST degrades to the pre-filled form, never a dead row.
|
||||
}
|
||||
}
|
||||
onFallback(fastForwardSeed(resolved));
|
||||
}
|
||||
|
||||
/** The content pane with nothing open. The ☰ is mobile-only: with no pane
|
||||
* there is no pane header to carry it, so the drawer would be swipe-only. */
|
||||
function Welcome({ onSpawn }: { onSpawn(): void }) {
|
||||
return (
|
||||
<div className="empty content-welcome">
|
||||
<div className="empty-glyph" aria-hidden="true">
|
||||
⌘
|
||||
</div>
|
||||
<div className="empty-title">No session open</div>
|
||||
<p className="empty-hint">
|
||||
Pick a session from the list, or start a new one. Open sessions keep streaming in the
|
||||
background — switching back is instant.
|
||||
</p>
|
||||
<p className="empty-hint">
|
||||
Press <kbd>?</kbd> for keyboard shortcuts.
|
||||
</p>
|
||||
<div className="empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost welcome-sessions"
|
||||
onClick={() => requestToggleDrawer()}
|
||||
>
|
||||
☰ Sessions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
title="Draft pane: n — fast-forward spawn: N"
|
||||
onClick={onSpawn}
|
||||
>
|
||||
+ Spawn session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -1,640 +0,0 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { attachWsUrl } from "./routes.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
import type { ClientControl, Uuid } from "./types.js";
|
||||
import { WebglPolicy } from "./webglRenderer.js";
|
||||
|
||||
export type { AttachInfo } from "./paneTitle.js";
|
||||
|
||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||
* rationed rather than handed to every connection. */
|
||||
const webglPolicy = new WebglPolicy();
|
||||
|
||||
/**
|
||||
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
|
||||
*
|
||||
* The connection is **decoupled from the UI**: it owns a long-lived WS that
|
||||
* keeps streaming PTY output into the terminal buffer whether or not the view
|
||||
* is currently mounted. The shell moves `el` between the visible content pane
|
||||
* and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never
|
||||
* the connection. Only `dispose()` (eviction, an explicit disconnect, or the
|
||||
* session ending) tears the socket down — so switching away and back is
|
||||
* instant, with scrollback already in place.
|
||||
*/
|
||||
export interface AttachConnection extends PaneConnection {}
|
||||
|
||||
export interface AttachOptions {
|
||||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
||||
onRequestClose?: (sessionId: Uuid) => void;
|
||||
/** Invoked when the socket closes on its own — the session ended or the link
|
||||
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
||||
* shell uses this to auto-close the pane rather than leave a dead one. */
|
||||
onClosed?: (sessionId: Uuid) => void;
|
||||
/** Asked on every close: should the socket be retried? The shell answers
|
||||
* from the catalog — see `shouldRetryClose`. */
|
||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||
}
|
||||
|
||||
export function createAttachConnection(
|
||||
sessionId: Uuid,
|
||||
opts: AttachOptions = {},
|
||||
info?: AttachInfo,
|
||||
): AttachConnection {
|
||||
const el = document.createElement("div");
|
||||
el.className = "attach";
|
||||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
||||
// tooltip for when you really need it.
|
||||
const titleHtml = paneTitleHtml(sessionId, info);
|
||||
const titleAttr = paneTitleAttr(sessionId, info);
|
||||
el.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||
</header>
|
||||
<div class="attach-body"></div>
|
||||
`;
|
||||
|
||||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
|
||||
const keybar = buildKeyBar((data) => sendInput(data));
|
||||
el.appendChild(keybar.el);
|
||||
|
||||
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
|
||||
stateEl.textContent = s;
|
||||
stateEl.dataset.state = kind;
|
||||
};
|
||||
|
||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
|
||||
// the session list is an off-canvas drawer and this is what opens it.
|
||||
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
||||
requestToggleDrawer(),
|
||||
);
|
||||
|
||||
disconnectBtn.addEventListener("click", () => {
|
||||
opts.onRequestClose?.(sessionId);
|
||||
});
|
||||
|
||||
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: {
|
||||
// The terminal stays dark in both app themes (convention); colors are
|
||||
// tuned to the maestro palette — signal-green cursor, slate background.
|
||||
background: "#121419",
|
||||
foreground: "#e6e8ec",
|
||||
cursor: "#5fe3a1",
|
||||
cursorAccent: "#121419",
|
||||
selectionBackground: "#2b6b4f",
|
||||
},
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(body);
|
||||
|
||||
// GPU rendering is scoped to the mounted view, not the connection: acquired
|
||||
// in onMount, released in onUnmount. See webglRenderer.ts.
|
||||
let webgl: WebglAddon | null = null;
|
||||
let dprMedia: MediaQueryList | null = null;
|
||||
|
||||
// The glyph atlas is rasterised for one device pixel ratio. Dragging the
|
||||
// window to a display with a different one (or an Android browser-zoom
|
||||
// change) leaves every cell blurry until the atlas is rebuilt.
|
||||
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, which is the pre-WebGL behaviour anyway
|
||||
}
|
||||
}
|
||||
|
||||
function releaseWebgl(): void {
|
||||
dprMedia?.removeEventListener("change", onDprChange);
|
||||
dprMedia = null;
|
||||
if (!webgl) return;
|
||||
const addon = webgl;
|
||||
webgl = null;
|
||||
try {
|
||||
addon.dispose(); // xterm falls back to the DOM renderer
|
||||
} 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();
|
||||
// Context loss is routine on Android — backgrounding the tab is enough.
|
||||
// Drop back to the DOM renderer, then retry only while the shared budget
|
||||
// allows and this view is still the one on screen.
|
||||
addon.onContextLoss(() => {
|
||||
webglPolicy.noteContextLoss();
|
||||
releaseWebgl();
|
||||
if (mounted) acquireWebgl();
|
||||
});
|
||||
term.loadAddon(addon);
|
||||
webgl = addon;
|
||||
watchDpr();
|
||||
} catch {
|
||||
// Context creation failed outright (no GPU, or the browser is already at
|
||||
// its context cap). Count it like a loss so we stop asking, and stay on
|
||||
// the DOM renderer.
|
||||
webglPolicy.noteContextLoss();
|
||||
webgl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||
// • normal buffer → term.scrollLines walks the scrollback.
|
||||
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
|
||||
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
||||
// scrolling *its* content (the only scroll that exists on the alt screen).
|
||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||||
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||||
// gesture stream; only the terminal surface opts out of native page panning.
|
||||
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
|
||||
// later sibling of the viewport, so it paints over it and takes every touch.
|
||||
{
|
||||
const root = term.element as HTMLElement | undefined;
|
||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||||
const cellH = () => {
|
||||
const core = (term as unknown as {
|
||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
|
||||
})._core;
|
||||
return core?._renderService?.dimensions?.css?.cell?.height || 16;
|
||||
};
|
||||
if (root) {
|
||||
let lastY = 0;
|
||||
let lastX = 0;
|
||||
let remainder = 0;
|
||||
root.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
lastY = e.touches[0].clientY;
|
||||
lastX = e.touches[0].clientX;
|
||||
remainder = 0;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
root.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const ch = cellH();
|
||||
const y = e.touches[0].clientY;
|
||||
const px = lastY - y + remainder; // finger up → scroll toward newer
|
||||
lastY = y;
|
||||
const lines = Math.trunc(px / ch);
|
||||
remainder = px - lines * ch;
|
||||
if (lines === 0) return;
|
||||
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
|
||||
// reporting on — that's the signal to forward instead of scrollback.
|
||||
const mouseTracked = term.modes.mouseTrackingMode !== "none";
|
||||
if (mouseTracked && screen) {
|
||||
// One wheel event per crossed cell, dispatched at the touch point so
|
||||
// xterm computes valid cell coords for the mouse report.
|
||||
const dir = lines > 0 ? 1 : -1;
|
||||
for (let i = 0; i < Math.abs(lines); i++) {
|
||||
screen.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: dir * ch,
|
||||
deltaMode: 0,
|
||||
clientX: lastX,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (term.buffer.active.type !== "alternate") {
|
||||
term.scrollLines(lines);
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile: the special-key bar is only useful while the soft keyboard is up,
|
||||
// so reveal it only when the terminal's hidden textarea is focused (CSS still
|
||||
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
|
||||
// preventDefault, so the bar stays put during use. Re-fit on the toggle since
|
||||
// showing/hiding the bar changes the terminal's available height.
|
||||
const ta = term.textarea;
|
||||
if (ta) {
|
||||
ta.addEventListener("focus", () => {
|
||||
keybar.el.classList.add("keybar-active");
|
||||
requestAnimationFrame(tryFit);
|
||||
});
|
||||
ta.addEventListener("blur", () => {
|
||||
keybar.el.classList.remove("keybar-active");
|
||||
requestAnimationFrame(tryFit);
|
||||
});
|
||||
}
|
||||
|
||||
let mounted = false;
|
||||
// A previewed pane is visible but must not steal the keyboard from the
|
||||
// sidebar, so mounting can opt out of the focus grab (and out of the one
|
||||
// the socket does when it finishes connecting).
|
||||
let focusOnMount = true;
|
||||
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
||||
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
||||
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
||||
// stops ~1–2 columns short of the edge. Same math, minus that subtraction.
|
||||
const tryFit = () => {
|
||||
if (!mounted) return; // parked views have no meaningful size to fit to
|
||||
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(); // fall back if internals aren't ready yet
|
||||
return;
|
||||
}
|
||||
// Measure the viewport's real inner width (clientWidth excludes any
|
||||
// scrollbar), not the container's — so columns always fit inside the
|
||||
// viewport whether or not xterm reserves a scrollbar. Falls back to the
|
||||
// padded container box before the viewport exists.
|
||||
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;
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify(msg));
|
||||
};
|
||||
|
||||
/** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the
|
||||
* mobile key bar so both flow through the same path. */
|
||||
const sendInput = (data: string) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
tryFit();
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
ws = new WebSocket(attachWsUrl(sessionId));
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setState("connected", "open");
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
if (mounted && focusOnMount) 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) {
|
||||
term.write(new Uint8Array(ev.data));
|
||||
} 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)));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setState("error", "error");
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
// The web proxy relays a daemon rejection (unknown session) as an
|
||||
// application close code (4000+) carrying a human reason. Show it in the
|
||||
// pane — both the header and the terminal body — and KEEP the pane up so
|
||||
// the user can read why, instead of letting it flash and vanish (#39).
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason && !disposed) {
|
||||
setState(reason, "error");
|
||||
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
// An intentional dispose has already set `disposed`; eviction and user
|
||||
// close handle their own pane teardown.
|
||||
if (disposed) return;
|
||||
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setState("reconnecting…", "connecting");
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(`closed (${ev.code})`, "closed");
|
||||
opts.onClosed?.(sessionId);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
|
||||
// Ctrl modifier (mobile) folds into the next typed character here.
|
||||
const dataDisp = term.onData((data) => {
|
||||
sendInput(keybar.consume(data));
|
||||
});
|
||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||
sendControl({ type: "Resize", cols, rows });
|
||||
});
|
||||
// xterm has no built-in copy-on-select; mirror the selection to the
|
||||
// clipboard ourselves (best-effort — insecure contexts lack the API).
|
||||
const selectionDisp = term.onSelectionChange(() => {
|
||||
const text = term.getSelection();
|
||||
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
const visual = window.visualViewport ?? null;
|
||||
visual?.addEventListener("resize", onResize);
|
||||
|
||||
// FitAddon only recomputes on the events we call it from — a window resize
|
||||
// misses container-driven changes (sidebar drag, mobile layout settling,
|
||||
// orientation, late font load). Observe the body directly so columns always
|
||||
// match the real rendered width and the terminal fills edge to edge.
|
||||
const ro = new ResizeObserver(() => tryFit());
|
||||
ro.observe(body);
|
||||
// Re-fit once the monospace font is actually loaded — cell width changes when
|
||||
// the fallback is swapped out, which otherwise leaves stale columns.
|
||||
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
|
||||
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
el,
|
||||
onMount(mountOpts?: { focus?: boolean }) {
|
||||
mounted = true;
|
||||
focusOnMount = mountOpts?.focus !== false;
|
||||
acquireWebgl();
|
||||
// Defer a tick so the element has its content-pane dimensions before fit.
|
||||
requestAnimationFrame(() => {
|
||||
tryFit();
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
|
||||
});
|
||||
},
|
||||
onUnmount() {
|
||||
mounted = false;
|
||||
// Parked views hand their GPU context back: the browser's cap is small
|
||||
// (smaller still on Android) and MAX_PARKED connections outlive the pane.
|
||||
releaseWebgl();
|
||||
},
|
||||
focusTerminal() {
|
||||
term.focus();
|
||||
},
|
||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||
// bar already take.
|
||||
sendInput,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", onResize);
|
||||
visual?.removeEventListener("resize", onResize);
|
||||
dataDisp.dispose();
|
||||
resizeDisp.dispose();
|
||||
selectionDisp.dispose();
|
||||
releaseWebgl();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
term.dispose();
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One key in the mobile key bar.
|
||||
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
|
||||
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
|
||||
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
|
||||
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
|
||||
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
|
||||
* - `char` is a literal character (the symbols the soft keyboard buries behind
|
||||
* a page switch), folded through a pending modifier like typed input.
|
||||
* - `seq` is a plain key (Esc, Tab) sent verbatim.
|
||||
*/
|
||||
type KeyDef =
|
||||
| { label: string; mod: Modifier }
|
||||
| { label: string; cursor: CursorFinal }
|
||||
| { label: string; tilde: number }
|
||||
| { label: string; char: string }
|
||||
| { label: string; seq: string };
|
||||
|
||||
type Modifier = "ctrl" | "alt";
|
||||
/** CSI final bytes: arrows A–D, Home `H`, End `F`. */
|
||||
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
||||
|
||||
/**
|
||||
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
|
||||
* 8-column / 2-row grid (#7):
|
||||
*
|
||||
* Esc Tab Home ↑ End PgUp - /
|
||||
* Ctrl Alt ← ↓ → PgDn | ~
|
||||
*
|
||||
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a
|
||||
* real-keyboard inverted-T in the middle with Home/End filling its top corners,
|
||||
* then the paging pair and the shell symbols that cost a soft-keyboard page
|
||||
* switch to reach. Narrower screens scroll the bar sideways rather than
|
||||
* shrinking the caps below a thumb's width.
|
||||
*/
|
||||
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: "~" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fold a single character into its control code: letters and `@[\]^_` map to
|
||||
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
|
||||
*/
|
||||
interface KeyBar {
|
||||
readonly el: HTMLElement;
|
||||
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
|
||||
consume(data: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Termux-style special-key bar. Hidden by default; CSS reveals it on
|
||||
* coarse-pointer (touch) devices. `send` delivers bytes to the PTY.
|
||||
*
|
||||
* Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key —
|
||||
* tapped here or typed on the soft keyboard — carries the modifier, then they
|
||||
* disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and
|
||||
* the keyboard stays open.
|
||||
*/
|
||||
function buildKeyBar(send: (data: string) => void): KeyBar {
|
||||
const el = document.createElement("div");
|
||||
el.className = "attach-keybar";
|
||||
el.setAttribute("role", "toolbar");
|
||||
el.setAttribute("aria-label", "Special keys");
|
||||
|
||||
const armed: Record<Modifier, boolean> = { ctrl: false, alt: false };
|
||||
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
|
||||
|
||||
const setMod = (mod: Modifier, on: boolean) => {
|
||||
armed[mod] = on;
|
||||
modBtns[mod]?.setAttribute("aria-pressed", String(on));
|
||||
};
|
||||
const clearMods = () => {
|
||||
setMod("ctrl", false);
|
||||
setMod("alt", false);
|
||||
};
|
||||
|
||||
for (const def of KEY_DEFS) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "keycap";
|
||||
btn.textContent = def.label;
|
||||
|
||||
if ("mod" in def) {
|
||||
btn.classList.add("keycap-mod");
|
||||
btn.setAttribute("aria-pressed", "false");
|
||||
btn.setAttribute("aria-label", `${def.label} modifier`);
|
||||
modBtns[def.mod] = btn;
|
||||
btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod]));
|
||||
} else if ("cursor" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else if ("tilde" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(tildeSeq(def.tilde, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else if ("char" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(applyModsToChar(def.char, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else {
|
||||
btn.addEventListener("click", () => {
|
||||
send(def.seq);
|
||||
clearMods();
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
|
||||
// preventDefault suppresses the native `:active` style, so flash an explicit
|
||||
// press animation here for tactile feedback.
|
||||
btn.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
btn.classList.remove("is-pressed");
|
||||
void btn.offsetWidth; // restart the animation if re-tapped mid-flash
|
||||
btn.classList.add("is-pressed");
|
||||
});
|
||||
btn.addEventListener("animationend", () => btn.classList.remove("is-pressed"));
|
||||
el.appendChild(btn);
|
||||
}
|
||||
|
||||
return {
|
||||
el,
|
||||
consume(data: string): string {
|
||||
if ((armed.ctrl || armed.alt) && data.length === 1) {
|
||||
const out = applyModsToChar(data, armed.ctrl, armed.alt);
|
||||
clearMods();
|
||||
return out;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The sidebar's half of the command bar's action page (#151): the bar picks
|
||||
// kill or rename and raises the flow the card already has, so what the page
|
||||
// runs is what the ✕ button and the `r` key run.
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
});
|
||||
|
||||
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector(".kill-dialog")?.remove();
|
||||
mounted?.dispose();
|
||||
mounted = null;
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: { name: "amber-falcon" },
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function mount(s: Session): HTMLElement {
|
||||
const handle = renderSidebar(storeWith([s]));
|
||||
mounted = handle;
|
||||
document.body.appendChild(handle.el);
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function raise(name: string, s: Session): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
||||
}
|
||||
|
||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("kill from the action page", () => {
|
||||
it("goes through the same confirmation the sidebar raises (#160)", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const s = session();
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
|
||||
"Kill amber-falcon",
|
||||
);
|
||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
||||
window.fetch = ((url: string) => {
|
||||
if (String(url).includes("force=true")) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "kill_guarded",
|
||||
message: "worktree has unsaved work",
|
||||
unsaved_work: { untracked_files: 1 },
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const s = session({ blocked_work: { untracked_files: 1 } });
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename from the action page", () => {
|
||||
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
||||
folder?.click();
|
||||
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
||||
});
|
||||
|
||||
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||
expect(input?.value).toBe("amber-falcon");
|
||||
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
||||
});
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
|
||||
// happy-dom runs without a backing store; renderSidebar reads the persisted
|
||||
// sidebar width on mount.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
// No daemon here; renderSidebar treats a failed info fetch as "absolute
|
||||
// paths are a fine fallback".
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector(".kill-dialog")?.remove();
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
// Like the real store, deliver an immediate snapshot so the sidebar
|
||||
// renders its rows during construction.
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
||||
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||
}
|
||||
|
||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("blocked-work indicator (#83)", () => {
|
||||
it("marks the row of a blocked session in diff notation", () => {
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
blocked_work: {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
insertions: 124,
|
||||
deletions: 18,
|
||||
},
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||
expect(badge?.textContent).toBe("+124-18↑2");
|
||||
// The spelled-out breakdown survives in the tooltip.
|
||||
expect(badge?.getAttribute("title")).toContain(
|
||||
"3 uncommitted files, 2 unpushed commits",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the file count when tracked changes carry no lines", () => {
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001a",
|
||||
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||
expect(badge?.textContent).toBe("~2?1");
|
||||
});
|
||||
|
||||
it("does not mark clear or unprobed sessions", () => {
|
||||
const clear = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000002",
|
||||
blocked_work: {} as InTheWay,
|
||||
});
|
||||
const unprobed = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000003",
|
||||
});
|
||||
const el = renderSidebar(storeWith([clear, unprobed])).el;
|
||||
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
});
|
||||
|
||||
it("confirms before an unguarded kill (#160)", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
|
||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("kills nothing when the confirmation is cancelled (#160)", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||
await flush();
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
|
||||
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000008",
|
||||
blocked_work: { untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
expect(dialog?.textContent).toContain("refused unless it is forced");
|
||||
});
|
||||
|
||||
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "kill_guarded",
|
||||
message: "worktree has unsaved work",
|
||||
unsaved_work: { untracked_files: 1 },
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
||||
command: "bash",
|
||||
blocked_work: { untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the override prompt without killing on cancel", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { code: "kill_guarded", message: "worktree has unsaved work" },
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000006",
|
||||
blocked_work: { unpushed_commits: 2 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent-reported detail (#112)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("renders the reported detail right after the duration on line 2", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000020",
|
||||
status: "Dead",
|
||||
activity: "Error",
|
||||
activity_since: ago(90_000),
|
||||
activity_detail: "cargo build failed",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
|
||||
const order = [...line2!.children].map((c) => c.className);
|
||||
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
|
||||
expect(order.indexOf("activity-detail")).toBeLessThan(
|
||||
order.findIndex((c) => c.startsWith("card-flag")),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the detail when no report is in force", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000021",
|
||||
activity: "Active",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session cards (#107)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
|
||||
expect(line1).not.toBeNull();
|
||||
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
|
||||
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
|
||||
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
||||
});
|
||||
|
||||
it("always renders line 2 for a freshly spawned session", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
||||
activity: "Active",
|
||||
activity_since: ago(5_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id);
|
||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2).not.toBeNull();
|
||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000012",
|
||||
activity: "Active",
|
||||
activity_since: ago(4 * 60_000 + 30_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id);
|
||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.dataset.activity).toBe("active");
|
||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("dims a name the daemon generated, until it settles (#131)", () => {
|
||||
const generated = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000a1",
|
||||
metadata: { name: "amber-falcon", name_source: "Generated" },
|
||||
});
|
||||
const haiku = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000a2",
|
||||
metadata: { name: "sidebar-rail", name_source: "Haiku" },
|
||||
});
|
||||
const chosen = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000a3",
|
||||
metadata: { name: "my-session", name_source: "User" },
|
||||
});
|
||||
// Named before `name_source` existed: settled, never dimmed.
|
||||
const legacy = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000a4",
|
||||
metadata: { name: "older-session" },
|
||||
});
|
||||
|
||||
const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
|
||||
const provisional = (s: Session) =>
|
||||
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
|
||||
expect(provisional(generated)).toBe(true);
|
||||
expect(provisional(haiku)).toBe(false);
|
||||
expect(provisional(chosen)).toBe(false);
|
||||
expect(provisional(legacy)).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the command as its tool icon when the tool is known", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001b",
|
||||
command: "claude",
|
||||
metadata: { name: "claude" },
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||
expect(cmd?.getAttribute("title")).toBe("claude");
|
||||
// The icon said it all: no text left over.
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the command's arguments, keeping them in the tooltip", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
||||
command: "claude --dangerously-skip-permissions",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
|
||||
});
|
||||
|
||||
it("keeps an unknown command as plain text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001d",
|
||||
command: "hx src/main.rs",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
|
||||
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
const mk = (id: string, activity: string) =>
|
||||
session({
|
||||
id,
|
||||
activity: activity as Session["activity"],
|
||||
status: "Running",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
|
||||
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
|
||||
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
|
||||
const el = renderSidebar(storeWith([idle, wait, error])).el;
|
||||
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"idle",
|
||||
);
|
||||
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an unknown activity variant as idle instead of failing to render", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000016",
|
||||
activity: "Teleported" as Session["activity"],
|
||||
activity_since: ago(120_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
|
||||
expect(line2?.getAttribute("data-activity")).toBe("idle");
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
|
||||
});
|
||||
|
||||
it("moves blocked work onto line 2 as diff notation", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000017",
|
||||
blocked_work: {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
insertions: 124,
|
||||
deletions: 18,
|
||||
},
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("+124");
|
||||
expect(line2?.textContent).toContain("-18");
|
||||
// Line 1 carries no badges.
|
||||
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows exited on line 2 for dead sessions", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000018",
|
||||
status: "Dead",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("exited");
|
||||
});
|
||||
|
||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
command: "claude",
|
||||
metadata: { name: "worker" },
|
||||
activity: "AwaitingInput",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
const rail = row.querySelector<HTMLElement>(".activity-rail");
|
||||
expect(rail?.getAttribute("title")).toContain("Awaiting input");
|
||||
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
expect(row.textContent).not.toMatch(/awaiting.?input/i);
|
||||
});
|
||||
|
||||
it("keeps the attach navigation and depth indentation", () => {
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLAnchorElement;
|
||||
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
|
||||
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
|
||||
});
|
||||
});
|
||||
@@ -1,871 +0,0 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
||||
import { openContextMenu } from "./contextMenu.js";
|
||||
import { toggleHelp } from "./help.js";
|
||||
import { CommandSet, defineCommands } from "./commands.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
import {
|
||||
abbreviateCwd,
|
||||
abbreviateFolderName,
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
formatInTheWay,
|
||||
MAX_SESSION_NAME_LEN,
|
||||
normalizeSessionName,
|
||||
sessionBlockers,
|
||||
isProvisionalName,
|
||||
sessionName,
|
||||
sessionTreePath,
|
||||
} from "./types.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
META_LINE_MIN_HOLD_MS,
|
||||
} from "./activity.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createPaletteSelect, createThemeToggle } from "./theme.js";
|
||||
import type { DaemonLink } from "./daemonLink.js";
|
||||
import { createStatusIndicator } from "./statusIndicator.js";
|
||||
|
||||
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
|
||||
* multi-view controller lives; dispatched from every Spawn affordance. */
|
||||
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
|
||||
export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to open the command bar with an empty query. Dispatched by
|
||||
* the sidebar's search field, which is the touch entry point to the bar
|
||||
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
|
||||
* Handled in main.ts, which owns the session snapshot the bar needs. */
|
||||
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
|
||||
export function requestOpenCommandBar(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||
* connection pool. */
|
||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||
|
||||
/** Ask the shell to open the spawn modal seeded from an existing session
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
/** Ask the sidebar to kill a session — the command bar's action page picking
|
||||
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
||||
* through the same confirm-and-blockers flow the card's ✕ does
|
||||
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
||||
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
||||
|
||||
/** Ask the sidebar to rename a session — the command bar's action page picking
|
||||
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
||||
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
||||
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
||||
|
||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||
* hash navigation, but a split/drag can move focus without ever touching
|
||||
* `location.hash` — so a click back onto a session whose hash still
|
||||
* matches fires no `hashchange` and the app never hears about it. This
|
||||
* event is the click-driven path that doesn't depend on the hash actually
|
||||
* changing. */
|
||||
export const SESSION_SELECT_EVENT = "catalog-session-select";
|
||||
|
||||
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
|
||||
* folder headers are skipped: a directory is not something to focus, and the
|
||||
* cards under it are one row away anyway. A *collapsed* folder still counts —
|
||||
* its header is the only row it has, so skipping it would leave the folder
|
||||
* unreachable. Same rule as the TUI sidebar. */
|
||||
const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]';
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
/** Session whose card is currently being renamed inline (#125). The tree
|
||||
* re-renders on every catalog delta, and an activity update arriving mid-edit
|
||||
* would otherwise replace the field the user is typing into. */
|
||||
let renamingId: string | null = null;
|
||||
|
||||
// docs/SIDEBAR.md: the state word is never rendered — the rail's colour
|
||||
// carries it. It survives only in tooltips/aria labels so meaning is not
|
||||
// colour-only.
|
||||
const STATE_WORDS: Record<string, string> = {
|
||||
active: "Active",
|
||||
idle: "Idle",
|
||||
"awaiting-input": "Awaiting input",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
||||
* full height. The row's own padding (`renderNode`) shifts outward with
|
||||
* nesting depth, carrying the rail with it. */
|
||||
function activityRailMarkup(slug: string, stateWord: string): string {
|
||||
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
|
||||
}
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const dead = s.status === "Dead";
|
||||
const blockers = sessionBlockers(s);
|
||||
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 fragments = [
|
||||
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
||||
s.activity_detail
|
||||
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
||||
: "",
|
||||
commandFragment(s.command),
|
||||
blockers ? blockedFragment(blockers) : "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
|
||||
* — with the spelled-out breakdown kept in the tooltip. */
|
||||
function blockedFragment(blockers: InTheWay): string {
|
||||
const stats = blockerStats(blockers)
|
||||
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
|
||||
.join("");
|
||||
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
|
||||
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
|
||||
* the card. The program becomes its tool icon when we have a mark for it, and
|
||||
* stays as its bare name when we do not. The full command line, arguments and
|
||||
* all, stays in the tooltip either way. */
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
||||
}
|
||||
|
||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
if (node.kind === "folder") {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "tree-folder";
|
||||
|
||||
if (node.name) {
|
||||
const header = document.createElement("button");
|
||||
header.type = "button";
|
||||
header.className = "tree-folder-header";
|
||||
header.style.paddingLeft = `${depth * 16}px`;
|
||||
const isCollapsed = collapsed.has(node.path);
|
||||
header.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
header.dataset.navKey = `folder:${node.path}`;
|
||||
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
|
||||
// glyph and the rotation never fight each other.
|
||||
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
|
||||
// segments to initials (`~/g/n/maestro`) so the dir name stays readable in
|
||||
// a narrow sidebar; the full path lives in the tooltip.
|
||||
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span>`;
|
||||
header.addEventListener("click", () => {
|
||||
if (collapsed.has(node.path)) collapsed.delete(node.path);
|
||||
else collapsed.add(node.path);
|
||||
// re-render handled by caller observing the store; trigger a refresh:
|
||||
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
if (isCollapsed) return wrap;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// The row outlives the snapshot it was built from: a refresh that changes
|
||||
// nothing structural patches it in place rather than replacing it, so the
|
||||
// handlers below have to read the current session, not the one captured here.
|
||||
let cur = node.session;
|
||||
const s = node.session;
|
||||
const now = Date.now();
|
||||
const row = document.createElement("a");
|
||||
row.className = "tree-leaf session-card";
|
||||
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
row.dataset.sessionId = s.id;
|
||||
row.dataset.navKey = `session:${s.id}`;
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
const name = sessionName(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||
row.innerHTML = `
|
||||
${activityRailMarkup(slug, stateWord)}
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${metaLine(s, now)}
|
||||
`;
|
||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
requestKill(cur, sessionName(cur));
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
openContextMenu(ev.clientX, ev.clientY, [
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
onSelect: () => requestKill(cur, sessionName(cur)),
|
||||
},
|
||||
]);
|
||||
});
|
||||
leafPatches.set(row, (next) => {
|
||||
cur = next;
|
||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||
);
|
||||
const rail = row.querySelector(".activity-rail");
|
||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||
const meta = row.querySelector(".card-line2");
|
||||
if (meta) meta.outerHTML = metaLine(next, Date.now());
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Refresh a standing card from a newer snapshot of its session, keeping the
|
||||
* element (and so the pointer's `:hover`) exactly where it is. Registered by
|
||||
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
|
||||
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
|
||||
|
||||
/**
|
||||
* Everything about the tree that a patch cannot fix up.
|
||||
*
|
||||
* The daemon re-sends a session on every activity, foreground or cwd shift —
|
||||
* a couple of times a minute per live session — and rebuilding the sidebar for
|
||||
* each one drops `:hover` from the row under the pointer. Refreshes whose
|
||||
* signature is unchanged are patched into the standing rows instead; anything
|
||||
* that moves, renames, appears or dies changes the signature and rebuilds.
|
||||
*/
|
||||
function treeSignature(node: TreeNode): string {
|
||||
if (node.kind === "folder") {
|
||||
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
|
||||
if (node.name && collapsed.has(node.path)) return head;
|
||||
return `${head}(${node.children.map(treeSignature).join(",")})`;
|
||||
}
|
||||
const s = node.session;
|
||||
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill behind a confirmation (#160): a kill is destructive and unattended
|
||||
* agents live in these sessions, so the web asks first, the way the TUI always
|
||||
* has. The daemon decides the rest — it refuses a kill over a worktree holding
|
||||
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
|
||||
* a forced kill. Anything else surfaces in the dialog that asked.
|
||||
*/
|
||||
function requestKill(s: Session, name: string): void {
|
||||
openKillConfirmDialog({
|
||||
name,
|
||||
blockers: sessionBlockers(s),
|
||||
onKill: async () => {
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
openForceKillDialog({
|
||||
name,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
reason: e.detail,
|
||||
onForce: () => killSession(s.id, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the card's name for a text field (#125). Enter commits, Escape and
|
||||
* blur cancel. An emptied field clears the name, so the card falls back to
|
||||
* the command — the same rule the spawn form's blank name field follows.
|
||||
*/
|
||||
function beginRename(row: HTMLElement, s: Session): void {
|
||||
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
|
||||
if (!nameEl) return;
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "leaf-name leaf-rename";
|
||||
input.value = customSessionName(s) ?? "";
|
||||
input.maxLength = MAX_SESSION_NAME_LEN;
|
||||
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
|
||||
// The card is an <a>, and the tree starts a drag on pointerdown: without
|
||||
// these, clicking into the field navigates away or begins dragging the row.
|
||||
for (const type of ["click", "pointerdown", "dblclick"]) {
|
||||
input.addEventListener(type, (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
renamingId = s.id;
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
renamingId = null;
|
||||
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
||||
return true;
|
||||
};
|
||||
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
// Normalised here so what is sent is what the daemon will store.
|
||||
const name = normalizeSessionName(input.value);
|
||||
if (!finish()) return;
|
||||
void renameSession(s.id, name).catch((e: Error) => {
|
||||
alert(`Failed to rename: ${e.message}`);
|
||||
});
|
||||
} else if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
input.addEventListener("blur", () => finish());
|
||||
|
||||
nameEl.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
// The tree has one empty state: no sessions at all. The old "No matches" one
|
||||
// belonged to the inline substring filter, which the command bar replaced
|
||||
// (#157) — the bar's own no-results state took over with it.
|
||||
function renderEmpty(onSpawn: () => void): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "empty";
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||
<div class="empty-title">No sessions yet</div>
|
||||
<p class="empty-hint">Start your first session — it'll show up here.</p>
|
||||
<div class="empty-actions"></div>`;
|
||||
const spawn = document.createElement("button");
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.addEventListener("click", onSpawn);
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
||||
* pane (quiet), and which one is focused (loud). */
|
||||
export interface SidebarHighlights {
|
||||
open: Set<string>;
|
||||
focused: string | null;
|
||||
}
|
||||
|
||||
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
||||
export interface SidebarHandle {
|
||||
/** The `<aside>` element to mount into the shell. */
|
||||
el: HTMLElement;
|
||||
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
||||
* Back-compat shim over {@link setHighlights}. */
|
||||
setActive(id: string | null): void;
|
||||
/** Apply the full open/focused highlight state. */
|
||||
setHighlights(h: SidebarHighlights): void;
|
||||
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
|
||||
* with `null`. A preview is a view, not state, so this only paints. */
|
||||
setPreview(sessionId: string | null): void;
|
||||
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
||||
focusList(): void;
|
||||
/** Whether the session list currently holds DOM focus. */
|
||||
hasListFocus(): boolean;
|
||||
/** Tear down the store subscription + listeners. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
|
||||
* live session status line, and the folder-grouped session tree. Unlike
|
||||
* the old full-screen catalog this is mounted once by the shell and stays put
|
||||
* while the content pane swaps between sessions — so the store subscription and
|
||||
* scroll position survive navigation. The active session is highlighted.
|
||||
*/
|
||||
export interface SidebarOptions {
|
||||
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
||||
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
||||
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
||||
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
||||
* design kit, which render the sidebar without a daemon. */
|
||||
link?: DaemonLink;
|
||||
/** The highlight moved onto a session, or off every session (`null`): the
|
||||
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
||||
* `source` is there so a mouse sweep can be debounced differently from a
|
||||
* keyboard step. */
|
||||
onPreview?: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||
const el = document.createElement("aside");
|
||||
el.className = "sidebar";
|
||||
|
||||
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
|
||||
// renders with absolute paths until it lands.
|
||||
let homeDir: string | undefined;
|
||||
|
||||
// The field never holds a query: it is the touch entry point to the command
|
||||
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
|
||||
// on-screen keyboard over a field nothing is typed into.
|
||||
const searchHeader = document.createElement("header");
|
||||
searchHeader.className = "sidebar-search";
|
||||
searchHeader.innerHTML = `
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
readonly
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
`;
|
||||
el.appendChild(searchHeader);
|
||||
|
||||
const treeHost = document.createElement("div");
|
||||
treeHost.className = "tree";
|
||||
el.appendChild(treeHost);
|
||||
|
||||
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
|
||||
// rather than the sidebar's own scrolling content, so they stay reachable
|
||||
// regardless of tree scroll position.
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "sidebar-footer";
|
||||
const helpBtn = document.createElement("button");
|
||||
helpBtn.type = "button";
|
||||
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
||||
helpBtn.textContent = "?";
|
||||
helpBtn.title = "Keyboard shortcuts (?)";
|
||||
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
||||
helpBtn.addEventListener("click", () => toggleHelp());
|
||||
footer.appendChild(helpBtn);
|
||||
footer.appendChild(createThemeToggle());
|
||||
footer.appendChild(createPaletteSelect());
|
||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||
if (import.meta.env.DEV) {
|
||||
const kit = document.createElement("a");
|
||||
kit.className = "btn btn-ghost";
|
||||
kit.href = "?kit";
|
||||
kit.textContent = "kit";
|
||||
kit.title = "Open the design kit (DEV)";
|
||||
footer.appendChild(kit);
|
||||
}
|
||||
// Daemon status sits beside the spawn button: colour for the link state,
|
||||
// hover for the release the daemon is running.
|
||||
const status = opts.link ? createStatusIndicator(opts.link) : null;
|
||||
if (status) footer.appendChild(status.el);
|
||||
const spawnBtn = document.createElement("button");
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
// The keyboard pair sits right here in the tooltip (#67).
|
||||
spawnBtn.title = "Spawn form: n — fast-forward spawn: N";
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
footer.appendChild(spawnBtn);
|
||||
el.appendChild(footer);
|
||||
|
||||
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
||||
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
||||
treeHost.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0 || !opts.onLeafDrag) return;
|
||||
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
|
||||
const id = leaf?.dataset.sessionId;
|
||||
if (id) opts.onLeafDrag(id, e);
|
||||
});
|
||||
// The rows are <a> elements, so the browser's native link drag-and-drop would
|
||||
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
|
||||
// until release. Suppress it so the pointer-based drag runs from the start.
|
||||
treeHost.addEventListener("dragstart", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
|
||||
});
|
||||
|
||||
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
|
||||
// width as a :root CSS var the shell grid reads; clamped to a sane range.
|
||||
const SIDEBAR_W_KEY = "maestro-sidebar-w";
|
||||
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
|
||||
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "sidebar-resize";
|
||||
handle.setAttribute("aria-hidden", "true");
|
||||
el.appendChild(handle);
|
||||
let dragStartX = 0;
|
||||
let dragStartW = 0;
|
||||
let dragging = false;
|
||||
handle.addEventListener("pointerdown", (e) => {
|
||||
dragging = true;
|
||||
dragStartX = e.clientX;
|
||||
dragStartW = el.getBoundingClientRect().width;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
document.body.style.userSelect = "none";
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
|
||||
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
|
||||
});
|
||||
const endDrag = (e: PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
try {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// pointer may already be released
|
||||
}
|
||||
document.body.style.userSelect = "";
|
||||
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
|
||||
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
|
||||
};
|
||||
handle.addEventListener("pointerup", endDrag);
|
||||
handle.addEventListener("pointercancel", endDrag);
|
||||
|
||||
// Touch entry (#157): the field is a button that looks like a field. It
|
||||
// never holds a query — focusing or tapping it hands the keyboard straight
|
||||
// to the bar's own input, which is also why it blurs first: leaving the
|
||||
// focus here would make the bar impossible to close without reopening it.
|
||||
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
||||
const openBar = () => {
|
||||
search.value = "";
|
||||
search.blur();
|
||||
requestOpenCommandBar();
|
||||
};
|
||||
search.addEventListener("focus", openBar);
|
||||
// A tap can land without a focus event (iOS Safari on a blurred field), so
|
||||
// the pointer path opens the bar too; the blur above keeps the two from
|
||||
// stacking two overlays.
|
||||
search.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
openBar();
|
||||
});
|
||||
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||
// The previewed row is painted alongside the open/focused tiers so a
|
||||
// re-render can't drop it, but it is never part of `highlights`: a preview
|
||||
// is a view, and nothing about it is durable (docs/PREVIEW.md).
|
||||
let previewing: string | null = null;
|
||||
const applyHighlights = () => {
|
||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const id = n.dataset.sessionId;
|
||||
if (!id) continue;
|
||||
const focused = highlights.focused === id;
|
||||
const open = highlights.open.has(id);
|
||||
n.classList.toggle("is-active", focused);
|
||||
n.classList.toggle("is-open", open && !focused);
|
||||
n.classList.toggle("is-previewing", previewing === id);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- keyboard navigation ----
|
||||
// The walk reads the rendered DOM rather than a parallel model, so it can
|
||||
// never disagree with what is on screen. Rows carry a `data-nav-key`
|
||||
// (`session:<id>` / `folder:<path>`) that survives a re-render.
|
||||
let navKey: string | null = null;
|
||||
|
||||
const navRows = (): HTMLElement[] => [
|
||||
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
|
||||
];
|
||||
|
||||
const rowFor = (key: string | null): HTMLElement | undefined =>
|
||||
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
|
||||
|
||||
const applyNav = () => {
|
||||
const rows = navRows();
|
||||
// A row that went away (session killed, folder collapsed) drops the
|
||||
// highlight rather than leaving it pointing at nothing.
|
||||
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
|
||||
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
||||
};
|
||||
|
||||
const sessionOf = (key: string | null): string | null =>
|
||||
key?.startsWith("session:") ? key.slice("session:".length) : null;
|
||||
|
||||
const moveNav = (delta: number) => {
|
||||
const rows = navRows();
|
||||
if (rows.length === 0) return;
|
||||
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
|
||||
// Nothing highlighted: start one off the end we are walking 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];
|
||||
navKey = next.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
next.scrollIntoView({ block: "nearest" });
|
||||
opts.onPreview?.(sessionOf(navKey));
|
||||
};
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||
const activateNav = () => {
|
||||
const row = rowFor(navKey);
|
||||
if (!row) return;
|
||||
if (row.classList.contains("tree-leaf")) {
|
||||
row.click();
|
||||
return;
|
||||
}
|
||||
// Expanding replaces the folder's header in the walk with the rows it was
|
||||
// hiding, so land on the first of them instead of losing the highlight.
|
||||
const key = navKey;
|
||||
row.click();
|
||||
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
|
||||
(h) => h.dataset.navKey === key,
|
||||
);
|
||||
const revealed = header?.parentElement
|
||||
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
|
||||
: undefined;
|
||||
navKey = revealed?.dataset.navKey ?? null;
|
||||
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.
|
||||
let hovered: string | null = null;
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
hovered = id;
|
||||
opts.onPreview?.(id);
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||
setHovered(row?.dataset.sessionId ?? null);
|
||||
});
|
||||
treeHost.addEventListener("pointerleave", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setHovered(null);
|
||||
});
|
||||
|
||||
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
||||
// keyboard stays here while j/k runs.
|
||||
treeHost.tabIndex = -1;
|
||||
// The keyboard walk's preview belongs to the list having the keyboard: once
|
||||
// it doesn't, the content pane goes back to what the operator actually opened.
|
||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
|
||||
|
||||
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||
const row = rowFor(navKey);
|
||||
const id = row?.dataset.sessionId;
|
||||
if (!row || !id) return undefined;
|
||||
const session = store.snapshot().sessions.get(id);
|
||||
return session ? { row, session } : undefined;
|
||||
};
|
||||
|
||||
/** `c`: collapse or expand the highlighted folder, the TUI's
|
||||
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
|
||||
* the key does nothing rather than toggling some other folder. */
|
||||
const toggleHighlightedFolder = () => {
|
||||
const row = rowFor(navKey);
|
||||
if (!row || !row.classList.contains("tree-folder-header")) return;
|
||||
row.click();
|
||||
applyNav();
|
||||
};
|
||||
|
||||
// The session list's half of the command registry (#148). One set per
|
||||
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
|
||||
// labels and chords still come from the one registry.
|
||||
const listKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"list.next": () => moveNav(1),
|
||||
"list.prev": () => moveNav(-1),
|
||||
"list.activate": () => activateNav(),
|
||||
"list.toggleFolder": () => toggleHighlightedFolder(),
|
||||
"list.rename": () => {
|
||||
const hit = highlightedSession();
|
||||
if (hit) beginRename(hit.row, hit.session);
|
||||
},
|
||||
"list.kill": () => {
|
||||
const hit = highlightedSession();
|
||||
if (hit) requestKill(hit.session, sessionName(hit.session));
|
||||
},
|
||||
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
||||
}),
|
||||
);
|
||||
|
||||
// The inline rename field lives inside a row and owns its own keys; the set's
|
||||
// plain-key rule keeps this walk out of it.
|
||||
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
|
||||
|
||||
/** Signature of what is currently on screen — see `treeSignature`. */
|
||||
let renderedSignature: string | null = null;
|
||||
|
||||
const refresh = () => {
|
||||
// Rebuilding the tree now would blow away the field being typed into.
|
||||
if (renamingId !== null) return;
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir);
|
||||
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
|
||||
if (signature === renderedSignature) {
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||
if (s) leafPatches.get(row)?.(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
||||
return;
|
||||
}
|
||||
if (tree.kind === "folder") {
|
||||
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
||||
}
|
||||
applyHighlights();
|
||||
applyNav();
|
||||
};
|
||||
|
||||
void getDaemonInfo()
|
||||
.then((info) => {
|
||||
homeDir = info.home_dir;
|
||||
refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
/* absolute paths are a fine fallback */
|
||||
});
|
||||
|
||||
// Dispatched when something outside the store changed what the tree should
|
||||
// look like — a folder collapsed, a rename ended — none of which the store
|
||||
// snapshot can show, so the signature check has to be bypassed.
|
||||
const rerenderHandler = () => {
|
||||
renderedSignature = null;
|
||||
refresh();
|
||||
};
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// The command bar's action page (#151) picks an action and hands it here:
|
||||
// both flows are the ones the card already has, reached by session rather
|
||||
// than by row.
|
||||
const killHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
requestKill(s, sessionName(s));
|
||||
};
|
||||
const renameHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
const row = rowForSession(s);
|
||||
if (row) beginRename(row, s);
|
||||
};
|
||||
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
|
||||
/** The card a session is rendered as, expanding whatever folder hides it
|
||||
* first: the rename prompt lives inside the card, so a collapsed folder
|
||||
* would otherwise swallow the request. */
|
||||
function rowForSession(s: Session): HTMLElement | null {
|
||||
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
||||
const found = find();
|
||||
if (found) return found;
|
||||
// Folder paths are the abbreviated directory's segments joined back up
|
||||
// (see buildTree), so the session's own path is normalised the same way
|
||||
// before it is compared against them.
|
||||
const cwd = sessionTreePath(s);
|
||||
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
||||
for (const folder of [...collapsed]) {
|
||||
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
||||
}
|
||||
rerenderHandler();
|
||||
return find();
|
||||
}
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||
|
||||
const unsubscribe = store.subscribe(refresh);
|
||||
|
||||
// Contract granularity: re-render at least every 30s while visible so the
|
||||
// duration cutover points land within one tick.
|
||||
const clock = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
refresh();
|
||||
}, 30_000);
|
||||
|
||||
return {
|
||||
el,
|
||||
setActive(id: string | null) {
|
||||
highlights = {
|
||||
open: id ? new Set([id]) : new Set(),
|
||||
focused: id,
|
||||
};
|
||||
applyHighlights();
|
||||
},
|
||||
setHighlights(h: SidebarHighlights) {
|
||||
highlights = h;
|
||||
applyHighlights();
|
||||
},
|
||||
setPreview(sessionId: string | null) {
|
||||
previewing = sessionId;
|
||||
applyHighlights();
|
||||
},
|
||||
focusList() {
|
||||
// Start from the session on screen — that is where the user's attention
|
||||
// already is — falling back to the standing highlight, then the top row.
|
||||
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
|
||||
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
|
||||
navKey = start?.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
treeHost.focus();
|
||||
start?.scrollIntoView({ block: "nearest" });
|
||||
},
|
||||
hasListFocus() {
|
||||
return treeHost.contains(document.activeElement);
|
||||
},
|
||||
dispose() {
|
||||
clearInterval(clock);
|
||||
status?.dispose();
|
||||
// An unfinished rename would otherwise leave every later sidebar
|
||||
// refusing to refresh.
|
||||
renamingId = null;
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
const ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: ID,
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A store whose updates actually reach subscribers, unlike the one-shot
|
||||
* harness in catalog.test.ts. */
|
||||
function liveStore(initial: Session[]) {
|
||||
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
|
||||
const listeners = new Set<CatalogListener>();
|
||||
const store = {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
listeners.add(fn);
|
||||
fn(state);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
const push = (sessions: Session[]) => {
|
||||
state.sessions = new Map(sessions.map((s) => [s.id, s]));
|
||||
for (const fn of listeners) fn(state);
|
||||
};
|
||||
return { store, push };
|
||||
}
|
||||
|
||||
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
|
||||
|
||||
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
|
||||
it("patches an activity change into the standing row instead of rebuilding it", () => {
|
||||
const { store, push } = liveStore([session({ activity: "Idle" })]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ activity: "AwaitingInput" })]);
|
||||
|
||||
// Same element: a rebuild under the pointer would drop `:hover` and restart
|
||||
// the row's transitions, which is the flicker this avoids.
|
||||
expect(rowOf(el)).toBe(before);
|
||||
// …and the card still tells the truth about the session.
|
||||
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
});
|
||||
|
||||
it("rebuilds when the row set changes", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
|
||||
|
||||
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
});
|
||||
|
||||
it("rebuilds when a session dies", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ status: "Dead" })]);
|
||||
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
expect(rowOf(el).dataset.status).toBe("dead");
|
||||
});
|
||||
|
||||
it("rebuilds when a session is renamed", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ metadata: { name: "harmony" } })]);
|
||||
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
|
||||
});
|
||||
});
|
||||
@@ -1,723 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
closeCommandBar,
|
||||
commandBarCandidates,
|
||||
dedupeCommands,
|
||||
directoryBarCandidates,
|
||||
directoryCandidate,
|
||||
isCommandBarOpen,
|
||||
openCommandBar,
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionBarCandidates,
|
||||
sessionActionCandidates,
|
||||
sessionCandidate,
|
||||
SPAWN_DIRECTORY_EVENT,
|
||||
} from "./commandBar.js";
|
||||
import { COMMAND_SPECS, type Command } from "./commands.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
closeCommandBar();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function press(key: string, init: KeyboardEventInit = {}): void {
|
||||
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
|
||||
}
|
||||
|
||||
function type(value: string): void {
|
||||
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
function activeName(): string | undefined {
|
||||
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
describe("empty-query ordering", () => {
|
||||
it("sorts most recently focused first", () => {
|
||||
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("sorts a never-focused session after every focused one, newest created first", () => {
|
||||
const focused = session("focused", {
|
||||
last_focused_at: "2020-01-01T00:00:00Z",
|
||||
created_at: "2020-01-01T00:00:00Z",
|
||||
});
|
||||
const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" });
|
||||
const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" });
|
||||
expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([
|
||||
"focused",
|
||||
"new-fresh",
|
||||
"old-fresh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts dead sessions after live ones regardless of recency", () => {
|
||||
const deadRecent = session("dead", {
|
||||
status: "Dead",
|
||||
last_focused_at: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
const liveNever = session("never");
|
||||
expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([
|
||||
"live",
|
||||
"never",
|
||||
"dead",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a null last_focused_at as never focused", () => {
|
||||
const nulled = session("nulled", { last_focused_at: null });
|
||||
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session candidates", () => {
|
||||
it("carries the candidate table's fields, with the directory abbreviated", () => {
|
||||
const s = session("api", {
|
||||
metadata: { name: "api" },
|
||||
command: "claude --resume",
|
||||
current_cwd: "/home/me/dev/api",
|
||||
});
|
||||
expect(sessionCandidate(s, "/home/me")).toEqual({
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: "api",
|
||||
directory: "~/dev/api",
|
||||
command: "claude --resume",
|
||||
tool: "claude",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
|
||||
const s = session("edit", {
|
||||
metadata: {},
|
||||
command: "hx src/",
|
||||
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
|
||||
});
|
||||
const candidate = sessionCandidate(s, "/home/me");
|
||||
expect(candidate.fields.tool).toBeUndefined();
|
||||
expect(candidate.fields.directory).toBe("~/dev/api");
|
||||
// No `metadata.name`: the sidebar's fallback is the command line.
|
||||
expect(candidate.fields.name).toBe("hx src/");
|
||||
});
|
||||
|
||||
it("builds candidates in the empty-query order", () => {
|
||||
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rows and grouping", () => {
|
||||
it("an empty query keeps every session in input order", () => {
|
||||
const candidates = sessionBarCandidates([session("a"), session("b")]);
|
||||
expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("groups by kind without reordering the ranking", () => {
|
||||
const candidates = sessionBarCandidates([
|
||||
session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }),
|
||||
session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }),
|
||||
]);
|
||||
const rows = barRows("api", candidates);
|
||||
const groups = barGroups(rows);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].title).toBe("Sessions");
|
||||
expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual(
|
||||
rows.map((r) => r.candidate.session.id),
|
||||
);
|
||||
// F6: the name beats the directory, and the directory match highlights
|
||||
// inside the directory.
|
||||
expect(groups[0].results[0].candidate.session.id).toBe("api");
|
||||
expect(groups[0].results[1].field).toBe("directory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the overlay", () => {
|
||||
const sessions = [
|
||||
session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
last_focused_at: "2026-01-01T11:00:00Z",
|
||||
}),
|
||||
session("worker", {
|
||||
metadata: { name: "worker" },
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
// Sessions only: the mixed list's command half is the sigil describe's
|
||||
// business, and the registry is whatever another test file happened to bind.
|
||||
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
||||
return openCommandBar(
|
||||
over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} },
|
||||
);
|
||||
}
|
||||
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
||||
expect(spec?.keys).toEqual(["ctrl-k"]);
|
||||
expect(spec?.scope).toBe("global");
|
||||
});
|
||||
|
||||
it("lists sessions by recency under a kind header on an empty query", () => {
|
||||
open();
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
]);
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
expect(activeName()).toBe("api");
|
||||
});
|
||||
|
||||
it("opening twice leaves one overlay", () => {
|
||||
open();
|
||||
open();
|
||||
expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1);
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
||||
open();
|
||||
type("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent);
|
||||
expect(marks).toEqual(["wor"]);
|
||||
});
|
||||
|
||||
it("highlights inside the directory when that is the field that matched", () => {
|
||||
open();
|
||||
type("notes");
|
||||
const row = document.querySelector(".cmdbar-row") as HTMLElement;
|
||||
expect(row.dataset.sessionId).toBe("notes");
|
||||
type("dev/wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect(
|
||||
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
||||
).toBe("dev/wor");
|
||||
});
|
||||
|
||||
it("renders a no-results state rather than an empty list", () => {
|
||||
open();
|
||||
type("zzz");
|
||||
expect(rowNames()).toEqual([]);
|
||||
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
||||
open();
|
||||
press("ArrowUp");
|
||||
expect(activeName()).toBe("api");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
press("n", { ctrlKey: true });
|
||||
expect(activeName()).toBe("notes");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("notes");
|
||||
press("p", { ctrlKey: true });
|
||||
expect(activeName()).toBe("worker");
|
||||
});
|
||||
|
||||
it("puts the highlight back on the best row whenever the query changes", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
type("o");
|
||||
expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent);
|
||||
});
|
||||
|
||||
it("opens the highlighted session through the shared open path on Enter", () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
expect(seen).toEqual(["worker"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("closes on Escape and on a press outside the panel", () => {
|
||||
open();
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
|
||||
open();
|
||||
(document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true }),
|
||||
);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays open for a press inside the panel", () => {
|
||||
open();
|
||||
(document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true }),
|
||||
);
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the keyboard to whatever held it when it opened", () => {
|
||||
const before = document.createElement("button");
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
open();
|
||||
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
|
||||
press("Escape");
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the sigils (#150) ----
|
||||
|
||||
function command(id: string, label: string, keys: string[], run = () => {}): Command {
|
||||
return { id, label, group: "Test", keys, scope: "global", run };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function rowKeys(): string[][] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
|
||||
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
describe("parsing the query line", () => {
|
||||
it("reads a sigil only in first position", () => {
|
||||
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
|
||||
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
|
||||
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
||||
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
|
||||
});
|
||||
|
||||
it("leaves a sigil character elsewhere in the query literal", () => {
|
||||
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
|
||||
// The case that matters: the second `~` of a directory query reaches the
|
||||
// matcher.
|
||||
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
|
||||
});
|
||||
|
||||
it("returns to the mixed list when the sigil is backspaced away", () => {
|
||||
expect(parseBarQuery(">").sigil).toBe(">");
|
||||
expect(parseBarQuery("").sigil).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command candidates", () => {
|
||||
it("dedupes by action, not by chord", () => {
|
||||
const run = () => {};
|
||||
const kill = command("a.kill", "Kill the session", ["x"], run);
|
||||
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
|
||||
const other = command("c.rename", "Rename the session", ["r"]);
|
||||
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
|
||||
});
|
||||
|
||||
it("matches on the label only, and keeps the chord for display", () => {
|
||||
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
|
||||
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
|
||||
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory candidates", () => {
|
||||
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
||||
const c = directoryCandidate("/home/me/dev/api", "/home/me");
|
||||
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
|
||||
});
|
||||
|
||||
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
||||
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
|
||||
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
|
||||
"/home/me/dev/api",
|
||||
"/tmp/scratch",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sigils in the overlay", () => {
|
||||
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
|
||||
const ran: string[] = [];
|
||||
const commands = [
|
||||
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
|
||||
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
ran.length = 0;
|
||||
});
|
||||
|
||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||
return openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands,
|
||||
onOpenSession: () => {},
|
||||
loadDirectories: async () => [],
|
||||
listDirEntries: async () => [],
|
||||
onSpawnDirectory: () => {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||
open();
|
||||
type(">");
|
||||
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
|
||||
expect(rowKeys()).toEqual([["n"], ["x"]]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("matches a command on its label and never on its chord", () => {
|
||||
open();
|
||||
type(">kill");
|
||||
expect(rowNames()).toEqual(["Kill the highlighted session"]);
|
||||
// `n` and `x` are chords, not label text: querying one finds nothing that
|
||||
// the label does not also justify.
|
||||
type(">ctrlx");
|
||||
expect(rowNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the highlighted command on Enter", () => {
|
||||
open();
|
||||
type(">kill");
|
||||
press("Enter");
|
||||
expect(ran).toEqual(["list.kill"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||
open();
|
||||
type("s");
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
"Commands",
|
||||
]);
|
||||
type(">spawn");
|
||||
expect(rowNames()).toEqual(["Open the spawn form"]);
|
||||
// Backspacing the sigil away is ordinary editing.
|
||||
type("spawn");
|
||||
expect(rowNames()).toContain("Open the spawn form");
|
||||
type("");
|
||||
expect(rowNames()).toEqual(["api"]);
|
||||
});
|
||||
|
||||
it("lists recent dirs then projects under `~`", async () => {
|
||||
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
|
||||
type("~");
|
||||
await flush();
|
||||
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||
const asked: string[] = [];
|
||||
open({
|
||||
loadDirectories: async () => ["/home/me/notes"],
|
||||
listDirEntries: async (dir) => {
|
||||
asked.push(dir);
|
||||
return ["maestro", "api"];
|
||||
},
|
||||
});
|
||||
// The second `~` is a literal query character and reaches the matcher.
|
||||
type("~~/dev/mae");
|
||||
await flush();
|
||||
expect(asked).toEqual(["~/dev/"]);
|
||||
expect(rowNames()).toEqual(["~/dev/maestro"]);
|
||||
});
|
||||
|
||||
it("spawns in the highlighted directory on Enter", async () => {
|
||||
const spawned: string[] = [];
|
||||
open({
|
||||
loadDirectories: async () => ["~/dev/api"],
|
||||
onSpawnDirectory: (path) => spawned.push(path),
|
||||
});
|
||||
type("~api");
|
||||
await flush();
|
||||
press("Enter");
|
||||
expect(spawned).toEqual(["/home/me/dev/api"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands,
|
||||
onOpenSession: () => {},
|
||||
loadDirectories: async () => ["/home/me/dev/api"],
|
||||
listDirEntries: async () => [],
|
||||
});
|
||||
type("~");
|
||||
await flush();
|
||||
press("Enter");
|
||||
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
expect(seen).toEqual(["/home/me/dev/api"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the focus round-trip over a pane (#152)", () => {
|
||||
function terminalTextarea(): HTMLTextAreaElement {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.className = "xterm-helper-textarea";
|
||||
document.body.appendChild(ta);
|
||||
return ta;
|
||||
}
|
||||
|
||||
it("hands the keyboard back to the terminal that had it", () => {
|
||||
const ta = terminalTextarea();
|
||||
ta.focus();
|
||||
openCommandBar({ sessions: [], commands: [] });
|
||||
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
|
||||
press("Escape");
|
||||
expect(document.activeElement).toBe(ta);
|
||||
});
|
||||
|
||||
it("reports no focus change to the daemon, opening or closing", () => {
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
try {
|
||||
const ta = terminalTextarea();
|
||||
ta.focus();
|
||||
openCommandBar({ sessions: [], commands: [] });
|
||||
press("Escape");
|
||||
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
|
||||
String(url).includes("/focus"),
|
||||
);
|
||||
expect(focusCalls).toEqual([]);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page (#151)", () => {
|
||||
const sessions = [
|
||||
session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
last_focused_at: "2026-01-01T11:00:00Z",
|
||||
}),
|
||||
session("worker", {
|
||||
metadata: { name: "worker" },
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
];
|
||||
|
||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||
return openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [],
|
||||
onOpenSession: () => {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function headerText(): string | undefined {
|
||||
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
expect(activeName()).toBe("Kill session");
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
type("spwn");
|
||||
expect(rowNames()).toEqual(["Spawn similar"]);
|
||||
type("zzz");
|
||||
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
|
||||
onOpenSession: () => {},
|
||||
});
|
||||
type(">");
|
||||
const before = rowNames();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(before);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
|
||||
type("~");
|
||||
await Promise.resolve();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["~/dev/api"]);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type("");
|
||||
press("Backspace");
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
press("Backspace");
|
||||
expect(headerText()).toContain("api");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
const seen: string[] = [];
|
||||
for (const [hook, key] of [
|
||||
["onKillSession", "Kill session"],
|
||||
["onRenameSession", "Rename session"],
|
||||
["onSpawnSimilar", "Spawn similar"],
|
||||
] as const) {
|
||||
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type(key);
|
||||
press("Enter");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
"onKillSession:worker",
|
||||
"onRenameSession:worker",
|
||||
"onSpawnSimilar:worker",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
const seen: string[] = [];
|
||||
const listeners: Array<[string, EventListener]> = [
|
||||
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
];
|
||||
for (const [name, fn] of listeners) document.addEventListener(name, fn);
|
||||
try {
|
||||
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
|
||||
open();
|
||||
press("Tab");
|
||||
type(action);
|
||||
press("Enter");
|
||||
}
|
||||
} finally {
|
||||
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
|
||||
}
|
||||
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
|
||||
});
|
||||
|
||||
it("offers no action beyond the three", () => {
|
||||
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||
"kill",
|
||||
"rename",
|
||||
"spawn-similar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,809 +0,0 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list, opened from the sidebar, closed with Escape.
|
||||
//
|
||||
// Three kinds live here: sessions and commands, ranked together by default,
|
||||
// and directories, which need the `~` sigil. `tab` on a session drills into
|
||||
// its action page (#151), which is the same overlay over three actions.
|
||||
|
||||
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
type CandidateKind,
|
||||
type CommandCandidate,
|
||||
type DirectoryCandidate,
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "./matcher.js";
|
||||
import { splitPath } from "./paths.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||
|
||||
/** A matcher candidate that remembers what it came from, so a result can be
|
||||
* acted on without a second lookup. The extra property is invisible to the
|
||||
* matcher, which only ever reads `kind` and `fields`. */
|
||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||
export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
||||
* ranks; `path` is the one a spawn is made in. */
|
||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||
|
||||
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||
* single `label` field, ranked exactly like a registry command — and carries
|
||||
* the action it stands for instead of a registry entry. */
|
||||
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||
|
||||
export type BarCandidate =
|
||||
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
||||
* shell owns the spawn, which is the same fast-forward path `N` takes. */
|
||||
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||
|
||||
/** One kind's slice of the ranked list, in rank order. */
|
||||
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: MatchResult<C>[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
session: "Sessions",
|
||||
command: "Commands",
|
||||
directory: "Directories",
|
||||
};
|
||||
|
||||
// ---- the sigils ----
|
||||
|
||||
export type BarSigil = ">" | "~";
|
||||
|
||||
export interface BarQuery {
|
||||
/** The sigil in force, or null for the mixed list. */
|
||||
sigil: BarSigil | null;
|
||||
/** What the matcher sees: everything after a sigil, or the whole query. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** Split a raw query line into its sigil and the query proper.
|
||||
*
|
||||
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
||||
* literal query character, which is what a directory query with a second `~`
|
||||
* in it depends on. Backspacing past a sigil needs no special case either —
|
||||
* the first character goes and the query is a mixed one again. */
|
||||
export function parseBarQuery(raw: string): BarQuery {
|
||||
const first = raw[0];
|
||||
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
||||
return { sigil: null, query: raw };
|
||||
}
|
||||
|
||||
// ---- the session action page ----
|
||||
|
||||
/** The actions a session's action page offers, and no others: the bar picks
|
||||
* one and hands off to the flow that already implements it
|
||||
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||
|
||||
export interface SessionAction {
|
||||
id: SessionActionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||
{ id: "kill", label: "Kill session" },
|
||||
{ id: "rename", label: "Rename session" },
|
||||
{ id: "spawn-similar", label: "Spawn similar" },
|
||||
];
|
||||
|
||||
/** The action page's candidates, in the order above — which is the order an
|
||||
* empty query lists them in, since the matcher keeps input order. */
|
||||
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||
return SESSION_ACTIONS.map((action) => ({
|
||||
kind: "command",
|
||||
action,
|
||||
fields: { label: action.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||
* `command` kind, so what separates them is what they carry. */
|
||||
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||
return candidate.kind === "command" && "action" in candidate;
|
||||
}
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
const stamp = s.last_focused_at;
|
||||
if (typeof stamp !== "string" || stamp === "") return null;
|
||||
const ms = Date.parse(stamp);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function createdAt(s: Session): number {
|
||||
const ms = Date.parse(s.created_at ?? "");
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
|
||||
* focused first; a session that has never been focused sorts after every
|
||||
* session that has, most recently created first among those; and a dead
|
||||
* session sorts after every live one regardless of recency, because a session
|
||||
* you cannot attach to is not where you just were.
|
||||
*
|
||||
* This is also the input order for a non-empty query, which is what the
|
||||
* matcher's last tie-break preserves. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
const focusB = focusedAt(b);
|
||||
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
|
||||
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
|
||||
|
||||
return createdAt(b) - createdAt(a);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
|
||||
* table names, with the directory abbreviated the way the sidebar shows it. */
|
||||
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
|
||||
const treePath = sessionTreePath(s);
|
||||
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
|
||||
const tool = splitCommand(s.command).tool ?? undefined;
|
||||
return {
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: sessionName(s),
|
||||
...(directory ? { directory } : {}),
|
||||
...(s.command ? { command: s.command } : {}),
|
||||
...(tool ? { tool } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every session as a candidate, in the empty-query order. */
|
||||
export function sessionBarCandidates(
|
||||
sessions: readonly Session[],
|
||||
homeDir?: string,
|
||||
): SessionBarCandidate[] {
|
||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||
}
|
||||
|
||||
/** Drop the commands that would list twice. Several chords can be bound to one
|
||||
* action — the registry already carries its aliases on one entry, but nothing
|
||||
* stops two entries running the same handler, and listed naively that is two
|
||||
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
||||
* displayed, so a command with two of them is still one row. */
|
||||
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
||||
const seenRun = new Set<Command["run"]>();
|
||||
const seenLabel = new Set<string>();
|
||||
const kept: Command[] = [];
|
||||
for (const command of commands) {
|
||||
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
||||
seenRun.add(command.run);
|
||||
seenLabel.add(command.label);
|
||||
kept.push(command);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Every bound command as a candidate, in registry order. Only the label is a
|
||||
* field: the chord is displayed beside the row, never matched, because
|
||||
* matching it would make `>` results depend on a user's rebinds. */
|
||||
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
||||
return dedupeCommands(commands).map((command) => ({
|
||||
kind: "command",
|
||||
command,
|
||||
fields: { label: command.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
||||
* a path the operator typed or `/api/dirs` completed. */
|
||||
export function expandHome(path: string, homeDir?: string): string {
|
||||
if (!homeDir) return path;
|
||||
if (path === "~") return homeDir;
|
||||
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
||||
}
|
||||
|
||||
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
||||
const absolute = expandHome(path, homeDir);
|
||||
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
||||
}
|
||||
|
||||
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
||||
* first mention of each — recent dirs (newest first) ahead of the project
|
||||
* list, which is the empty-query order the contract asks for. */
|
||||
export function directoryBarCandidates(
|
||||
paths: readonly string[],
|
||||
homeDir?: string,
|
||||
): DirectoryBarCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DirectoryBarCandidate[] = [];
|
||||
for (const path of paths) {
|
||||
if (path === "") continue;
|
||||
const candidate = directoryCandidate(path, homeDir);
|
||||
if (seen.has(candidate.path)) continue;
|
||||
seen.add(candidate.path);
|
||||
out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The directory list the bar starts from: what the spawn form already knows —
|
||||
* `GET /api/recent-dirs` and the project list — either alone if the other
|
||||
* fails. */
|
||||
export async function loadBarDirectories(): Promise<string[]> {
|
||||
const [recents, discovered] = await Promise.all([
|
||||
recentDirs()
|
||||
.then((r) => r.dirs.map((d) => d.cwd))
|
||||
.catch(() => [] as string[]),
|
||||
fetchProjects()
|
||||
.then((list) => list.map((p) => p.path))
|
||||
.catch(() => [] as string[]),
|
||||
]);
|
||||
return [...recents, ...discovered];
|
||||
}
|
||||
|
||||
// ---- grouping ----
|
||||
|
||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||
* and groups appear in the order their best-scoring member does — so the first
|
||||
* row under the first header is the globally best result. */
|
||||
export function barGroups<C extends BarCandidate>(
|
||||
results: readonly MatchResult<C>[],
|
||||
): BarGroup<C>[] {
|
||||
const groups: BarGroup<C>[] = [];
|
||||
for (const result of results) {
|
||||
const kind = result.candidate.kind;
|
||||
let group = groups.find((g) => g.kind === kind);
|
||||
if (!group) {
|
||||
group = { kind, title: KIND_TITLES[kind], results: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.results.push(result);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||
* this list. */
|
||||
export function barRows<C extends BarCandidate>(
|
||||
query: string,
|
||||
candidates: readonly C[],
|
||||
): MatchResult<C>[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
/** Text with its matched ranges wrapped in `<mark>`. Ranges come from the
|
||||
* matcher as `[start, end)` character pairs over the field that won. */
|
||||
export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment {
|
||||
const frag = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
if (start > cursor) frag.append(text.slice(cursor, start));
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "cmdbar-hit";
|
||||
mark.textContent = text.slice(start, end);
|
||||
frag.append(mark);
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < text.length) frag.append(text.slice(cursor));
|
||||
return frag;
|
||||
}
|
||||
|
||||
/** The name line of a row: the session's name, the command's label, or the
|
||||
* directory's path. */
|
||||
function rowName(candidate: BarCandidate): string {
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
return candidate.fields.name;
|
||||
case "command":
|
||||
return candidate.fields.label;
|
||||
case "directory":
|
||||
return candidate.fields.path;
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line under a session row: the field the match was found in
|
||||
* when that was not the name, so a session found through its directory says
|
||||
* so. Only sessions have one — a command shows keycaps instead, and a
|
||||
* directory's path is already the name. */
|
||||
function metaText(result: BarResult): string | undefined {
|
||||
if (result.candidate.kind !== "session") return undefined;
|
||||
const fields = result.candidate.fields;
|
||||
switch (result.field) {
|
||||
case "directory":
|
||||
return fields.directory;
|
||||
case "command":
|
||||
return fields.command;
|
||||
case "tool":
|
||||
return fields.tool;
|
||||
default:
|
||||
return fields.directory;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
const candidate = result.candidate;
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmdbar-row";
|
||||
row.setAttribute("role", "option");
|
||||
row.id = `cmdbar-row-${index}`;
|
||||
row.dataset.index = String(index);
|
||||
row.dataset.kind = candidate.kind;
|
||||
if (candidate.kind === "session") {
|
||||
row.dataset.sessionId = candidate.session.id;
|
||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
} else if (isActionCandidate(candidate)) {
|
||||
row.dataset.actionId = candidate.action.id;
|
||||
} else if (candidate.kind === "command") {
|
||||
row.dataset.commandId = candidate.command.id;
|
||||
} else {
|
||||
row.dataset.path = candidate.path;
|
||||
}
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "cmdbar-row-name";
|
||||
const nameText = rowName(candidate);
|
||||
const nameMatched =
|
||||
result.field === "name" || result.field === "label" || result.field === "path";
|
||||
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
|
||||
row.appendChild(name);
|
||||
|
||||
// The chord is teaching material, not a match target: it is rendered from
|
||||
// the live registry entry and never fed to the matcher.
|
||||
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
|
||||
const keys = document.createElement("span");
|
||||
keys.className = "cmdbar-row-keys";
|
||||
for (const cap of keyCaps(candidate.command.keys[0])) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.textContent = cap;
|
||||
keys.appendChild(kbd);
|
||||
}
|
||||
row.appendChild(keys);
|
||||
return row;
|
||||
}
|
||||
|
||||
const meta = metaText(result);
|
||||
if (meta) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "cmdbar-row-meta";
|
||||
const highlighted = result.field !== undefined && result.field !== "name";
|
||||
el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta);
|
||||
row.appendChild(el);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||
mixed: "Go to a session…",
|
||||
">": "Run a command…",
|
||||
"~": "Spawn in a directory…",
|
||||
};
|
||||
|
||||
// ---- the overlay ----
|
||||
|
||||
export interface CommandBarOptions {
|
||||
/** Sessions to build candidates from, as the store has them right now. */
|
||||
sessions: readonly Session[];
|
||||
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
||||
homeDir?: string;
|
||||
/** Open the highlighted session. Defaults to the shared open path — the
|
||||
* sidebar's click event — which is what the contract requires; tests pass
|
||||
* their own. */
|
||||
onOpenSession?: (sessionId: string) => void;
|
||||
/** The command registry to list under `>`. Defaults to every bound command,
|
||||
* in registry order. */
|
||||
commands?: readonly Command[];
|
||||
/** The directory list behind `~`, fetched once on first use. */
|
||||
loadDirectories?: () => Promise<string[]>;
|
||||
/** One directory's subdirectories, for filling the list in as a path is
|
||||
* typed. Defaults to `GET /api/dirs`. */
|
||||
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||
/** Spawn a session in the highlighted directory. Defaults to raising
|
||||
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
||||
* spawn. */
|
||||
onSpawnDirectory?: (path: string) => void;
|
||||
/** The action page's three actions (#151). Each defaults to raising the
|
||||
* event the flow that already implements it listens on — the sidebar's
|
||||
* confirm-and-blockers kill, its inline rename prompt, the spawn form
|
||||
* seeded from the session. The bar picks the action and nothing more. */
|
||||
onKillSession?: (session: Session) => void;
|
||||
onRenameSession?: (session: Session) => void;
|
||||
onSpawnSimilar?: (session: Session) => void;
|
||||
}
|
||||
|
||||
interface OpenBar {
|
||||
el: HTMLElement;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
let current: OpenBar | null = null;
|
||||
|
||||
export function isCommandBarOpen(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
export function closeCommandBar(): void {
|
||||
current?.dispose();
|
||||
}
|
||||
|
||||
/** Mount the bar. Opening while one is up replaces it, so a stray second call
|
||||
* can never stack two overlays. */
|
||||
export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
closeCommandBar();
|
||||
|
||||
// Closing returns the keyboard to whatever held it when the bar opened: a
|
||||
// round trip through the bar never silently moves focus.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const openSession =
|
||||
opts.onOpenSession ??
|
||||
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
||||
const spawnDirectory =
|
||||
opts.onSpawnDirectory ??
|
||||
((path: string) =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
||||
const sessionEvent =
|
||||
(name: string) =>
|
||||
(session: Session): void => {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: session }));
|
||||
};
|
||||
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
|
||||
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
|
||||
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
|
||||
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
||||
const listEntries =
|
||||
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
||||
|
||||
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
|
||||
opts.sessions,
|
||||
opts.homeDir,
|
||||
);
|
||||
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
|
||||
opts.commands ?? publishedCommands(),
|
||||
);
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "cmdbar-overlay";
|
||||
el.setAttribute("role", "dialog");
|
||||
el.setAttribute("aria-modal", "true");
|
||||
el.setAttribute("aria-label", "Command bar");
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "cmdbar-panel";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "cmdbar-input";
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
input.setAttribute("aria-controls", "cmdbar-results");
|
||||
input.setAttribute("aria-label", "Search sessions, commands and directories");
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.spellcheck = false;
|
||||
input.placeholder = PLACEHOLDERS.mixed;
|
||||
|
||||
const results = document.createElement("div");
|
||||
results.className = "cmdbar-results";
|
||||
results.id = "cmdbar-results";
|
||||
results.setAttribute("role", "listbox");
|
||||
|
||||
panel.append(input, results);
|
||||
el.appendChild(panel);
|
||||
document.body.appendChild(el);
|
||||
|
||||
let rows: BarResult[] = [];
|
||||
let highlighted = 0;
|
||||
|
||||
// The action page (#151). `page` is the session being acted on, `stashed`
|
||||
// the result-list query it was drilled into from — backing out restores it,
|
||||
// so the round trip costs the operator nothing.
|
||||
let page: Session | null = null;
|
||||
let stashed = "";
|
||||
let header: HTMLElement | null = null;
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
let directories: DirectoryBarCandidate[] = [];
|
||||
let directoriesRequested = false;
|
||||
let probed: DirectoryBarCandidate[] = [];
|
||||
let probedDir: string | null = null;
|
||||
const listings = new Map<string, string[]>();
|
||||
|
||||
function alive(): boolean {
|
||||
return current?.el === el;
|
||||
}
|
||||
|
||||
/** Fetch the recents-and-projects list the first time `~` is used. */
|
||||
function ensureDirectories(): void {
|
||||
if (directoriesRequested) return;
|
||||
directoriesRequested = true;
|
||||
loadDirectories()
|
||||
.then((paths) => {
|
||||
directories = directoryBarCandidates(paths, opts.homeDir);
|
||||
if (alive()) render();
|
||||
})
|
||||
.catch(() => {
|
||||
/* no directories is a shorter list, not an error state */
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
|
||||
* the directory it names is listed and its children join the candidates, so
|
||||
* `~` reaches a directory that was never spawned in. */
|
||||
function probeDirectories(query: string): void {
|
||||
const { dir } = splitPath(query);
|
||||
if (dir === "") {
|
||||
probed = [];
|
||||
probedDir = null;
|
||||
return;
|
||||
}
|
||||
if (dir === probedDir) return;
|
||||
probedDir = dir;
|
||||
const cached = listings.get(dir);
|
||||
if (cached) {
|
||||
probed = directoryBarCandidates(
|
||||
cached.map((entry) => `${dir}${entry}`),
|
||||
opts.homeDir,
|
||||
);
|
||||
return;
|
||||
}
|
||||
probed = [];
|
||||
listEntries(dir)
|
||||
.then((entries) => {
|
||||
listings.set(dir, entries);
|
||||
if (!alive() || probedDir !== dir) return;
|
||||
probed = directoryBarCandidates(
|
||||
entries.map((entry) => `${dir}${entry}`),
|
||||
opts.homeDir,
|
||||
);
|
||||
render();
|
||||
})
|
||||
.catch(() => {
|
||||
/* an unreadable directory contributes nothing */
|
||||
});
|
||||
}
|
||||
|
||||
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
||||
if (page) return sessionActionCandidates();
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
case "~": {
|
||||
ensureDirectories();
|
||||
probeDirectories(parsed.query);
|
||||
const seen = new Set<string>();
|
||||
return [...directories, ...probed].filter((c) =>
|
||||
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return parsed.query === ""
|
||||
? [...sessionCandidates]
|
||||
: [...sessionCandidates, ...commandCandidates];
|
||||
}
|
||||
}
|
||||
|
||||
function paintHighlight(): void {
|
||||
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
||||
const active = Number(row.dataset.index) === highlighted;
|
||||
row.classList.toggle("is-active", active);
|
||||
row.setAttribute("aria-selected", active ? "true" : "false");
|
||||
if (active) {
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
input.setAttribute("aria-activedescendant", row.id);
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
/** The line naming the session being acted on, so the target is never
|
||||
* ambiguous. Present only while the action page is up. */
|
||||
function paintHeader(): void {
|
||||
header?.remove();
|
||||
header = null;
|
||||
if (!page) return;
|
||||
header = document.createElement("div");
|
||||
header.className = "cmdbar-page-header";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = `Actions for "${sessionName(page)}"`;
|
||||
panel.insertBefore(header, results);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
// On the action page the query line is over the three actions, so there is
|
||||
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
|
||||
input.placeholder = page
|
||||
? `Act on ${sessionName(page)}…`
|
||||
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||
paintHeader();
|
||||
rows = barRows(parsed.query, candidatesFor(parsed));
|
||||
// The highlight starts on the globally best row every time the list
|
||||
// changes: an old index would point at a different result.
|
||||
highlighted = 0;
|
||||
results.replaceChildren();
|
||||
|
||||
if (rows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "cmdbar-empty";
|
||||
empty.textContent = "No matches";
|
||||
results.appendChild(empty);
|
||||
paintHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
for (const group of barGroups(rows)) {
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped — and the action page is one kind too.
|
||||
if (parsed.sigil === null && !page) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = group.title;
|
||||
results.appendChild(header);
|
||||
}
|
||||
for (const result of group.results) {
|
||||
const at = index;
|
||||
const row = renderRow(result, at);
|
||||
row.addEventListener("click", () => activate(at));
|
||||
results.appendChild(row);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
/** The highlight does not wrap: it stops at the first and last row. */
|
||||
function move(delta: number): void {
|
||||
if (rows.length === 0) return;
|
||||
highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta));
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||
* other kind, and none on the page itself, which has no session rows. */
|
||||
function drillIn(): void {
|
||||
const candidate = rows[highlighted]?.candidate;
|
||||
if (page || candidate?.kind !== "session") return;
|
||||
page = candidate.session;
|
||||
stashed = input.value;
|
||||
input.value = "";
|
||||
render();
|
||||
}
|
||||
|
||||
/** Escape, or backspace at an empty query: back to the result list with the
|
||||
* query the drill-in was made from. */
|
||||
function leavePage(): void {
|
||||
page = null;
|
||||
input.value = stashed;
|
||||
stashed = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function runAction(action: SessionAction, session: Session): void {
|
||||
close();
|
||||
switch (action.id) {
|
||||
case "kill":
|
||||
killSession(session);
|
||||
return;
|
||||
case "rename":
|
||||
renameSession(session);
|
||||
return;
|
||||
case "spawn-similar":
|
||||
spawnSimilar(session);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function activate(index: number): void {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const candidate = result.candidate;
|
||||
if (isActionCandidate(candidate)) {
|
||||
// The page cannot be up without a session to act on.
|
||||
if (page) runAction(candidate.action, page);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
openSession(candidate.session.id);
|
||||
return;
|
||||
case "command":
|
||||
candidate.command.run();
|
||||
return;
|
||||
case "directory":
|
||||
spawnDirectory(candidate.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
// On an action page Escape is one level up, not the way out.
|
||||
if (page) leavePage();
|
||||
else close();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
drillIn();
|
||||
return;
|
||||
case "Backspace":
|
||||
// Ordinary editing, except at an empty query on the action page.
|
||||
if (page && input.value === "") {
|
||||
e.preventDefault();
|
||||
leavePage();
|
||||
}
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activate(highlighted);
|
||||
return;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
return;
|
||||
case "n":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
}
|
||||
return;
|
||||
case "p":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (current?.el !== el) return;
|
||||
current = null;
|
||||
el.remove();
|
||||
// Restoring focus is what makes the bar a view over the app rather than a
|
||||
// place in it; a detached node cannot take it.
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
}
|
||||
|
||||
input.addEventListener("keydown", onKeydown);
|
||||
input.addEventListener("input", render);
|
||||
// A press on the scrim closes; one inside the panel is left alone.
|
||||
el.addEventListener("mousedown", (ev) => {
|
||||
if (!panel.contains(ev.target as Node)) close();
|
||||
});
|
||||
|
||||
current = { el, dispose: close };
|
||||
render();
|
||||
input.focus();
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// A session's action page (docs/COMMAND-BAR.md, "Drill-in"): the same overlay
|
||||
// over kill, rename and spawn similar, still fuzzy searchable. The page names
|
||||
// the session it acts on, so the target is never ambiguous.
|
||||
|
||||
import { sessionName, type Session } from "../../types.js";
|
||||
import { ResultList } from "./ResultList.js";
|
||||
import type { BarGroup } from "./model.js";
|
||||
|
||||
export interface ActionPageProps {
|
||||
session: Session;
|
||||
/** The three actions, ranked by the query line — one kind, so no headers. */
|
||||
groups: BarGroup[];
|
||||
highlighted: number;
|
||||
onActivate(index: number): void;
|
||||
}
|
||||
|
||||
export function ActionPage({ session, groups, highlighted, onActivate }: ActionPageProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="cmdbar-page-header border-b border-border px-sp-3 py-sp-1 text-fs-11 text-muted"
|
||||
>
|
||||
{`Actions for "${sessionName(session)}"`}
|
||||
</div>
|
||||
<ResultList
|
||||
groups={groups}
|
||||
showHeaders={false}
|
||||
highlighted={highlighted}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "../../uiEvents.js";
|
||||
import { COMMAND_SPECS, type Command } from "../../commands.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||
|
||||
vi.mock("../../api.js", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listDir: vi.fn(async () => ({ path: "", entries: [] })),
|
||||
projects: vi.fn(async () => []),
|
||||
recentDirs: vi.fn(async () => ({ dirs: [] })),
|
||||
}));
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function command(over: Partial<Command> = {}): Command {
|
||||
return {
|
||||
id: "cmd",
|
||||
label: "A command",
|
||||
group: "Test",
|
||||
keys: ["a"],
|
||||
scope: "global",
|
||||
run: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }),
|
||||
session("worker", {
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
session("notes", { current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
function open(over: Partial<CommandBarProps> = {}) {
|
||||
return render(
|
||||
<CommandBar
|
||||
sessions={sessions}
|
||||
homeDir="/home/me"
|
||||
commands={[]}
|
||||
onOpenSession={() => {}}
|
||||
onClose={() => {}}
|
||||
{...over}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return screen.getByRole("combobox") as HTMLInputElement;
|
||||
}
|
||||
|
||||
function type(value: string): void {
|
||||
fireEvent.change(input(), { target: { value } });
|
||||
}
|
||||
|
||||
function press(key: string, init: Record<string, unknown> = {}): void {
|
||||
fireEvent.keyDown(input(), { key, ...init });
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
function activeName(): string | undefined {
|
||||
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("the overlay", () => {
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
||||
expect(spec?.keys).toEqual(["ctrl-k"]);
|
||||
expect(spec?.scope).toBe("global");
|
||||
});
|
||||
|
||||
it("lists sessions by recency under a kind header on an empty query", () => {
|
||||
open();
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
]);
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
expect(activeName()).toBe("api");
|
||||
});
|
||||
|
||||
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
||||
open();
|
||||
type("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual(
|
||||
["wor"],
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights inside the directory when that is the field that matched", () => {
|
||||
open();
|
||||
type("dev/wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect(
|
||||
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
||||
).toBe("dev/wor");
|
||||
});
|
||||
|
||||
it("renders a no-results state rather than an empty list", () => {
|
||||
open();
|
||||
type("zzz");
|
||||
expect(rowNames()).toEqual([]);
|
||||
expect(screen.getByText("No matches")).toBeDefined();
|
||||
});
|
||||
|
||||
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
||||
open();
|
||||
press("ArrowUp");
|
||||
expect(activeName()).toBe("api");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
press("n", { ctrlKey: true });
|
||||
expect(activeName()).toBe("notes");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("notes");
|
||||
press("p", { ctrlKey: true });
|
||||
expect(activeName()).toBe("worker");
|
||||
});
|
||||
|
||||
it("puts the highlight back on the best row whenever the query changes", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
type("o");
|
||||
expect(activeName()).toBe(rowNames()[0]);
|
||||
});
|
||||
|
||||
it("opens the highlighted session through the caller's open path on Enter", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
open({ onOpenSession, onClose });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises the shared select event when the caller supplies no handler", () => {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
open({ onOpenSession: undefined });
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api");
|
||||
});
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
open({ onOpenSession, onClose });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes on Escape and on a press outside the panel", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stays open for a press inside the panel", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the keyboard to whatever held it when it opened", () => {
|
||||
const before = document.createElement("input");
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
const view = open();
|
||||
expect(document.activeElement).toBe(input());
|
||||
view.unmount();
|
||||
expect(document.activeElement).toBe(before);
|
||||
before.remove();
|
||||
});
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ onOpenSession });
|
||||
fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]);
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sigils", () => {
|
||||
const commands = [
|
||||
command({ id: "help.toggle", label: "Show help", keys: ["?"] }),
|
||||
command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }),
|
||||
];
|
||||
|
||||
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||
open({ commands });
|
||||
type(">");
|
||||
expect(rowNames()).toEqual(["Show help", "Open the spawn form"]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual(
|
||||
["?", "n"],
|
||||
);
|
||||
});
|
||||
|
||||
it("matches a command on its label and never on its chord", () => {
|
||||
open({ commands });
|
||||
type(">help");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
type(">?");
|
||||
expect(rowNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the highlighted command on Enter", () => {
|
||||
const run = vi.fn();
|
||||
open({ commands: [command({ label: "Run me", run })] });
|
||||
type(">run");
|
||||
press("Enter");
|
||||
expect(run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||
open({ commands });
|
||||
type(">show");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
type("");
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
});
|
||||
|
||||
it("lists recent dirs then projects under `~`", async () => {
|
||||
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"]));
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||
const listDirEntries = vi.fn(async () => ["maestro", "api"]);
|
||||
open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries });
|
||||
// The second `~` is a literal query character and reaches the matcher.
|
||||
type("~~/dev/mae");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
|
||||
expect(listDirEntries).toHaveBeenCalledWith("~/dev/");
|
||||
});
|
||||
|
||||
it("spawns in the highlighted directory on Enter", async () => {
|
||||
const onSpawnDirectory = vi.fn();
|
||||
open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Enter");
|
||||
expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x");
|
||||
});
|
||||
|
||||
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
open({ loadDirectories: async () => ["/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Enter");
|
||||
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page", () => {
|
||||
function drill(over: Partial<CommandBarProps> = {}) {
|
||||
const view = open(over);
|
||||
press("Tab");
|
||||
return view;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
drill();
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
drill();
|
||||
expect(screen.getByText('Actions for "api"')).toBeDefined();
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
drill();
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
drill();
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
open({ commands: [command({ label: "Show help" })] });
|
||||
type(">show");
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: async () => ["/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["/srv/x"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
press("Escape");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(input().value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
drill();
|
||||
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
drill();
|
||||
type("ren");
|
||||
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
const onClose = vi.fn();
|
||||
drill({ onClose });
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
for (const [query, key] of [
|
||||
["kill", "onKillSession"],
|
||||
["rename", "onRenameSession"],
|
||||
["spawn", "onSpawnSimilar"],
|
||||
] as const) {
|
||||
const handler = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
drill({ [key]: handler, onClose });
|
||||
type(query);
|
||||
press("Enter");
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
for (const [query, event] of [
|
||||
["kill", KILL_SESSION_EVENT],
|
||||
["rename", RENAME_SESSION_EVENT],
|
||||
["spawn", SPAWN_SIMILAR_EVENT],
|
||||
] as const) {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(event, listener);
|
||||
drill();
|
||||
type(query);
|
||||
press("Enter");
|
||||
document.removeEventListener(event, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api");
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list. Sessions and commands rank together by default; `>` narrows to
|
||||
// commands, `~` to directories. `tab` on a session drills into its action page.
|
||||
//
|
||||
// The bar picks an action and hands off — it never reimplements one. Each
|
||||
// handler defaults to the event the flow that already implements it listens
|
||||
// on, so the React bar and the legacy shell raise the same flows.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "../../uiEvents.js";
|
||||
import { publishedCommands, type Command } from "../../commands.js";
|
||||
import { sessionName, type Session } from "../../types.js";
|
||||
import { ActionPage } from "./ActionPage.js";
|
||||
import { ResultList } from "./ResultList.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
commandBarCandidates,
|
||||
dirEntryPaths,
|
||||
directoryBarCandidates,
|
||||
isActionCandidate,
|
||||
listDirEntries as defaultListDirEntries,
|
||||
loadBarDirectories,
|
||||
parseBarQuery,
|
||||
probeDir,
|
||||
sessionActionCandidates,
|
||||
sessionBarCandidates,
|
||||
PLACEHOLDERS,
|
||||
type BarCandidate,
|
||||
type BarResult,
|
||||
type DirectoryBarCandidate,
|
||||
type SessionAction,
|
||||
} from "./model.js";
|
||||
|
||||
/** Raised when Enter lands on a directory row and no handler was given. The
|
||||
* bar picks the directory; the shell owns the spawn, which is the same
|
||||
* fast-forward path `N` takes. */
|
||||
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||
|
||||
export interface CommandBarProps {
|
||||
/** Sessions to build candidates from, as the store has them right now. */
|
||||
sessions: readonly Session[];
|
||||
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
||||
homeDir?: string;
|
||||
/** The command registry to list under `>`. Defaults to every bound command,
|
||||
* in registry order. */
|
||||
commands?: readonly Command[];
|
||||
/** The directory list behind `~`, fetched once on first use. */
|
||||
loadDirectories?: () => Promise<string[]>;
|
||||
/** One directory's subdirectories, for filling the list in as a path is
|
||||
* typed. Defaults to `GET /api/dirs`. */
|
||||
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||
onOpenSession?(sessionId: string): void;
|
||||
onSpawnDirectory?(path: string): void;
|
||||
onKillSession?(session: Session): void;
|
||||
onRenameSession?(session: Session): void;
|
||||
onSpawnSimilar?(session: Session): void;
|
||||
/** Closing returns the keyboard to whatever held it when the bar opened. */
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
function raise(name: string, detail: unknown): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
}
|
||||
|
||||
export function CommandBar(props: CommandBarProps) {
|
||||
const {
|
||||
sessions,
|
||||
homeDir,
|
||||
commands,
|
||||
loadDirectories = loadBarDirectories,
|
||||
listDirEntries = defaultListDirEntries,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [raw, setRaw] = useState("");
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
// The action page: `page` is the session being acted on, `stashed` the
|
||||
// result-list query it was drilled into from — backing out restores it, so
|
||||
// the round trip costs the operator nothing.
|
||||
const [page, setPage] = useState<Session | null>(null);
|
||||
const [stashed, setStashed] = useState("");
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
const [directories, setDirectories] = useState<DirectoryBarCandidate[]>([]);
|
||||
const [probed, setProbed] = useState<DirectoryBarCandidate[]>([]);
|
||||
const requestedRef = useRef(false);
|
||||
const listingsRef = useRef(new Map<string, string[]>());
|
||||
|
||||
// On the action page the query line is over the three actions, so there is
|
||||
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||
const parsed = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]);
|
||||
|
||||
const sessionCandidates = useMemo(
|
||||
() => sessionBarCandidates(sessions, homeDir),
|
||||
[sessions, homeDir],
|
||||
);
|
||||
const commandCandidates = useMemo(
|
||||
() => commandBarCandidates(commands ?? publishedCommands()),
|
||||
[commands],
|
||||
);
|
||||
|
||||
// Closing returns the keyboard to whatever held it when the bar opened: a
|
||||
// round trip through the bar never silently moves focus.
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
inputRef.current?.focus();
|
||||
return () => {
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The recents-and-projects list, the first time `~` is used. No directories
|
||||
// is a shorter list, not an error state.
|
||||
useEffect(() => {
|
||||
if (parsed.sigil !== "~" || requestedRef.current) return;
|
||||
requestedRef.current = true;
|
||||
let live = true;
|
||||
loadDirectories()
|
||||
.then((paths) => {
|
||||
if (live) setDirectories(directoryBarCandidates(paths, homeDir));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [parsed.sigil, loadDirectories, homeDir]);
|
||||
|
||||
// Fill the list in from `GET /api/dirs` once the query looks like a path: the
|
||||
// directory it names is listed and its children join the candidates, so `~`
|
||||
// reaches a directory that was never spawned in.
|
||||
const dir = parsed.sigil === "~" ? probeDir(parsed.query) : "";
|
||||
useEffect(() => {
|
||||
if (dir === "") {
|
||||
setProbed([]);
|
||||
return;
|
||||
}
|
||||
const cached = listingsRef.current.get(dir);
|
||||
if (cached) {
|
||||
setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir));
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
setProbed([]);
|
||||
listDirEntries(dir)
|
||||
.then((entries) => {
|
||||
listingsRef.current.set(dir, entries);
|
||||
if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir));
|
||||
})
|
||||
.catch(() => {
|
||||
/* an unreadable directory contributes nothing */
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [dir, listDirEntries, homeDir]);
|
||||
|
||||
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
const candidates: BarCandidate[] = useMemo(() => {
|
||||
if (page) return sessionActionCandidates();
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
case "~": {
|
||||
const seen = new Set<string>();
|
||||
return [...directories, ...probed].filter((c) =>
|
||||
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return parsed.query === ""
|
||||
? [...sessionCandidates]
|
||||
: [...sessionCandidates, ...commandCandidates];
|
||||
}
|
||||
}, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => barGroups(barRows(parsed.query, candidates)),
|
||||
[parsed.query, candidates],
|
||||
);
|
||||
// Grouping is a rendering of the ranking, so the flattened groups are the
|
||||
// rows in the order they appear — which is the order the highlight walks and
|
||||
// Enter acts on.
|
||||
const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
// The highlight starts on the globally best row every time the list changes:
|
||||
// an old index would point at a different result.
|
||||
useEffect(() => {
|
||||
setHighlighted(0);
|
||||
}, [rows]);
|
||||
|
||||
/** The highlight does not wrap: it stops at the first and last row. */
|
||||
const move = useCallback(
|
||||
(delta: number) => {
|
||||
setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta)));
|
||||
},
|
||||
[rows.length],
|
||||
);
|
||||
|
||||
const runAction = useCallback(
|
||||
(action: SessionAction, session: Session) => {
|
||||
onClose();
|
||||
switch (action.id) {
|
||||
case "kill":
|
||||
(props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session);
|
||||
return;
|
||||
case "rename":
|
||||
(props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session);
|
||||
return;
|
||||
case "spawn-similar":
|
||||
(props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar],
|
||||
);
|
||||
|
||||
const activate = useCallback(
|
||||
(index: number) => {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const candidate = result.candidate;
|
||||
if (isActionCandidate(candidate)) {
|
||||
// The page cannot be up without a session to act on.
|
||||
if (page) runAction(candidate.action, page);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
(props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))(
|
||||
candidate.session.id,
|
||||
);
|
||||
return;
|
||||
case "command":
|
||||
candidate.command.run();
|
||||
return;
|
||||
case "directory":
|
||||
(props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))(
|
||||
candidate.path,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory],
|
||||
);
|
||||
|
||||
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||
* other kind, and none on the page itself, which has no session rows. */
|
||||
const drillIn = useCallback(() => {
|
||||
const candidate = rows[highlighted]?.candidate;
|
||||
if (page || candidate?.kind !== "session") return;
|
||||
setPage(candidate.session);
|
||||
setStashed(raw);
|
||||
setRaw("");
|
||||
}, [rows, highlighted, page, raw]);
|
||||
|
||||
/** Escape, or backspace at an empty query: back to the result list with the
|
||||
* query the drill-in was made from. */
|
||||
const leavePage = useCallback(() => {
|
||||
setPage(null);
|
||||
setRaw(stashed);
|
||||
setStashed("");
|
||||
}, [stashed]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
// On an action page Escape is one level up, not the way out.
|
||||
if (page) leavePage();
|
||||
else onClose();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
drillIn();
|
||||
return;
|
||||
case "Backspace":
|
||||
// Ordinary editing, except at an empty query on the action page.
|
||||
if (page && raw === "") {
|
||||
e.preventDefault();
|
||||
leavePage();
|
||||
}
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activate(highlighted);
|
||||
return;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
return;
|
||||
case "n":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
}
|
||||
return;
|
||||
case "p":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="cmdbar-overlay fixed inset-0 z-modal flex items-start justify-center bg-bg/70 pt-[12vh] backdrop-blur-sm max-[600px]:items-end max-[600px]:pt-0"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command bar"
|
||||
onMouseDown={(e) => {
|
||||
// A press on the scrim closes; one inside the panel is left alone.
|
||||
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="cmdbar-panel flex w-full max-w-[560px] flex-col overflow-hidden rounded-r-3 border border-border bg-surface shadow-2 max-[600px]:max-w-none max-[600px]:rounded-b-none"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="cmdbar-results"
|
||||
aria-label="Search sessions, commands and directories"
|
||||
aria-activedescendant={activeRowId}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="cmdbar-input w-full border-b border-border bg-transparent px-sp-3 py-sp-2 text-fs-15 text-ink outline-none"
|
||||
placeholder={page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]}
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{page ? (
|
||||
<ActionPage
|
||||
session={page}
|
||||
groups={groups}
|
||||
highlighted={highlighted}
|
||||
onActivate={activate}
|
||||
/>
|
||||
) : (
|
||||
<ResultList
|
||||
groups={groups}
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped.
|
||||
showHeaders={parsed.sigil === null}
|
||||
highlighted={highlighted}
|
||||
onActivate={activate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// The bar's result list: kind headers over rank-ordered rows
|
||||
// (docs/COMMAND-BAR.md, "Structure"). Headers are labels, never rows — the
|
||||
// highlight walks the rows alone, in the order they are rendered.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { keyCaps } from "../../commands.js";
|
||||
import {
|
||||
highlightSegments,
|
||||
isActionCandidate,
|
||||
metaText,
|
||||
rowName,
|
||||
type BarGroup,
|
||||
type BarResult,
|
||||
} from "./model.js";
|
||||
|
||||
export interface ResultListProps {
|
||||
groups: BarGroup[];
|
||||
/** Dropped while a sigil is in force or on the action page: one kind needs
|
||||
* no header. */
|
||||
showHeaders: boolean;
|
||||
/** Index into the flattened rows, in the order they render. */
|
||||
highlighted: number;
|
||||
onActivate(index: number): void;
|
||||
}
|
||||
|
||||
function Highlighted({ text, ranges }: { text: string; ranges: BarResult["ranges"] }) {
|
||||
return (
|
||||
<>
|
||||
{highlightSegments(text, ranges).map((segment, i) =>
|
||||
segment.hit ? (
|
||||
<mark key={i} className="cmdbar-hit bg-transparent font-semibold text-accent">
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{segment.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
result,
|
||||
index,
|
||||
active,
|
||||
onActivate,
|
||||
}: {
|
||||
result: BarResult;
|
||||
index: number;
|
||||
active: boolean;
|
||||
onActivate(index: number): void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const candidate = result.candidate;
|
||||
|
||||
useEffect(() => {
|
||||
if (active) ref.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const name = rowName(candidate);
|
||||
const nameMatched =
|
||||
result.field === "name" || result.field === "label" || result.field === "path";
|
||||
const meta = metaText(result);
|
||||
const dead = candidate.kind === "session" && candidate.session.status === "Dead";
|
||||
const chord =
|
||||
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||
? candidate.command.keys[0]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="option"
|
||||
id={`cmdbar-row-${index}`}
|
||||
aria-selected={active}
|
||||
data-index={index}
|
||||
data-kind={candidate.kind}
|
||||
data-session-id={candidate.kind === "session" ? candidate.session.id : undefined}
|
||||
data-action-id={isActionCandidate(candidate) ? candidate.action.id : undefined}
|
||||
data-command-id={
|
||||
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||
? candidate.command.id
|
||||
: undefined
|
||||
}
|
||||
data-path={candidate.kind === "directory" ? candidate.path : undefined}
|
||||
className={[
|
||||
"cmdbar-row flex cursor-pointer items-baseline gap-sp-2 rounded-r-1 px-sp-3 py-sp-1 text-fs-13",
|
||||
active ? "is-active bg-surface-2 text-ink" : "text-ink",
|
||||
dead ? "is-dead opacity-60" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => onActivate(index)}
|
||||
>
|
||||
<span className="cmdbar-row-name truncate">
|
||||
{nameMatched ? <Highlighted text={name} ranges={result.ranges} /> : name}
|
||||
</span>
|
||||
{chord !== undefined ? (
|
||||
<span className="cmdbar-row-keys ml-auto flex gap-1">
|
||||
{keyCaps(chord).map((cap, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="rounded-r-1 border border-border px-1 text-fs-11 text-muted"
|
||||
>
|
||||
{cap}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
) : meta ? (
|
||||
<span className="cmdbar-row-meta ml-auto truncate text-fs-11 text-muted">
|
||||
{result.field !== undefined && result.field !== "name" ? (
|
||||
<Highlighted text={meta} ranges={result.ranges} />
|
||||
) : (
|
||||
meta
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultList({ groups, showHeaders, highlighted, onActivate }: ResultListProps) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div
|
||||
id="cmdbar-results"
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||
>
|
||||
<div className="cmdbar-empty px-sp-3 py-sp-2 text-fs-12 text-muted">No matches</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
return (
|
||||
<div
|
||||
id="cmdbar-results"
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<div key={group.kind}>
|
||||
{showHeaders ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="cmdbar-group-title px-sp-3 pt-sp-2 pb-1 text-fs-11 uppercase tracking-wide text-muted"
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
) : null}
|
||||
{group.results.map((result) => {
|
||||
const at = index++;
|
||||
return (
|
||||
<Row
|
||||
key={at}
|
||||
result={result}
|
||||
index={at}
|
||||
active={at === highlighted}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||
export { ResultList, type ResultListProps } from "./ResultList.js";
|
||||
export { ActionPage, type ActionPageProps } from "./ActionPage.js";
|
||||
export { useCommandBar, type CommandBarControls, type UseCommandBarOptions } from "./useCommandBar.js";
|
||||
export * from "./model.js";
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Command } from "../../commands.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
commandBarCandidates,
|
||||
dedupeCommands,
|
||||
directoryBarCandidates,
|
||||
directoryCandidate,
|
||||
highlightSegments,
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionActionCandidates,
|
||||
sessionBarCandidates,
|
||||
sessionCandidate,
|
||||
} from "./model.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function command(over: Partial<Command> = {}): Command {
|
||||
return {
|
||||
id: "a",
|
||||
label: "A command",
|
||||
group: "Test",
|
||||
keys: ["a"],
|
||||
scope: "global",
|
||||
run: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("empty-query ordering", () => {
|
||||
it("sorts most recently focused first", () => {
|
||||
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("sorts a never-focused session after every focused one, newest created first", () => {
|
||||
const focused = session("focused", { last_focused_at: "2026-01-01T09:00:00Z" });
|
||||
const old = session("old", { created_at: "2026-01-01T00:00:00Z" });
|
||||
const fresh = session("fresh", { created_at: "2026-01-02T00:00:00Z" });
|
||||
expect(orderSessionsForBar([old, fresh, focused]).map((s) => s.id)).toEqual([
|
||||
"focused",
|
||||
"fresh",
|
||||
"old",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts dead sessions after live ones regardless of recency", () => {
|
||||
const dead = session("dead", { status: "Dead", last_focused_at: "2026-01-01T12:00:00Z" });
|
||||
const live = session("live", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
expect(orderSessionsForBar([dead, live]).map((s) => s.id)).toEqual(["live", "dead"]);
|
||||
});
|
||||
|
||||
it("treats an empty last_focused_at as never focused", () => {
|
||||
const blank = session("blank", { last_focused_at: "" });
|
||||
const focused = session("focused", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
expect(orderSessionsForBar([blank, focused]).map((s) => s.id)).toEqual(["focused", "blank"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session candidates", () => {
|
||||
it("carries the candidate table's fields, with the directory abbreviated", () => {
|
||||
const s = session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
command: "claude --resume",
|
||||
});
|
||||
expect(sessionCandidate(s, "/home/me").fields).toEqual({
|
||||
name: "api",
|
||||
directory: "~/dev/api",
|
||||
command: "claude --resume",
|
||||
tool: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
|
||||
const s = session("edit", {
|
||||
metadata: {},
|
||||
command: "hx src/",
|
||||
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
|
||||
});
|
||||
const candidate = sessionCandidate(s, "/home/me");
|
||||
expect(candidate.fields.tool).toBeUndefined();
|
||||
expect(candidate.fields.directory).toBe("~/dev/api");
|
||||
// No `metadata.name`: the sidebar's fallback is the command line.
|
||||
expect(candidate.fields.name).toBe("hx src/");
|
||||
});
|
||||
|
||||
it("builds candidates in the empty-query order", () => {
|
||||
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rows and grouping", () => {
|
||||
it("an empty query keeps every candidate in input order", () => {
|
||||
const candidates = sessionBarCandidates([session("a"), session("b")]);
|
||||
expect(barRows("", candidates).map((r) => r.score)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("groups by kind without reordering the ranking", () => {
|
||||
const rows = barRows("kill", [
|
||||
...sessionBarCandidates([session("killer-app", { metadata: { name: "killer-app" } })]),
|
||||
...commandBarCandidates([command({ label: "Kill selected session" })]),
|
||||
]);
|
||||
const groups = barGroups(rows);
|
||||
expect(groups.map((g) => g.title)).toEqual(["Sessions", "Commands"]);
|
||||
expect(groups.flatMap((g) => g.results)).toEqual(rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsing the query line", () => {
|
||||
it("reads a sigil only in first position", () => {
|
||||
expect(parseBarQuery(">help")).toEqual({ sigil: ">", query: "help" });
|
||||
expect(parseBarQuery("~dev")).toEqual({ sigil: "~", query: "dev" });
|
||||
});
|
||||
|
||||
it("leaves a sigil character elsewhere in the query literal", () => {
|
||||
expect(parseBarQuery("~dev/~scratch")).toEqual({ sigil: "~", query: "dev/~scratch" });
|
||||
expect(parseBarQuery("a>b")).toEqual({ sigil: null, query: "a>b" });
|
||||
});
|
||||
|
||||
it("returns to the mixed list when the sigil is backspaced away", () => {
|
||||
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("command candidates", () => {
|
||||
it("dedupes by action, not by chord", () => {
|
||||
const run = () => {};
|
||||
const cmds = [
|
||||
command({ id: "one", label: "Do it", keys: ["j"], run }),
|
||||
command({ id: "two", label: "Do it", keys: ["down"], run }),
|
||||
];
|
||||
expect(commandBarCandidates(cmds)).toHaveLength(1);
|
||||
expect(dedupeCommands(cmds)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches on the label only, never on the chord", () => {
|
||||
const candidates = commandBarCandidates([command({ label: "Show help", keys: ["ctrl-q"] })]);
|
||||
expect(barRows("ctrlq", candidates)).toEqual([]);
|
||||
expect(barRows("show", candidates)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory candidates", () => {
|
||||
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
||||
const candidate = directoryCandidate("/home/me/dev/api", "/home/me");
|
||||
expect(candidate.fields.path).toBe("~/dev/api");
|
||||
expect(candidate.path).toBe("/home/me/dev/api");
|
||||
});
|
||||
|
||||
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
||||
const candidates = directoryBarCandidates(
|
||||
["~/dev/api", "/home/me/dev/api", "", "/srv/x"],
|
||||
"/home/me",
|
||||
);
|
||||
expect(candidates.map((c) => c.path)).toEqual(["/home/me/dev/api", "/srv/x"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the action page's candidates", () => {
|
||||
it("offers no action beyond the three", () => {
|
||||
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||
"kill",
|
||||
"rename",
|
||||
"spawn-similar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlight segments", () => {
|
||||
it("splits the text on the matcher's ranges", () => {
|
||||
expect(highlightSegments("web-search", [[0, 1], [4, 5]])).toEqual([
|
||||
{ text: "w", hit: true },
|
||||
{ text: "eb-", hit: false },
|
||||
{ text: "s", hit: true },
|
||||
{ text: "earch", hit: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
// The command bar's pure model (docs/COMMAND-BAR.md), lifted from
|
||||
// ../../commandBar.ts for the React port (#247): what a candidate is, the
|
||||
// order an empty query lists them in, how a query line splits into a sigil and
|
||||
// a query, and how a ranked list groups for rendering.
|
||||
//
|
||||
// Nothing here touches the DOM or the network apart from `loadBarDirectories`,
|
||||
// which is the one place the `~` list comes from.
|
||||
|
||||
import { listDir, projects as fetchProjects, recentDirs } from "../../api.js";
|
||||
import type { Command } from "../../commands.js";
|
||||
import { splitPath } from "../../paths.js";
|
||||
import { splitCommand } from "../../toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "../../types.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
type CandidateKind,
|
||||
type CommandCandidate,
|
||||
type DirectoryCandidate,
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "../../utils/matcher.js";
|
||||
|
||||
/** A matcher candidate that remembers what it came from, so a result can be
|
||||
* acted on without a second lookup. The extra property is invisible to the
|
||||
* matcher, which only ever reads `kind` and `fields`. */
|
||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||
export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
||||
* ranks; `path` is the one a spawn is made in. */
|
||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||
|
||||
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||
* single `label` field, ranked exactly like a registry command — and carries
|
||||
* the action it stands for instead of a registry entry. */
|
||||
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||
|
||||
export type BarCandidate =
|
||||
| SessionBarCandidate
|
||||
| CommandBarCandidate
|
||||
| ActionBarCandidate
|
||||
| DirectoryBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** One kind's slice of the ranked list, in rank order. */
|
||||
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: MatchResult<C>[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
session: "Sessions",
|
||||
command: "Commands",
|
||||
directory: "Directories",
|
||||
};
|
||||
|
||||
// ---- the sigils ----
|
||||
|
||||
export type BarSigil = ">" | "~";
|
||||
|
||||
export interface BarQuery {
|
||||
/** The sigil in force, or null for the mixed list. */
|
||||
sigil: BarSigil | null;
|
||||
/** What the matcher sees: everything after a sigil, or the whole query. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
export const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||
mixed: "Go to a session…",
|
||||
">": "Run a command…",
|
||||
"~": "Spawn in a directory…",
|
||||
};
|
||||
|
||||
/** Split a raw query line into its sigil and the query proper.
|
||||
*
|
||||
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
||||
* literal query character, which is what a directory query with a second `~`
|
||||
* in it depends on. Backspacing past a sigil needs no special case either —
|
||||
* the first character goes and the query is a mixed one again. */
|
||||
export function parseBarQuery(raw: string): BarQuery {
|
||||
const first = raw[0];
|
||||
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
||||
return { sigil: null, query: raw };
|
||||
}
|
||||
|
||||
// ---- the session action page ----
|
||||
|
||||
/** The actions a session's action page offers, and no others: the bar picks
|
||||
* one and hands off to the flow that already implements it
|
||||
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||
|
||||
export interface SessionAction {
|
||||
id: SessionActionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||
{ id: "kill", label: "Kill session" },
|
||||
{ id: "rename", label: "Rename session" },
|
||||
{ id: "spawn-similar", label: "Spawn similar" },
|
||||
];
|
||||
|
||||
/** The action page's candidates, in the order above — which is the order an
|
||||
* empty query lists them in, since the matcher keeps input order. */
|
||||
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||
return SESSION_ACTIONS.map((action) => ({
|
||||
kind: "command",
|
||||
action,
|
||||
fields: { label: action.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||
* `command` kind, so what separates them is what they carry. */
|
||||
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||
return candidate.kind === "command" && "action" in candidate;
|
||||
}
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
const stamp = s.last_focused_at;
|
||||
if (typeof stamp !== "string" || stamp === "") return null;
|
||||
const ms = Date.parse(stamp);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function createdAt(s: Session): number {
|
||||
const ms = Date.parse(s.created_at ?? "");
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
|
||||
* focused first; a session that has never been focused sorts after every
|
||||
* session that has, most recently created first among those; and a dead
|
||||
* session sorts after every live one regardless of recency, because a session
|
||||
* you cannot attach to is not where you just were.
|
||||
*
|
||||
* This is also the input order for a non-empty query, which is what the
|
||||
* matcher's last tie-break preserves. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
const focusB = focusedAt(b);
|
||||
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
|
||||
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
|
||||
|
||||
return createdAt(b) - createdAt(a);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
|
||||
* table names, with the directory abbreviated the way the sidebar shows it. */
|
||||
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
|
||||
const treePath = sessionTreePath(s);
|
||||
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
|
||||
const tool = splitCommand(s.command).tool ?? undefined;
|
||||
return {
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: sessionName(s),
|
||||
...(directory ? { directory } : {}),
|
||||
...(s.command ? { command: s.command } : {}),
|
||||
...(tool ? { tool } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every session as a candidate, in the empty-query order. */
|
||||
export function sessionBarCandidates(
|
||||
sessions: readonly Session[],
|
||||
homeDir?: string,
|
||||
): SessionBarCandidate[] {
|
||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||
}
|
||||
|
||||
/** Drop the commands that would list twice. Several chords can be bound to one
|
||||
* action — the registry already carries its aliases on one entry, but nothing
|
||||
* stops two entries running the same handler, and listed naively that is two
|
||||
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
||||
* displayed, so a command with two of them is still one row. */
|
||||
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
||||
const seenRun = new Set<Command["run"]>();
|
||||
const seenLabel = new Set<string>();
|
||||
const kept: Command[] = [];
|
||||
for (const command of commands) {
|
||||
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
||||
seenRun.add(command.run);
|
||||
seenLabel.add(command.label);
|
||||
kept.push(command);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Every bound command as a candidate, in registry order. Only the label is a
|
||||
* field: the chord is displayed beside the row, never matched, because
|
||||
* matching it would make `>` results depend on a user's rebinds. */
|
||||
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
||||
return dedupeCommands(commands).map((command) => ({
|
||||
kind: "command",
|
||||
command,
|
||||
fields: { label: command.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
||||
* a path the operator typed or `/api/dirs` completed. */
|
||||
export function expandHome(path: string, homeDir?: string): string {
|
||||
if (!homeDir) return path;
|
||||
if (path === "~") return homeDir;
|
||||
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
||||
}
|
||||
|
||||
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
||||
const absolute = expandHome(path, homeDir);
|
||||
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
||||
}
|
||||
|
||||
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
||||
* first mention of each — recent dirs (newest first) ahead of the project
|
||||
* list, which is the empty-query order the contract asks for. */
|
||||
export function directoryBarCandidates(
|
||||
paths: readonly string[],
|
||||
homeDir?: string,
|
||||
): DirectoryBarCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DirectoryBarCandidate[] = [];
|
||||
for (const path of paths) {
|
||||
if (path === "") continue;
|
||||
const candidate = directoryCandidate(path, homeDir);
|
||||
if (seen.has(candidate.path)) continue;
|
||||
seen.add(candidate.path);
|
||||
out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The directory list the bar starts from: what the spawn form already knows —
|
||||
* `GET /api/recent-dirs` and the project list — either alone if the other
|
||||
* fails. */
|
||||
export async function loadBarDirectories(): Promise<string[]> {
|
||||
const [recents, discovered] = await Promise.all([
|
||||
recentDirs()
|
||||
.then((r) => r.dirs.map((d) => d.cwd))
|
||||
.catch(() => [] as string[]),
|
||||
fetchProjects()
|
||||
.then((list) => list.map((p) => p.path))
|
||||
.catch(() => [] as string[]),
|
||||
]);
|
||||
return [...recents, ...discovered];
|
||||
}
|
||||
|
||||
/** One directory's children as candidate paths, for filling the `~` list in as
|
||||
* a path is typed. The directory being listed comes from the query itself. */
|
||||
export function dirEntryPaths(dir: string, entries: readonly string[]): string[] {
|
||||
return entries.map((entry) => `${dir}${entry}`);
|
||||
}
|
||||
|
||||
/** The directory a `~` query names, or `""` when it names none yet. */
|
||||
export function probeDir(query: string): string {
|
||||
return splitPath(query).dir;
|
||||
}
|
||||
|
||||
/** `GET /api/dirs`, in the shape the bar wants it. */
|
||||
export function listDirEntries(dir: string): Promise<string[]> {
|
||||
return listDir(dir).then((listing) => listing.entries);
|
||||
}
|
||||
|
||||
// ---- grouping ----
|
||||
|
||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||
* and groups appear in the order their best-scoring member does — so the first
|
||||
* row under the first header is the globally best result. */
|
||||
export function barGroups<C extends BarCandidate>(
|
||||
results: readonly MatchResult<C>[],
|
||||
): BarGroup<C>[] {
|
||||
const groups: BarGroup<C>[] = [];
|
||||
for (const result of results) {
|
||||
const kind = result.candidate.kind;
|
||||
let group = groups.find((g) => g.kind === kind);
|
||||
if (!group) {
|
||||
group = { kind, title: KIND_TITLES[kind], results: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.results.push(result);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||
* this list. */
|
||||
export function barRows<C extends BarCandidate>(
|
||||
query: string,
|
||||
candidates: readonly C[],
|
||||
): MatchResult<C>[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
// ---- row text ----
|
||||
|
||||
/** The name line of a row: the session's name, the command's label, or the
|
||||
* directory's path. */
|
||||
export function rowName(candidate: BarCandidate): string {
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
return candidate.fields.name;
|
||||
case "command":
|
||||
return candidate.fields.label;
|
||||
case "directory":
|
||||
return candidate.fields.path;
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line under a session row: the field the match was found in
|
||||
* when that was not the name, so a session found through its directory says
|
||||
* so. Only sessions have one — a command shows keycaps instead, and a
|
||||
* directory's path is already the name. */
|
||||
export function metaText(result: BarResult): string | undefined {
|
||||
if (result.candidate.kind !== "session") return undefined;
|
||||
const fields = result.candidate.fields;
|
||||
switch (result.field) {
|
||||
case "directory":
|
||||
return fields.directory;
|
||||
case "command":
|
||||
return fields.command;
|
||||
case "tool":
|
||||
return fields.tool;
|
||||
default:
|
||||
return fields.directory;
|
||||
}
|
||||
}
|
||||
|
||||
/** One run of a row's text, flagged when the matcher matched inside it. The
|
||||
* React rendering of `renderHighlighted`: ranges come from the matcher as
|
||||
* `[start, end)` character pairs over the field that won. */
|
||||
export interface HighlightSegment {
|
||||
text: string;
|
||||
hit: boolean;
|
||||
}
|
||||
|
||||
export function highlightSegments(
|
||||
text: string,
|
||||
ranges: readonly MatchRange[],
|
||||
): HighlightSegment[] {
|
||||
const out: HighlightSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false });
|
||||
out.push({ text: text.slice(start, end), hit: true });
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false });
|
||||
return out;
|
||||
}
|
||||
|
||||
export { sessionName };
|
||||
export type { CandidateKind, MatchRange, MatchResult };
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT } from "../../uiEvents.js";
|
||||
import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js";
|
||||
|
||||
let controls: ReturnType<typeof useCommandBar>;
|
||||
|
||||
function Probe(opts: UseCommandBarOptions) {
|
||||
controls = useCommandBar(opts);
|
||||
return <div>{controls.open ? "open" : "closed"}</div>;
|
||||
}
|
||||
|
||||
function ctrlK(target: EventTarget = window): void {
|
||||
act(() => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useCommandBar", () => {
|
||||
it("opens on ctrl-k, from the capture phase so an attached xterm never sees it", () => {
|
||||
const view = render(<Probe />);
|
||||
ctrlK();
|
||||
expect(view.container.textContent).toBe("open");
|
||||
});
|
||||
|
||||
it("stays deaf while the bar is already up — its own input owns the keys", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Probe onOpen={onOpen} />);
|
||||
ctrlK();
|
||||
ctrlK();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves a form field's own Ctrl-K alone", () => {
|
||||
const field = document.createElement("input");
|
||||
document.body.appendChild(field);
|
||||
const view = render(<Probe />);
|
||||
ctrlK(field);
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
field.remove();
|
||||
});
|
||||
|
||||
it("opens from the sidebar's search field, the touch entry point", () => {
|
||||
const view = render(<Probe />);
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||
});
|
||||
expect(view.container.textContent).toBe("open");
|
||||
});
|
||||
|
||||
it("runs the caller's hook before opening, so a preview is discarded first", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Probe onOpen={onOpen} />);
|
||||
ctrlK();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes on demand", () => {
|
||||
const view = render(<Probe />);
|
||||
ctrlK();
|
||||
act(() => controls.closeBar());
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
});
|
||||
|
||||
it("binds nothing while disabled — another overlay owns the keyboard", () => {
|
||||
const view = render(<Probe enabled={false} />);
|
||||
ctrlK();
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Opening and closing the bar (docs/COMMAND-BAR.md, "Opening"). `ctrl-k` is a
|
||||
// registry command like any other, so the `?` overlay lists it without a
|
||||
// second list; the touch entry point — the sidebar's search field — raises
|
||||
// `OPEN_COMMAND_BAR_EVENT` and lands on the same open.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT } from "../../uiEvents.js";
|
||||
import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js";
|
||||
|
||||
export interface UseCommandBarOptions {
|
||||
/** Run before the bar opens — the shell discards a preview here, since the
|
||||
* bar is not previewed into (docs/PREVIEW.md). */
|
||||
onOpen?(): void;
|
||||
/** Off while another overlay owns the keyboard (the restart overlay, the
|
||||
* help dialog). Defaults to on. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandBarControls {
|
||||
open: boolean;
|
||||
openBar(): void;
|
||||
closeBar(): void;
|
||||
}
|
||||
|
||||
export function useCommandBar(opts: UseCommandBarOptions = {}): CommandBarControls {
|
||||
const { onOpen, enabled = true } = opts;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openBar = useCallback(() => {
|
||||
onOpen?.();
|
||||
setOpen(true);
|
||||
}, [onOpen]);
|
||||
|
||||
const closeBar = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const keys = new CommandSet(defineCommands({ "bar.open": openBar }));
|
||||
|
||||
// Capture phase: an attached xterm swallows every keystroke it is given, so
|
||||
// the bar's binding has to be seen ahead of the terminal.
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
// The bar owns the keyboard while it is up; its own input handles the keys.
|
||||
if (open) return;
|
||||
// The pane's hidden textarea is not a typing target for the bar: ctrl-k
|
||||
// is seen ahead of xterm and opens the bar. A real form field — the spawn
|
||||
// form's inputs — keeps its own Ctrl+K.
|
||||
if (isBarTypingTarget(e.target)) return;
|
||||
keys.handle(e);
|
||||
};
|
||||
const onTouchEntry = () => {
|
||||
if (!open) openBar();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
document.addEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeydown, true);
|
||||
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
|
||||
};
|
||||
}, [enabled, open, openBar]);
|
||||
|
||||
return { open, openBar, closeBar };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ContextMenu } from "./ContextMenu.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function labels(): string[] {
|
||||
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("ContextMenu (#125)", () => {
|
||||
it("renders one item per entry, in order", () => {
|
||||
render(
|
||||
<ContextMenu
|
||||
x={10}
|
||||
y={20}
|
||||
items={[
|
||||
{ label: "Rename…", onSelect: () => {} },
|
||||
{ label: "Kill session", onSelect: () => {}, danger: true },
|
||||
]}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(labels()).toEqual(["Rename…", "Kill session"]);
|
||||
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe("Kill session");
|
||||
});
|
||||
|
||||
it("runs the selected entry and closes", () => {
|
||||
const chosen = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: chosen }]} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole("menuitem"));
|
||||
expect(chosen).toHaveBeenCalledOnce();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes on Escape and on a press outside it", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent(document.body, new Event("pointerdown", { bubbles: true }));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stays open for a press inside itself", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
|
||||
const menu = document.querySelector(".context-menu") as HTMLElement;
|
||||
fireEvent(menu, new Event("pointerdown", { bubbles: true }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes on scroll and on resize", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
|
||||
fireEvent.scroll(window);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.resize(window);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// React port of ../../contextMenu.ts (#125), the general-purpose version —
|
||||
// unlike the sidebar's own menu, this one also closes on scroll/resize, since
|
||||
// a menu anchored to coordinates goes stale the moment the page moves under it.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
onSelect(): void;
|
||||
/** Renders the entry in the destructive style. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
|
||||
// Nudge back inside the viewport once the menu has a measurable size.
|
||||
useLayoutEffect(() => {
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const maxX = window.innerWidth - rect.width;
|
||||
const maxY = window.innerHeight - rect.height;
|
||||
setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) });
|
||||
}, [x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
const onPointerDown = (e: Event) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
window.addEventListener("scroll", onClose, true);
|
||||
window.addEventListener("resize", onClose);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
window.removeEventListener("pointerdown", onPointerDown, true);
|
||||
window.removeEventListener("scroll", onClose, true);
|
||||
window.removeEventListener("resize", onClose);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
(ref.current?.firstElementChild as HTMLElement | null)?.focus();
|
||||
}, []);
|
||||
|
||||
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: pos.left, top: pos.top }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`context-menu-item block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
|
||||
item.danger ? "is-danger text-danger" : "text-ink"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js";
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js";
|
||||
import type { Scheduler } from "./events.js";
|
||||
import type { DaemonInfo } from "./types.js";
|
||||
import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.js";
|
||||
import type { DaemonInfo } from "../../types.js";
|
||||
|
||||
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
|
||||
return {
|
||||
@@ -14,14 +13,18 @@ function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:0
|
||||
};
|
||||
}
|
||||
|
||||
/** Scheduler whose timers only fire when the test says so. */
|
||||
function manual(): Scheduler & { run(): void; pending(): number } {
|
||||
/** Scheduler whose timers only fire when the test says so, recording the
|
||||
* delay each was scheduled with. */
|
||||
function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } {
|
||||
let next = 1;
|
||||
const timers = new Map<number, () => void>();
|
||||
const delays: number[] = [];
|
||||
return {
|
||||
setTimer(fn) {
|
||||
delays,
|
||||
setTimer(fn, ms) {
|
||||
const h = next++;
|
||||
timers.set(h, fn);
|
||||
delays.push(ms);
|
||||
return h;
|
||||
},
|
||||
clearTimer(h) {
|
||||
@@ -73,21 +76,22 @@ describe("formatRelease", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DaemonLink", () => {
|
||||
it("starts online once the first probe lands", async () => {
|
||||
const link = new DaemonLink({
|
||||
describe("daemon link store", () => {
|
||||
it("starts in the connecting state until the first probe lands", async () => {
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => info("aaa", "i1"),
|
||||
scheduler: manual(),
|
||||
reload: () => {},
|
||||
});
|
||||
await link.start();
|
||||
expect(link.status().state).toBe("online");
|
||||
expect(link.status().build?.commit).toBe("aaa");
|
||||
expect(store.getState().state).toBe("connecting");
|
||||
await store.getState().probe();
|
||||
expect(store.getState().state).toBe("online");
|
||||
expect(store.getState().build?.commit).toBe("aaa");
|
||||
});
|
||||
|
||||
it("holds the modal back during the grace period, then blocks", async () => {
|
||||
const sched = manual();
|
||||
const link = new DaemonLink({
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
@@ -95,19 +99,19 @@ describe("DaemonLink", () => {
|
||||
reload: () => {},
|
||||
graceMs: 1000,
|
||||
});
|
||||
link.noteDisconnected();
|
||||
store.getState().noteDisconnected();
|
||||
// Still inside the grace window: the dot goes amber, nothing blocks.
|
||||
expect(link.status().state).toBe("reconnecting");
|
||||
expect(store.getState().state).toBe("reconnecting");
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
expect(link.status().state).toBe("down");
|
||||
expect(store.getState().state).toBe("down");
|
||||
});
|
||||
|
||||
it("unlocks without reloading when the same build comes back", async () => {
|
||||
const sched = manual();
|
||||
const reload = vi.fn();
|
||||
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||
const link = new DaemonLink({
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => {
|
||||
if (!reply) throw new Error("down");
|
||||
return reply;
|
||||
@@ -115,45 +119,45 @@ describe("DaemonLink", () => {
|
||||
scheduler: sched,
|
||||
reload,
|
||||
});
|
||||
await link.start();
|
||||
await store.getState().probe();
|
||||
|
||||
reply = null;
|
||||
link.noteDisconnected();
|
||||
store.getState().noteDisconnected();
|
||||
sched.run(); // grace expires → blocked, poll scheduled
|
||||
await Promise.resolve();
|
||||
expect(link.status().state).toBe("down");
|
||||
expect(store.getState().state).toBe("down");
|
||||
|
||||
// Same commit, new process: a restart, not an upgrade — the page stays.
|
||||
reply = info("aaa", "i2");
|
||||
await link.probe();
|
||||
await store.getState().probe();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(link.status().state).toBe("online");
|
||||
expect(store.getState().state).toBe("online");
|
||||
});
|
||||
|
||||
it("reloads the page when the daemon comes back on a new commit", async () => {
|
||||
const sched = manual();
|
||||
const reload = vi.fn();
|
||||
let reply = info("aaa", "i1");
|
||||
const link = new DaemonLink({
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => reply,
|
||||
scheduler: sched,
|
||||
reload,
|
||||
});
|
||||
await link.start();
|
||||
await store.getState().probe();
|
||||
|
||||
link.noteDisconnected();
|
||||
store.getState().noteDisconnected();
|
||||
reply = info("bbb", "i2");
|
||||
await link.probe();
|
||||
await store.getState().probe();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
// Left blocked deliberately: the reload is what ends this page's life, and
|
||||
// flashing an unlocked UI in between would let the user act on stale code.
|
||||
expect(link.status().state).toBe("updating");
|
||||
expect(store.getState().state).toBe("updating");
|
||||
});
|
||||
|
||||
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
|
||||
const sched = manual();
|
||||
let attempts = 0;
|
||||
const link = new DaemonLink({
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => {
|
||||
attempts += 1;
|
||||
throw new Error("down");
|
||||
@@ -161,7 +165,7 @@ describe("DaemonLink", () => {
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
link.noteDisconnected();
|
||||
store.getState().noteDisconnected();
|
||||
sched.run(); // grace → first poll
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -171,23 +175,41 @@ describe("DaemonLink", () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(attempts).toBeGreaterThan(first);
|
||||
link.stop();
|
||||
store.getState().stop();
|
||||
});
|
||||
|
||||
it("notifies subscribers on every state change", async () => {
|
||||
const sched = manual();
|
||||
const seen: string[] = [];
|
||||
const link = new DaemonLink({
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => info("aaa", "i1"),
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
link.subscribe((s) => seen.push(s.state));
|
||||
await link.start();
|
||||
link.noteDisconnected();
|
||||
store.subscribe((s) => seen.push(s.state));
|
||||
await store.getState().probe();
|
||||
store.getState().noteDisconnected();
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
expect(seen).toContain("reconnecting");
|
||||
expect(seen[0]).toBe("connecting");
|
||||
});
|
||||
|
||||
it("schedules the grace timer and poll at the documented defaults", async () => {
|
||||
const sched = manual();
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
expect(GRACE_MS).toBe(1200);
|
||||
expect(POLL_MS).toBe(700);
|
||||
store.getState().noteDisconnected();
|
||||
expect(sched.delays).toEqual([GRACE_MS]);
|
||||
sched.run(); // grace fires → schedules the first poll
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(sched.delays).toEqual([GRACE_MS, POLL_MS]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDaemonLinkStore } from "./DaemonLink.js";
|
||||
import { DaemonLink } from "./DaemonLink.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("DaemonLink", () => {
|
||||
it("probes once on mount, regardless of the initial connected value", async () => {
|
||||
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
|
||||
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||
render(<DaemonLink store={store} connected={false} />);
|
||||
await Promise.resolve();
|
||||
expect(fetchInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not treat the initial connected value as a transition", async () => {
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||
scheduler: { setTimer: () => 0, clearTimer: () => {} },
|
||||
});
|
||||
render(<DaemonLink store={store} connected={true} />);
|
||||
await Promise.resolve();
|
||||
// Only the mount probe ran — no extra noteConnected() probe on top of it.
|
||||
expect(store.getState().state).toBe("online");
|
||||
});
|
||||
|
||||
it("forwards a later connected drop to noteDisconnected", async () => {
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||
scheduler: { setTimer: () => 0, clearTimer: () => {} },
|
||||
});
|
||||
const { rerender } = render(<DaemonLink store={store} connected={true} />);
|
||||
await Promise.resolve();
|
||||
expect(store.getState().state).toBe("online");
|
||||
|
||||
rerender(<DaemonLink store={store} connected={false} />);
|
||||
expect(store.getState().state).toBe("reconnecting");
|
||||
});
|
||||
|
||||
it("forwards a later connected recovery to noteConnected", async () => {
|
||||
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
|
||||
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||
const { rerender } = render(<DaemonLink store={store} connected={true} />);
|
||||
await Promise.resolve();
|
||||
expect(store.getState().state).toBe("online");
|
||||
|
||||
// The socket dropped: noteDisconnected() moves off "online" so the coming
|
||||
// recovery isn't a no-op.
|
||||
rerender(<DaemonLink store={store} connected={false} />);
|
||||
expect(store.getState().state).toBe("reconnecting");
|
||||
fetchInfo.mockClear();
|
||||
|
||||
rerender(<DaemonLink store={store} connected={true} />);
|
||||
await Promise.resolve();
|
||||
expect(fetchInfo).toHaveBeenCalledTimes(1);
|
||||
expect(store.getState().state).toBe("online");
|
||||
});
|
||||
|
||||
it("stops the store's polling on unmount", () => {
|
||||
const store = createDaemonLinkStore({
|
||||
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||
});
|
||||
const stop = vi.spyOn(store.getState(), "stop");
|
||||
const { unmount } = render(<DaemonLink store={store} connected={false} />);
|
||||
unmount();
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// React port of ../../daemonLink.ts. The link to the daemon behind this page:
|
||||
// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx)
|
||||
// renders what this reports. See docs/RESTART.md for the restart contract.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { getDaemonInfo } from "../../api.js";
|
||||
import type { BuildInfo, DaemonInfo } from "../../types.js";
|
||||
|
||||
/**
|
||||
* - `connecting` — first probe hasn't landed yet.
|
||||
* - `online` — the daemon answered and is the same process, or a resolved new one.
|
||||
* - `reconnecting` — the socket dropped, but within the grace window; a blip
|
||||
* shouldn't throw a modal in the user's face.
|
||||
* - `down` — unreachable past the grace window: the overlay blocks the UI and
|
||||
* the poll runs until it answers.
|
||||
* - `updating` — it answered on a different commit; the page is reloading and
|
||||
* stays blocked until it does.
|
||||
*/
|
||||
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
|
||||
|
||||
/** What happened between two `/api/info` replies. */
|
||||
export type RestartKind = "same" | "restarted" | "upgraded";
|
||||
|
||||
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
|
||||
* to ride out a wifi hiccup, short enough that a real restart is visible. */
|
||||
export const GRACE_MS = 1200;
|
||||
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
|
||||
export const POLL_MS = 700;
|
||||
|
||||
/**
|
||||
* Compare two daemon replies.
|
||||
*
|
||||
* The commit decides first: a different one means new code, which for a browser
|
||||
* means new assets. The per-process `instance` catches the rest — a restart of
|
||||
* the same build, which needs no reload. A daemon built outside a git checkout
|
||||
* reports an empty commit, so an empty-vs-empty pair never counts as an
|
||||
* upgrade; the instance still reports the restart.
|
||||
*/
|
||||
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
||||
const a = before.build?.commit ?? "";
|
||||
const b = after.build?.commit ?? "";
|
||||
if (a && b && a !== b) return "upgraded";
|
||||
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
|
||||
return "same";
|
||||
}
|
||||
|
||||
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
|
||||
* the build carried uncommitted changes. Empty builds say so rather than
|
||||
* rendering a blank tooltip. */
|
||||
export function formatRelease(build: BuildInfo | undefined): string {
|
||||
if (!build?.commit) return "unknown release";
|
||||
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
|
||||
const parts = [build.commit];
|
||||
if (day) parts.push(`· ${day}`);
|
||||
if (build.dirty) parts.push("(dirty)");
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */
|
||||
export interface Scheduler {
|
||||
setTimer(fn: () => void, ms: number): number;
|
||||
clearTimer(handle: number): void;
|
||||
}
|
||||
|
||||
const browserScheduler: Scheduler = {
|
||||
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
||||
clearTimer: (h) => window.clearTimeout(h),
|
||||
};
|
||||
|
||||
export interface DaemonLinkState {
|
||||
state: LinkState;
|
||||
/** The release the daemon last reported. Undefined until the first probe,
|
||||
* and on a daemon too old to carry one. */
|
||||
build?: BuildInfo;
|
||||
/** Hostname the daemon reports, for the indicator's tooltip. */
|
||||
hostname?: string;
|
||||
/** How many probes have failed in the current outage — the overlay counts
|
||||
* attempts so a long restart doesn't look frozen. */
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface DaemonLinkActions {
|
||||
/** One `/api/info` round-trip. Resolves `true` when the daemon answered. */
|
||||
probe(): Promise<boolean>;
|
||||
/** The events socket dropped. Starts the grace timer and the poll. */
|
||||
noteDisconnected(): void;
|
||||
/** The events socket came back. It carries no identity of its own, so the
|
||||
* state only clears once a probe confirms *which* daemon answered — a
|
||||
* reconnect to an upgraded daemon must reload, not resume. */
|
||||
noteConnected(): void;
|
||||
/** Stop polling. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions;
|
||||
|
||||
export interface DaemonLinkOptions {
|
||||
/** Injectable for tests; defaults to `GET /api/info`. */
|
||||
fetchInfo?: () => Promise<DaemonInfo>;
|
||||
scheduler?: Scheduler;
|
||||
/** What an upgrade does. Injectable so tests don't navigate. */
|
||||
reload?: () => void;
|
||||
graceMs?: number;
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
/** Builds an isolated daemon-link store — same shape as
|
||||
* {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook
|
||||
* and a plain object (`getState`/`subscribe`), so it drives the overlay
|
||||
* whether or not React is the one calling `noteConnected`/`noteDisconnected`. */
|
||||
export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) {
|
||||
const fetchInfo = opts.fetchInfo ?? getDaemonInfo;
|
||||
const scheduler = opts.scheduler ?? browserScheduler;
|
||||
const reload = opts.reload ?? (() => window.location.reload());
|
||||
const graceMs = opts.graceMs ?? GRACE_MS;
|
||||
const pollMs = opts.pollMs ?? POLL_MS;
|
||||
|
||||
let identity: DaemonInfo | null = null;
|
||||
let graceTimer: number | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
return create<DaemonLinkStore>((set, get) => {
|
||||
function clearTimers(): void {
|
||||
if (graceTimer !== null) {
|
||||
scheduler.clearTimer(graceTimer);
|
||||
graceTimer = null;
|
||||
}
|
||||
if (pollTimer !== null) {
|
||||
scheduler.clearTimer(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function probe(): Promise<boolean> {
|
||||
let info: DaemonInfo;
|
||||
try {
|
||||
info = await fetchInfo();
|
||||
} catch {
|
||||
set((s) => ({ attempts: s.attempts + 1 }));
|
||||
return false;
|
||||
}
|
||||
const before = identity;
|
||||
identity = info;
|
||||
if (before && compareIdentity(before, info) === "upgraded") {
|
||||
// Stay blocked through the reload: an unlocked frame here would let the
|
||||
// user drive new server code from the bundle it just replaced.
|
||||
set({ state: "updating", build: info.build, hostname: info.hostname });
|
||||
reload();
|
||||
return true;
|
||||
}
|
||||
clearTimers();
|
||||
set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
const state = get().state;
|
||||
if (state === "online" || state === "updating") return;
|
||||
const ok = await probe();
|
||||
if (ok || pollTimer !== null) return;
|
||||
pollTimer = scheduler.setTimer(() => {
|
||||
pollTimer = null;
|
||||
void poll();
|
||||
}, pollMs);
|
||||
}
|
||||
|
||||
return {
|
||||
state: "connecting",
|
||||
build: undefined,
|
||||
hostname: undefined,
|
||||
attempts: 0,
|
||||
|
||||
probe,
|
||||
|
||||
noteDisconnected() {
|
||||
const state = get().state;
|
||||
if (state === "down" || state === "updating") return;
|
||||
set({ state: "reconnecting", attempts: 0 });
|
||||
if (graceTimer === null) {
|
||||
graceTimer = scheduler.setTimer(() => {
|
||||
graceTimer = null;
|
||||
if (get().state === "reconnecting") set({ state: "down" });
|
||||
void poll();
|
||||
}, graceMs);
|
||||
}
|
||||
},
|
||||
|
||||
noteConnected() {
|
||||
const state = get().state;
|
||||
if (state === "online" || state === "updating") return;
|
||||
void probe();
|
||||
},
|
||||
|
||||
stop: clearTimers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type DaemonLinkStoreApi = ReturnType<typeof createDaemonLinkStore>;
|
||||
|
||||
export interface DaemonLinkProps {
|
||||
/** The link store this instance drives — `RestartOverlay` (and, later, the
|
||||
* sidebar's status indicator) read the same one. */
|
||||
store: DaemonLinkStoreApi;
|
||||
/** The catalog store's `connected` field (docs/RESTART.md): the events
|
||||
* socket dying is the earliest signal a restart happened, ahead of any
|
||||
* poll. Pass `useStore(catalogStore, (s) => s.connected)`. */
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders nothing — the controller that wires a `DaemonLinkStore` to the
|
||||
* catalog's socket state. Mount once near the app root; `RestartOverlay`
|
||||
* reads the store it drives.
|
||||
*/
|
||||
export function DaemonLink({ store, connected }: DaemonLinkProps): null {
|
||||
const mounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted.current) {
|
||||
mounted.current = true;
|
||||
void store.getState().probe();
|
||||
return;
|
||||
}
|
||||
if (connected) store.getState().noteConnected();
|
||||
else store.getState().noteDisconnected();
|
||||
}, [store, connected]);
|
||||
|
||||
useEffect(() => () => store.getState().stop(), [store]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js";
|
||||
import { RestartOverlay } from "./RestartOverlay.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi {
|
||||
const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||
store.setState({ state });
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("RestartOverlay", () => {
|
||||
it("renders nothing while connecting, online, or reconnecting", () => {
|
||||
for (const state of ["connecting", "online", "reconnecting"] as const) {
|
||||
const { unmount } = render(<RestartOverlay store={storeAt(state)} />);
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks with a restarting message when down", () => {
|
||||
render(<RestartOverlay store={storeAt("down")} />);
|
||||
const dialog = screen.getByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain("Maestro is restarting");
|
||||
expect(dialog.textContent).toContain("Waiting for the daemon to come back…");
|
||||
});
|
||||
|
||||
it("counts attempts once more than one probe has failed", () => {
|
||||
const store = storeAt("down");
|
||||
store.setState({ attempts: 3 });
|
||||
render(<RestartOverlay store={store} />);
|
||||
expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)");
|
||||
});
|
||||
|
||||
it("says it is reloading when updating", () => {
|
||||
render(<RestartOverlay store={storeAt("updating")} />);
|
||||
const dialog = screen.getByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain("Maestro was updated");
|
||||
expect(dialog.textContent).toContain("Reloading this page onto the new release…");
|
||||
});
|
||||
|
||||
it("names the release from the store's build", () => {
|
||||
const store = storeAt("down");
|
||||
store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } });
|
||||
render(<RestartOverlay store={store} />);
|
||||
expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02");
|
||||
});
|
||||
|
||||
it("clears the moment the store reports the link is back", () => {
|
||||
const store = storeAt("down");
|
||||
const { rerender } = render(<RestartOverlay store={store} />);
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeNull();
|
||||
store.setState({ state: "online" });
|
||||
rerender(<RestartOverlay store={store} />);
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows keyboard shortcuts while blocking, but not once cleared", () => {
|
||||
const store = storeAt("down");
|
||||
const handler = vi.fn();
|
||||
window.addEventListener("keydown", handler);
|
||||
const { rerender } = render(<RestartOverlay store={store} />);
|
||||
|
||||
fireEvent.keyDown(window, { key: "n" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
store.setState({ state: "online" });
|
||||
rerender(<RestartOverlay store={store} />);
|
||||
fireEvent.keyDown(window, { key: "n" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.removeEventListener("keydown", handler);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that
|
||||
// went away — see docs/RESTART.md.
|
||||
//
|
||||
// Rendered while the link is `down` or `updating`, gone the moment it is
|
||||
// back. It owns no polling of its own: `DaemonLink` polls, this only renders
|
||||
// what the store reports.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js";
|
||||
|
||||
export interface RestartOverlayProps {
|
||||
/** The same store a sibling `DaemonLink` drives. */
|
||||
store: DaemonLinkStoreApi;
|
||||
}
|
||||
|
||||
/** The poll runs while this is up; saying how many tries have gone by is what
|
||||
* separates "still restarting" from "wedged" for someone staring at it. */
|
||||
function attemptText(attempts: number): string {
|
||||
if (attempts <= 1) return "Waiting for the daemon to come back…";
|
||||
return `Waiting for the daemon to come back… (${attempts} attempts)`;
|
||||
}
|
||||
|
||||
function swallow(e: KeyboardEvent): void {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
export function RestartOverlay({ store }: RestartOverlayProps) {
|
||||
const state = useStore(store, (s) => s.state);
|
||||
const build = useStore(store, (s) => s.build);
|
||||
const attempts = useStore(store, (s) => s.attempts);
|
||||
|
||||
const blocking = state === "down" || state === "updating";
|
||||
|
||||
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
|
||||
// does not stop them: `n` would still open a spawn form against a daemon
|
||||
// that is not there. Swallow keys in the capture phase until the link is
|
||||
// back, without `preventDefault` so the browser's own chords still work.
|
||||
useEffect(() => {
|
||||
if (!blocking) return;
|
||||
window.addEventListener("keydown", swallow, true);
|
||||
return () => window.removeEventListener("keydown", swallow, true);
|
||||
}, [blocking]);
|
||||
|
||||
if (!blocking) return null;
|
||||
|
||||
const updating = state === "updating";
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop restart-backdrop">
|
||||
<div className="modal restart-modal" role="alertdialog" aria-modal="true" aria-labelledby="restart-title">
|
||||
<div className="restart-spinner" aria-hidden="true" />
|
||||
<h2 id="restart-title">{updating ? "Maestro was updated" : "Maestro is restarting"}</h2>
|
||||
<p className="restart-detail">
|
||||
{updating ? "Reloading this page onto the new release…" : attemptText(attempts)}
|
||||
</p>
|
||||
<p className="restart-release">{formatRelease(build)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { keyCaps } from "../../commands.js";
|
||||
import { groupedCommands } from "./model.js";
|
||||
import { HelpOverlay } from "./HelpOverlay.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function press(key: string, init: KeyboardEventInit = {}): void {
|
||||
fireEvent.keyDown(window, { key, ...init });
|
||||
}
|
||||
|
||||
describe("HelpOverlay", () => {
|
||||
it("opens on ? and closes on a second ?", () => {
|
||||
render(<HelpOverlay />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
press("?", { shiftKey: true });
|
||||
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||
press("?", { shiftKey: true });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores ? while typing and when a non-shift modifier is held", () => {
|
||||
render(<HelpOverlay />);
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
fireEvent.keyDown(input, { key: "?", shiftKey: true });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
press("?", { ctrlKey: true });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("closes on Escape, on the close button, and on a click outside the panel", () => {
|
||||
render(<HelpOverlay />);
|
||||
|
||||
press("?", { shiftKey: true });
|
||||
press("Escape");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
press("?", { shiftKey: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
press("?", { shiftKey: true });
|
||||
fireEvent.click(screen.getByRole("dialog"));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("stays open for a click inside the panel", () => {
|
||||
render(<HelpOverlay />);
|
||||
press("?", { shiftKey: true });
|
||||
fireEvent.click(document.querySelector(".help-panel") as HTMLElement);
|
||||
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders exactly the commands the registry has bound, grouped", () => {
|
||||
render(<HelpOverlay />);
|
||||
press("?", { shiftKey: true });
|
||||
|
||||
const groups = groupedCommands();
|
||||
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
|
||||
groups.map((g) => g.title),
|
||||
);
|
||||
expect(
|
||||
[...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
|
||||
).toEqual(groups.flatMap((g) => g.items.map((c) => c.id)));
|
||||
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
|
||||
groups.flatMap((g) => g.items.map((c) => c.label)),
|
||||
);
|
||||
expect(
|
||||
[...document.querySelectorAll(".help-row")].map((r) =>
|
||||
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
|
||||
),
|
||||
).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// React port of ../../help.ts: the `?` overlay renders the command registry
|
||||
// (commands.ts) directly — the same list that dispatches the keys — so a
|
||||
// binding can never be documented as one thing and do another.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
|
||||
import { TOGGLE_HELP_EVENT } from "../../uiEvents.js";
|
||||
import { groupedCommands } from "./model.js";
|
||||
|
||||
export function HelpOverlay() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => setOpen((o) => !o) }));
|
||||
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => setOpen(false) }));
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (open && overlayKeys.handle(e)) return;
|
||||
helpKeys.handle(e);
|
||||
};
|
||||
const onToggle = () => setOpen((o) => !o);
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
document.addEventListener(TOGGLE_HELP_EVENT, onToggle);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener(TOGGLE_HELP_EVENT, onToggle);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Keyboard shortcuts"
|
||||
className="help-overlay fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<div className="help-panel max-h-[80vh] w-full max-w-[560px] overflow-auto rounded-r-3 border border-border bg-surface p-sp-4 shadow-2">
|
||||
<header className="help-head flex items-center justify-between">
|
||||
<h2 className="help-title m-0 text-fs-15 font-semibold text-ink">Keyboard shortcuts</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="help-close rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
|
||||
onClick={close}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
{groupedCommands().map((group) => (
|
||||
<section key={group.title} className="help-group mt-sp-3">
|
||||
<h3 className="help-group-title text-fs-12 font-semibold text-muted">{group.title}</h3>
|
||||
<dl className="help-list mt-sp-1">
|
||||
{group.items.map((cmd) => (
|
||||
<div
|
||||
key={cmd.id}
|
||||
className="help-row flex items-center justify-between gap-sp-3 py-sp-1"
|
||||
data-command-id={cmd.id}
|
||||
>
|
||||
<dt className="help-keys m-0 flex gap-sp-1">
|
||||
{keyCaps(cmd.keys[0]).map((cap, i) => (
|
||||
<kbd key={i} className="rounded-r-1 border border-border px-sp-1 text-fs-11">
|
||||
{cap}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="help-desc m-0 text-fs-12 text-ink">{cmd.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { KillDialog } from "./KillDialog.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("KillDialog", () => {
|
||||
it("focuses the kill button, so Enter confirms", () => {
|
||||
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={vi.fn()} />);
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" }));
|
||||
});
|
||||
|
||||
it("focuses the force button too", () => {
|
||||
render(
|
||||
<KillDialog
|
||||
name="alpha"
|
||||
blockers={null}
|
||||
force
|
||||
reason="worktree is dirty."
|
||||
onConfirm={() => Promise.resolve()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" }));
|
||||
});
|
||||
|
||||
it("names the blockers in the confirm body", () => {
|
||||
render(
|
||||
<KillDialog
|
||||
name="alpha"
|
||||
blockers={{ uncommitted_files: 2, untracked_files: 0, unpushed_commits: 0 }}
|
||||
onConfirm={() => Promise.resolve()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/will be refused unless it is forced/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("cancels on Cancel, on Escape and on a backdrop click", () => {
|
||||
const onCancel = vi.fn();
|
||||
const first = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
first.unmount();
|
||||
|
||||
const second = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
expect(onCancel).toHaveBeenCalledTimes(2);
|
||||
second.unmount();
|
||||
|
||||
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole("dialog"));
|
||||
expect(onCancel).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("disables the button while pending and shows a rejection inline, so the press can be retried", async () => {
|
||||
let reject: (e: Error) => void = () => {};
|
||||
const onConfirm = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
|
||||
render(<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />);
|
||||
|
||||
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
|
||||
fireEvent.click(kill);
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(kill.disabled).toBe(true);
|
||||
|
||||
reject(new Error("daemon unreachable"));
|
||||
await waitFor(() => expect(kill.disabled).toBe(false));
|
||||
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resets pending when force changes, so the force button is clickable", async () => {
|
||||
// Simulate the Sidebar flow: onConfirm resolves (parent switches to
|
||||
// force mode via rerender), but pending was set to true before the call.
|
||||
const onConfirm = vi.fn(() => Promise.resolve());
|
||||
const { rerender } = render(
|
||||
<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
|
||||
fireEvent.click(kill);
|
||||
expect(kill.disabled).toBe(true);
|
||||
|
||||
// Parent re-renders with force=true (the daemon refused the unforced kill).
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
||||
rerender(
|
||||
<KillDialog
|
||||
name="alpha"
|
||||
blockers={{ uncommitted_files: 1, untracked_files: 0, unpushed_commits: 0 }}
|
||||
force
|
||||
reason="worktree is dirty"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const forceBtn = screen.getByRole("button", { name: "Force kill" }) as HTMLButtonElement;
|
||||
expect(forceBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// React port of ../../killDialog.ts: the two kill prompts (#81, #160). The
|
||||
// daemon refuses an unforced kill over a worktree holding unsaved work; the
|
||||
// same component in `force` mode is the override that follows that refusal.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { formatInTheWay, type InTheWay } from "../../types.js";
|
||||
|
||||
export interface KillDialogProps {
|
||||
/** Session name, as the sidebar shows it. */
|
||||
name: string;
|
||||
/** What the card last saw in the way, when anything was. */
|
||||
blockers: InTheWay | null;
|
||||
/** Set once the daemon has refused an unforced kill: this pass forces it,
|
||||
* and `reason` carries the daemon's refusal message. */
|
||||
force?: boolean;
|
||||
/** Message from the daemon's refusal, shown when there are no blockers to
|
||||
* spell out. */
|
||||
reason?: string;
|
||||
/** Run the kill (or the forced kill). A rejection is rendered inline, and
|
||||
* the dialog stays open so the press can be retried. */
|
||||
onConfirm(): Promise<void>;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
export function KillDialog(props: KillDialogProps) {
|
||||
const { name, blockers, force, reason, onConfirm, onCancel } = props;
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The acting button takes the focus in both modes: the operator already
|
||||
// asked for the kill, so Enter confirms it.
|
||||
useEffect(() => confirmRef.current?.focus(), []);
|
||||
|
||||
// When the dialog transitions from normal → force mode, the previous
|
||||
// onConfirm resolved without error (it mutated parent state and returned),
|
||||
// so `pending` is still true. Reset it so the force button is clickable.
|
||||
useEffect(() => setPending(false), [force]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
void onConfirm().catch((e: unknown) => {
|
||||
setPending(false);
|
||||
setError(`Failed to kill: ${(e as Error).message}`);
|
||||
});
|
||||
};
|
||||
|
||||
const body = blockers
|
||||
? force
|
||||
? `Its worktree still has ${formatInTheWay(blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||
: `Its worktree still has ${formatInTheWay(blockers)}. The kill will be refused unless it is forced.`
|
||||
: force
|
||||
? `${reason ?? ""} Forcing the kill removes the session and leaves the worktree on disk.`
|
||||
: "The session and everything running in it go away. Its worktree stays on disk.";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Kill ${name}`}
|
||||
className="kill-dialog fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="kill-dialog-panel min-w-[280px] max-w-[420px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
|
||||
<h2 className="kill-dialog-title m-0 text-fs-15 font-semibold text-ink">Kill "{name}"?</h2>
|
||||
<p className="kill-dialog-body mt-sp-2 text-fs-13 text-ink">{body}</p>
|
||||
{error && <p className="kill-dialog-error mt-sp-2 text-fs-12 text-danger">{error}</p>}
|
||||
<div className="kill-dialog-actions mt-sp-3 flex justify-end gap-sp-2">
|
||||
<button
|
||||
type="button"
|
||||
className="kill-dialog-cancel rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
disabled={pending}
|
||||
className={`rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink disabled:opacity-60 ${
|
||||
force ? "kill-dialog-force" : "kill-dialog-kill"
|
||||
}`}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{force ? "Force kill" : "Kill"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
|
||||
export { HelpOverlay } from "./HelpOverlay.js";
|
||||
export { groupedCommands } from "./model.js";
|
||||
@@ -0,0 +1,20 @@
|
||||
// Command-registry grouping for the help overlay. Moved out of help.ts (whose
|
||||
// imperative DOM overlay #253 deleted) so HelpOverlay.tsx renders the same
|
||||
// grouping the vanilla overlay did.
|
||||
|
||||
import { publishedCommands, type Command } from "../../commands.js";
|
||||
|
||||
/** Published commands bucketed by `group`, in registry order — the order the
|
||||
* overlay lists them in. */
|
||||
export function groupedCommands(): { title: string; items: Command[] }[] {
|
||||
const groups: { title: string; items: Command[] }[] = [];
|
||||
for (const cmd of publishedCommands()) {
|
||||
let group = groups.find((g) => g.title === cmd.group);
|
||||
if (!group) {
|
||||
group = { title: cmd.group, items: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(cmd);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useViewport } from "./useViewport.js";
|
||||
|
||||
function Probe() {
|
||||
useViewport();
|
||||
return null;
|
||||
}
|
||||
|
||||
class FakeVisualViewport {
|
||||
height = 800;
|
||||
offsetTop = 0;
|
||||
private listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||||
|
||||
addEventListener(type: string, cb: () => void): void {
|
||||
this.listeners[type].push(cb);
|
||||
}
|
||||
removeEventListener(type: string, cb: () => void): void {
|
||||
this.listeners[type] = this.listeners[type].filter((l) => l !== cb);
|
||||
}
|
||||
fire(type: "resize" | "scroll"): void {
|
||||
for (const l of this.listeners[type]) l();
|
||||
}
|
||||
}
|
||||
|
||||
let vv: FakeVisualViewport;
|
||||
|
||||
beforeEach(() => {
|
||||
vv = new FakeVisualViewport();
|
||||
vi.stubGlobal("visualViewport", vv);
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
document.documentElement.className = "";
|
||||
document.documentElement.removeAttribute("style");
|
||||
});
|
||||
|
||||
describe("useViewport", () => {
|
||||
it("mirrors the visual viewport's height and offset onto CSS custom properties", () => {
|
||||
vv.height = 640;
|
||||
vv.offsetTop = 12;
|
||||
render(<Probe />);
|
||||
const root = document.documentElement.style;
|
||||
expect(root.getPropertyValue("--app-vh")).toBe("640px");
|
||||
expect(root.getPropertyValue("--app-top")).toBe("12px");
|
||||
});
|
||||
|
||||
it("no-ops when visualViewport is unavailable", () => {
|
||||
vi.stubGlobal("visualViewport", undefined);
|
||||
expect(() => render(<Probe />)).not.toThrow();
|
||||
expect(document.documentElement.style.getPropertyValue("--app-vh")).toBe("");
|
||||
});
|
||||
|
||||
it("marks kb-open once an editable shrinks the viewport well past the baseline", () => {
|
||||
render(<Probe />);
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
vv.height = 800 - 200;
|
||||
vv.fire("resize");
|
||||
expect(document.documentElement.classList.contains("kb-open")).toBe(true);
|
||||
|
||||
input.blur();
|
||||
vv.height = 800;
|
||||
vv.fire("resize");
|
||||
expect(document.documentElement.classList.contains("kb-open")).toBe(false);
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// React port of ../../viewport.ts. Binds the app shell's height and position
|
||||
// to the *visual* viewport rather than the layout one, so a pinned bottom bar
|
||||
// (the attach view's special-key bar, NAP-342) lands above an open on-screen
|
||||
// keyboard instead of behind it. See viewport.ts for the full mechanism.
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
const KB_OPEN_THRESHOLD = 150;
|
||||
|
||||
export function useViewport(): void {
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
let raf = 0;
|
||||
let baseline = vv.height;
|
||||
|
||||
const isEditing = () => {
|
||||
const a = document.activeElement as HTMLElement | null;
|
||||
if (!a) return false;
|
||||
return a.tagName === "TEXTAREA" || a.tagName === "INPUT" || a.isContentEditable;
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
raf = 0;
|
||||
root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`);
|
||||
root.style.setProperty("--app-top", `${Math.round(vv.offsetTop)}px`);
|
||||
|
||||
// Nothing focused ⇒ keyboard can't be open ⇒ this height is the baseline.
|
||||
const editing = isEditing();
|
||||
if (!editing) baseline = vv.height;
|
||||
const kbOpen = editing && baseline - vv.height > KB_OPEN_THRESHOLD;
|
||||
root.classList.toggle("kb-open", kbOpen);
|
||||
};
|
||||
const schedule = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(sync);
|
||||
};
|
||||
|
||||
vv.addEventListener("resize", schedule);
|
||||
vv.addEventListener("scroll", schedule);
|
||||
sync();
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener("resize", schedule);
|
||||
vv.removeEventListener("scroll", schedule);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Dev-only design kit (#254), React port of the deleted kit.ts + kit.css.
|
||||
// Specimen page for the Tailwind token mapping (tailwind.config.ts ->
|
||||
// src/styles.css) so the visual system can be reviewed as a whole. Loads on
|
||||
// `?kit` (see index.tsx) and never ships to a production bundle.
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { ActivityRail, STATE_WORDS } from "../sidebar/ActivityRail.js";
|
||||
import { SessionCard, type SessionCardProps } from "../sidebar/SessionCard.js";
|
||||
import { PaneTitle } from "../layout/PaneTitle.js";
|
||||
import { PALETTES, useTheme, type Palette } from "../../contexts/ThemeContext.js";
|
||||
import type { Session } from "../../types.js";
|
||||
|
||||
/** The accent each palette lands on, read from styles.css — shown so all six
|
||||
* are comparable at a glance without switching the live one one at a time. */
|
||||
const PALETTE_ACCENT: Record<Palette, string> = {
|
||||
graphite: "oklch(0.92 0.005 256)",
|
||||
"ice-cyan": "oklch(0.85 0.115 205)",
|
||||
periwinkle: "oklch(0.75 0.125 275)",
|
||||
"amber-phosphor": "oklch(0.85 0.16 85)",
|
||||
ember: "oklch(0.78 0.16 40)",
|
||||
orchid: "oklch(0.78 0.13 320)",
|
||||
};
|
||||
|
||||
const COLOR_TOKENS = [
|
||||
"--bg",
|
||||
"--bg-grid",
|
||||
"--surface",
|
||||
"--surface-2",
|
||||
"--surface-3",
|
||||
"--border",
|
||||
"--border-strong",
|
||||
"--ink",
|
||||
"--muted",
|
||||
"--faint",
|
||||
"--accent",
|
||||
"--accent-strong",
|
||||
"--accent-ink",
|
||||
"--accent-tint",
|
||||
"--accent-fill",
|
||||
"--live",
|
||||
"--danger",
|
||||
"--danger-tint",
|
||||
"--warn",
|
||||
"--term-bg",
|
||||
] as const;
|
||||
|
||||
// Tailwind's content scanner needs each utility to appear as a literal,
|
||||
// contiguous string in source — a template-built `w-${token}` is invisible
|
||||
// to it, so every scale below pairs the token label with its whole class name.
|
||||
const SPACING_TOKENS = [
|
||||
{ token: "sp-1", widthClass: "w-sp-1" },
|
||||
{ token: "sp-2", widthClass: "w-sp-2" },
|
||||
{ token: "sp-3", widthClass: "w-sp-3" },
|
||||
{ token: "sp-4", widthClass: "w-sp-4" },
|
||||
{ token: "sp-5", widthClass: "w-sp-5" },
|
||||
{ token: "sp-6", widthClass: "w-sp-6" },
|
||||
{ token: "sp-8", widthClass: "w-sp-8" },
|
||||
] as const;
|
||||
const RADIUS_TOKENS = [
|
||||
{ token: "r-1", radiusClass: "rounded-r-1" },
|
||||
{ token: "r-2", radiusClass: "rounded-r-2" },
|
||||
{ token: "r-3", radiusClass: "rounded-r-3" },
|
||||
{ token: "r-pill", radiusClass: "rounded-r-pill" },
|
||||
] as const;
|
||||
const FONT_SIZE_TOKENS = [
|
||||
{ token: "fs-11", sizeClass: "text-fs-11" },
|
||||
{ token: "fs-12", sizeClass: "text-fs-12" },
|
||||
{ token: "fs-13", sizeClass: "text-fs-13" },
|
||||
{ token: "fs-14", sizeClass: "text-fs-14" },
|
||||
{ token: "fs-15", sizeClass: "text-fs-15" },
|
||||
{ token: "fs-17", sizeClass: "text-fs-17" },
|
||||
{ token: "fs-20", sizeClass: "text-fs-20" },
|
||||
] as const;
|
||||
const Z_INDEX_TOKENS = [
|
||||
{ name: "z-sticky", value: 10 },
|
||||
{ name: "z-backdrop", value: 40 },
|
||||
{ name: "z-modal", value: 50 },
|
||||
{ name: "z-toast", value: 60 },
|
||||
] as const;
|
||||
|
||||
function Section({
|
||||
title,
|
||||
note,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
note?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-t border-border py-sp-6 first:border-t-0 first:pt-0">
|
||||
<h2 className="text-fs-17 font-semibold text-ink">{title}</h2>
|
||||
{note && <p className="mt-sp-1 max-w-[70ch] text-fs-12 text-muted">{note}</p>}
|
||||
<div className="mt-sp-4 flex flex-wrap gap-sp-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-sp-2">
|
||||
<div className="flex min-h-[48px] items-center rounded-r-2 border border-border bg-surface p-sp-3">
|
||||
{children}
|
||||
</div>
|
||||
<span className="font-mono text-fs-11 text-faint">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fakeSession(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
cwd: "/home/dev/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: new Date().toISOString(),
|
||||
activity_since: new Date(Date.now() - 4 * 60_000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const NOOP_CARD_HANDLERS: Omit<SessionCardProps, "session" | "depth" | "now" | "highlighted" | "active" | "open" | "previewing" | "renaming"> = {
|
||||
onSelect: () => {},
|
||||
onOpenMenu: () => {},
|
||||
onSpawnSimilar: () => {},
|
||||
onKill: () => {},
|
||||
onRenameCommit: () => {},
|
||||
onRenameCancel: () => {},
|
||||
};
|
||||
|
||||
const CARD_SPECIMENS: Array<{ label: string; props: Omit<SessionCardProps, keyof typeof NOOP_CARD_HANDLERS> }> = [
|
||||
{
|
||||
label: "idle",
|
||||
props: {
|
||||
session: fakeSession({ command: "bash" }),
|
||||
depth: 0,
|
||||
now: Date.now(),
|
||||
highlighted: false,
|
||||
active: false,
|
||||
open: false,
|
||||
previewing: false,
|
||||
renaming: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "active, focused",
|
||||
props: {
|
||||
session: fakeSession({ activity: "Active", command: "vite dev" }),
|
||||
depth: 0,
|
||||
now: Date.now(),
|
||||
highlighted: false,
|
||||
active: true,
|
||||
open: true,
|
||||
previewing: false,
|
||||
renaming: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "awaiting-input",
|
||||
props: {
|
||||
session: fakeSession({ activity: "AwaitingInput", activity_since: new Date(Date.now() - 12_000).toISOString() }),
|
||||
depth: 0,
|
||||
now: Date.now(),
|
||||
highlighted: false,
|
||||
active: false,
|
||||
open: false,
|
||||
previewing: false,
|
||||
renaming: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "error",
|
||||
props: {
|
||||
session: fakeSession({ activity: "Error", activity_detail: "tests failing", activity_since: new Date(Date.now() - 7_200_000).toISOString() }),
|
||||
depth: 0,
|
||||
now: Date.now(),
|
||||
highlighted: false,
|
||||
active: false,
|
||||
open: false,
|
||||
previewing: false,
|
||||
renaming: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "previewing",
|
||||
props: {
|
||||
session: fakeSession({ command: "opencode" }),
|
||||
depth: 0,
|
||||
now: Date.now(),
|
||||
highlighted: false,
|
||||
active: false,
|
||||
open: false,
|
||||
previewing: true,
|
||||
renaming: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "exited",
|
||||
props: {
|
||||
session: fakeSession({ status: "Dead", command: "btop" }),
|
||||
depth: 0,
|
||||
now: Date.now(),
|
||||
highlighted: false,
|
||||
active: false,
|
||||
open: false,
|
||||
previewing: false,
|
||||
renaming: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ACTIVITY_STATES = Object.keys(STATE_WORDS) as Array<keyof typeof STATE_WORDS>;
|
||||
|
||||
export function DesignKit() {
|
||||
const { theme, palette, toggleTheme, setPalette } = useTheme();
|
||||
// "open" is deliberately display:none in the real app (.mv-pane's rule,
|
||||
// styles.css) — a streaming pane already shows it's connected — so start on
|
||||
// a state the kit page can actually show.
|
||||
const [attachState, setAttachState] = useState<"connecting" | "open" | "closed">("connecting");
|
||||
|
||||
return (
|
||||
// #root is a fixed-height, overflow-hidden flex column (main.css) built for
|
||||
// the app shell's own internal scrolling — this page needs its own scroll
|
||||
// container to match, or everything past the first viewport is clipped.
|
||||
<div className="h-full overflow-y-auto bg-bg text-ink">
|
||||
<header className="flex items-center justify-between border-b border-border px-sp-4 py-sp-3">
|
||||
<h1 className="text-fs-20 font-semibold">
|
||||
maestro<span className="text-accent">_</span> · design kit
|
||||
</h1>
|
||||
<div className="flex items-center gap-sp-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={toggleTheme}>
|
||||
{theme === "dark" ? "☀ light" : "☾ dark"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-sp-4 py-sp-4">
|
||||
<Section
|
||||
title="Colour palettes"
|
||||
note="6 palettes, picked via data-palette on <html> through ThemeContext. Switching one recolours every token below live."
|
||||
>
|
||||
{PALETTES.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPalette(p)}
|
||||
className={`flex flex-col items-center gap-sp-1 rounded-r-2 border p-sp-2 ${
|
||||
p === palette ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="h-sp-6 w-sp-8 rounded-r-1"
|
||||
style={{ background: PALETTE_ACCENT[p] }}
|
||||
/>
|
||||
<span className="font-mono text-fs-11 text-muted">{p}</span>
|
||||
</button>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Colour tokens" note="Current palette + theme, applied through var(--token).">
|
||||
{COLOR_TOKENS.map((t) => (
|
||||
<div key={t} className="flex flex-col items-start gap-sp-1">
|
||||
<div
|
||||
className="h-sp-8 w-sp-8 rounded-r-2 border border-border"
|
||||
style={{ background: `var(${t})` }}
|
||||
/>
|
||||
<span className="font-mono text-fs-11 text-faint">{t}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Typography" note="fontSize -> var(--fs-N), from tailwind.config.ts.">
|
||||
{FONT_SIZE_TOKENS.map(({ token, sizeClass }) => (
|
||||
<Specimen key={token} label={token}>
|
||||
<span className={`font-mono ${sizeClass}`}>Sessions</span>
|
||||
</Specimen>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Spacing scale" note="spacing -> var(--sp-N).">
|
||||
{SPACING_TOKENS.map(({ token, widthClass }) => (
|
||||
<div key={token} className="flex flex-col items-start gap-sp-1">
|
||||
<div className={`h-sp-3 bg-accent ${widthClass}`} />
|
||||
<span className="font-mono text-fs-11 text-faint">{token}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Border radii" note="borderRadius -> var(--r-N).">
|
||||
{RADIUS_TOKENS.map(({ token, radiusClass }) => (
|
||||
<div key={token} className="flex flex-col items-start gap-sp-1">
|
||||
<div className={`h-sp-6 w-sp-8 border-2 border-accent ${radiusClass}`} />
|
||||
<span className="font-mono text-fs-11 text-faint">{token}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Z-index scale" note="zIndex -> var(--z-*).">
|
||||
{Z_INDEX_TOKENS.map((z) => (
|
||||
<div key={z.name} className="flex items-baseline gap-sp-2 font-mono text-fs-12">
|
||||
<span className="text-muted">{z.name}</span>
|
||||
<span className="text-ink">{z.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Buttons" note="Terminal-native bracket chrome — .btn / .btn-primary / .btn-ghost / .btn-icon / .btn-danger.">
|
||||
<Specimen label="btn">
|
||||
<button type="button" className="btn">Cancel</button>
|
||||
</Specimen>
|
||||
<Specimen label="btn-primary">
|
||||
<button type="button" className="btn btn-primary">+ Spawn</button>
|
||||
</Specimen>
|
||||
<Specimen label="btn-ghost">
|
||||
<button type="button" className="btn btn-ghost">Dismiss</button>
|
||||
</Specimen>
|
||||
<Specimen label="btn-icon">
|
||||
<button type="button" className="btn btn-icon">✕</button>
|
||||
</Specimen>
|
||||
<Specimen label="btn-danger">
|
||||
<button type="button" className="btn btn-danger">Kill</button>
|
||||
</Specimen>
|
||||
<Specimen label="btn:disabled">
|
||||
<button type="button" className="btn btn-primary" disabled>+ Spawn</button>
|
||||
</Specimen>
|
||||
</Section>
|
||||
|
||||
<Section title="Activity rail" note="ActivityRail.tsx — one state per session card, colour-coded and never colour-only (title/aria-label carry the word).">
|
||||
{ACTIVITY_STATES.map((slug) => (
|
||||
<Specimen key={slug} label={`${slug} · ${STATE_WORDS[slug]}`}>
|
||||
<div className="relative h-[32px] w-[24px] overflow-hidden rounded-r-1 bg-surface-2">
|
||||
<ActivityRail activity={slug} />
|
||||
</div>
|
||||
</Specimen>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Session cards" note="SessionCard.tsx, docs/SIDEBAR.md states.">
|
||||
<div className="w-full max-w-sm divide-y divide-border rounded-r-2 border border-border bg-surface-2">
|
||||
{CARD_SPECIMENS.map(({ label, props }) => (
|
||||
<div key={label} className="relative">
|
||||
<SessionCard {...props} {...NOOP_CARD_HANDLERS} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Terminal chrome" note="attach-header + attach-body, as worn by TerminalPane/StructuredPane.">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-r-2 border border-border">
|
||||
<div className="attach-header">
|
||||
<PaneTitle
|
||||
sessionId="11111111-2222-3333-4444-555555555555"
|
||||
session={fakeSession({ command: "claude" })}
|
||||
/>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="attach-state"
|
||||
data-state={attachState}
|
||||
onClick={() =>
|
||||
setAttachState((s) => (s === "open" ? "connecting" : s === "connecting" ? "closed" : "open"))
|
||||
}
|
||||
title="Click to cycle"
|
||||
>
|
||||
{attachState}
|
||||
</button>
|
||||
<button type="button" className="btn btn-icon disconnect" title="Disconnect">
|
||||
⏻
|
||||
</button>
|
||||
</div>
|
||||
<div className="attach-body h-[120px] items-start">
|
||||
<span className="font-mono text-fs-13 text-muted">$ pnpm test</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CLOSE_DRAWER_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../uiEvents.js";
|
||||
import { TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
|
||||
import { Drawer } from "./Drawer.js";
|
||||
|
||||
/** Force the mobile media query on, whatever happy-dom's viewport says
|
||||
* (mirrors ../layout/MultiView's own `forceMobile` in multiview.test.ts). */
|
||||
function forceMobile(matches: boolean): void {
|
||||
vi.spyOn(window, "matchMedia").mockImplementation(
|
||||
(query: string) =>
|
||||
({
|
||||
matches: query.includes("859") && matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
}
|
||||
|
||||
function shell(): HTMLElement {
|
||||
return document.querySelector(".app-shell") as HTMLElement;
|
||||
}
|
||||
|
||||
function touch(type: "touchstart" | "touchend", x: number, y: number): void {
|
||||
const touches = [{ clientX: x, clientY: y }];
|
||||
const event = new Event(type, { bubbles: true }) as unknown as {
|
||||
touches: typeof touches;
|
||||
changedTouches: typeof touches;
|
||||
};
|
||||
event.touches = touches;
|
||||
event.changedTouches = touches;
|
||||
act(() => {
|
||||
document.dispatchEvent(event as unknown as Event);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => forceMobile(true));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function openViaToggle(): void {
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Drawer (#35)", () => {
|
||||
it("toggles open on the toggle event, off on desktop by class alone", () => {
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
openViaToggle();
|
||||
expect(shell().classList.contains("drawer-open")).toBe(true);
|
||||
});
|
||||
|
||||
it("closes when a session row is tapped", () => {
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
openViaToggle();
|
||||
fireEvent.click(document.querySelector(".tree-leaf") as HTMLElement);
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes when a spawn is requested", () => {
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
openViaToggle();
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
});
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes when spawn similar is requested", () => {
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
openViaToggle();
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
|
||||
});
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes on the close-drawer event (after spawn lands)", () => {
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
openViaToggle();
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
|
||||
});
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes on a backdrop click", () => {
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
openViaToggle();
|
||||
fireEvent.click(document.querySelector(".mv-backdrop") as HTMLElement);
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("opens on a rightward swipe and closes on a leftward one, only on mobile", () => {
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
touch("touchstart", 0, 100);
|
||||
touch("touchend", 100, 100);
|
||||
expect(shell().classList.contains("drawer-open")).toBe(true);
|
||||
|
||||
touch("touchstart", 100, 100);
|
||||
touch("touchend", 0, 100);
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores swipes on desktop", () => {
|
||||
forceMobile(false);
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
touch("touchstart", 0, 100);
|
||||
touch("touchend", 100, 100);
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a mostly-vertical or too-short gesture", () => {
|
||||
render(
|
||||
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||
<div>content</div>
|
||||
</Drawer>,
|
||||
);
|
||||
touch("touchstart", 0, 0);
|
||||
touch("touchend", 10, 100);
|
||||
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// React port of ../../drawer.ts (#35): the mobile off-canvas sidebar. On
|
||||
// desktop the `drawer-open` class is inert — CSS gates the drawer styling to
|
||||
// narrow viewports (styles.css, `.app-shell`).
|
||||
//
|
||||
// Anything that puts a new view in the content area must close the drawer, or
|
||||
// it renders behind the sidebar and the tap reads as a dead button — this
|
||||
// listens for the same TOGGLE_DRAWER_EVENT/OPEN_SPAWN_EVENT/SPAWN_SIMILAR_EVENT
|
||||
// the rest of the shell already raises, so it needs no wiring from callers.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { CLOSE_DRAWER_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT, TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
|
||||
|
||||
export interface DrawerProps {
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Drawer({ sidebar, children, className }: DrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onToggle = () => setOpen((o) => !o);
|
||||
const onClose = () => setOpen(false);
|
||||
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, onClose);
|
||||
document.addEventListener(SPAWN_SIMILAR_EVENT, onClose);
|
||||
document.addEventListener(CLOSE_DRAWER_EVENT, onClose);
|
||||
return () => {
|
||||
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
||||
document.removeEventListener(OPEN_SPAWN_EVENT, onClose);
|
||||
document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose);
|
||||
document.removeEventListener(CLOSE_DRAWER_EVENT, onClose);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Horizontal swipes drive the drawer (right opens, left closes) rather than
|
||||
// the browser's back/forward navigation. Only a clearly horizontal gesture
|
||||
// past a threshold counts, so vertical terminal scrolling (which owns its
|
||||
// own touch handler) is untouched.
|
||||
useEffect(() => {
|
||||
const mobileMq = window.matchMedia("(max-width: 859px)");
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let tracking = false;
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
tracking = mobileMq.matches && e.touches.length === 1;
|
||||
if (tracking) {
|
||||
sx = e.touches[0].clientX;
|
||||
sy = e.touches[0].clientY;
|
||||
}
|
||||
};
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - sx;
|
||||
const dy = t.clientY - sy;
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||
setOpen((o) => {
|
||||
if (dx > 0 && !o) return true;
|
||||
if (dx < 0 && o) return false;
|
||||
return o;
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
document.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener("touchstart", onTouchStart);
|
||||
document.removeEventListener("touchend", onTouchEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`app-shell flex h-full min-h-0 ${open ? "drawer-open" : ""} ${className ?? ""}`}>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="sidebar flex h-full min-h-0 flex-col bg-surface"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest(".tree-leaf")) setOpen(false);
|
||||
}}
|
||||
>
|
||||
{sidebar}
|
||||
</div>
|
||||
<section className="content min-h-0 flex-1">{children}</section>
|
||||
<div className="mv-backdrop" onClick={() => setOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { leaves } from "../../layout.js";
|
||||
import { createLayoutStore } from "../../stores/layoutStore.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import { MultiView } from "./MultiView.js";
|
||||
|
||||
vi.mock("../terminal/TerminalPane.js", () => ({
|
||||
TerminalPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`terminal-${sessionId}`} />,
|
||||
}));
|
||||
|
||||
vi.mock("../structured/StructuredPane.js", () => ({
|
||||
StructuredPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`structured-${sessionId}`} />,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function store() {
|
||||
return createLayoutStore({
|
||||
load: () => null,
|
||||
save: vi.fn(),
|
||||
scheduler: { setTimer: () => 1, clearTimer: () => {} },
|
||||
});
|
||||
}
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/n",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("MultiView", () => {
|
||||
it("shows the empty state when the active tab has no panes", () => {
|
||||
const s = store();
|
||||
render(<MultiView store={s} />);
|
||||
expect(screen.getByText(/No session open/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a pane per leaf, dispatching to the terminal or structured body", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().openSessionBeside("s2");
|
||||
const sessions = new Map([
|
||||
["s1", session("s1")],
|
||||
["s2", session("s2", { view: "Structured" })],
|
||||
]);
|
||||
render(<MultiView store={s} sessions={sessions} />);
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
expect(screen.getByTestId("structured-s2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("split right adds a second pane to the tree", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
fireEvent.click(screen.getByTitle("Split right"));
|
||||
expect(screen.getAllByText("Empty pane")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("close pane removes it", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
const leafId = leaves(s.getState().layout.tabs[0].root)[0].id;
|
||||
s.getState().splitFocused(leafId, "row"); // adds an empty leaf beside s1
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
// splitFocused(after: true) puts the fresh empty leaf second — close it.
|
||||
fireEvent.click(screen.getAllByTitle("Close pane")[1]);
|
||||
expect(screen.queryByText("Empty pane")).toBeNull();
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("prunes a leaf whose session no longer exists, once the live set is known", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().openSessionBeside("dead");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
expect(screen.queryByTestId("terminal-dead")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not touch the layout while the live set is still unknown", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} />);
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("preview mode (docs/PREVIEW.md)", () => {
|
||||
it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
// s1's own tab is still in the strip — the preview never touched it.
|
||||
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
|
||||
const pane = document.querySelector('[data-session-id="s9"]');
|
||||
expect(pane).toBeTruthy();
|
||||
expect(pane!.classList.contains("is-preview")).toBe(true);
|
||||
expect(screen.getByText("preview")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("puts the layout back byte for byte once the preview ends", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
const before = JSON.stringify(s.getState().layout);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
act(() => s.getState().previewSession(null));
|
||||
|
||||
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||
expect(screen.queryByText("preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not grab the keyboard while previewing", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||
expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
|
||||
// TerminalPane's `focus` prop is verified by TerminalPane's own tests;
|
||||
// here the preview must never end up as the active-tab's accent pane.
|
||||
expect(pane.className).not.toContain("border-accent");
|
||||
});
|
||||
|
||||
it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||
fireEvent.pointerDown(pane);
|
||||
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(screen.queryByText("preview")).toBeNull();
|
||||
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
|
||||
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
|
||||
// single-pane collapse stay with whichever issue wires this into the shell
|
||||
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { activeTab, type Dir, type Node } from "../../layout.js";
|
||||
import { usePreview } from "../../hooks/usePreview.js";
|
||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||
import type { Session, Uuid } from "../../types.js";
|
||||
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
||||
import { PaneTitle } from "./PaneTitle.js";
|
||||
import { TabBar } from "./TabBar.js";
|
||||
|
||||
/** Reaching the focused pane from outside the tree — what the shell's
|
||||
* leader chords need (focus.toggle, the literal-byte escape hatches). */
|
||||
export interface MultiViewHandle {
|
||||
/** Hand the keyboard to the focused pane's terminal (or composer). */
|
||||
focusPane(): void;
|
||||
/** Type into the focused pane. A structured pane has no PTY and ignores it. */
|
||||
sendInput(data: string): void;
|
||||
}
|
||||
|
||||
export interface MultiViewProps {
|
||||
store: LayoutStoreApi;
|
||||
/** Live sessions, keyed by id — drives pane titles and (once known) boot
|
||||
* reconciliation. Passing `undefined` defers reconciliation. */
|
||||
sessions?: ReadonlyMap<Uuid, Session>;
|
||||
onDisconnect?: (sessionId: Uuid) => void;
|
||||
emptyState?: ReactNode;
|
||||
}
|
||||
|
||||
export const MultiView = forwardRef<MultiViewHandle, MultiViewProps>(function MultiView(
|
||||
{ store, sessions, onDisconnect, emptyState },
|
||||
ref,
|
||||
) {
|
||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
||||
const tab = activeTab(layout);
|
||||
|
||||
// Live pane handles, keyed by leaf. Read at call time (not render time) so
|
||||
// the chords always reach whatever is focused right now.
|
||||
const panes = useRef(new Map<string, PaneContainerHandle>()).current;
|
||||
const focusedLeafRef = useRef<string | null>(null);
|
||||
focusedLeafRef.current = tab.focusedLeafId;
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusPane: () => panes.get(focusedLeafRef.current ?? "")?.focusTerminal(),
|
||||
sendInput: (data: string) => panes.get(focusedLeafRef.current ?? "")?.sendInput(data),
|
||||
}),
|
||||
[panes],
|
||||
);
|
||||
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
|
||||
const isPreviewing = previewedSessionId !== null;
|
||||
|
||||
const reconciledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (reconciledRef.current || !sessions) return;
|
||||
reconciledRef.current = true;
|
||||
store.getState().reconcile(new Set(sessions.keys()));
|
||||
}, [sessions, store]);
|
||||
|
||||
return (
|
||||
<div className="mv flex h-full min-h-0 flex-col">
|
||||
<TabBar store={store} />
|
||||
<div className="mv-canvas relative min-h-0 flex-1">
|
||||
{tab.root ? (
|
||||
<SplitNode
|
||||
node={tab.root}
|
||||
tab={tab}
|
||||
panes={panes}
|
||||
store={store}
|
||||
sessions={sessions}
|
||||
onDisconnect={onDisconnect}
|
||||
previewedSessionId={previewedSessionId}
|
||||
commitPreview={commitPreview}
|
||||
/>
|
||||
) : (
|
||||
(emptyState ?? (
|
||||
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
||||
No session open — pick one from the sidebar, or spawn a new one.
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isPreviewing && (
|
||||
<>
|
||||
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
|
||||
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
|
||||
preview
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface TreeProps {
|
||||
tab: { id: string; focusedLeafId: string | null };
|
||||
/** Pane handles by leaf id — each `Pane` registers and unregisters its own. */
|
||||
panes: Map<string, PaneContainerHandle>;
|
||||
store: LayoutStoreApi;
|
||||
sessions?: ReadonlyMap<Uuid, Session>;
|
||||
onDisconnect?: (sessionId: Uuid) => void;
|
||||
previewedSessionId: Uuid | null;
|
||||
commitPreview: (sessionId: Uuid) => void;
|
||||
}
|
||||
|
||||
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
||||
if (node.kind === "leaf") {
|
||||
return <Pane leaf={node} isFocused={rest.tab.focusedLeafId === node.id} {...rest} />;
|
||||
}
|
||||
return <SplitContainer node={node} {...rest} />;
|
||||
}
|
||||
|
||||
function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { kind: "split" }> }) {
|
||||
const slotA = useRef<HTMLDivElement | null>(null);
|
||||
const slotB = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const onDividerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const divider = e.currentTarget;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
divider.setPointerCapture(e.pointerId);
|
||||
const rect = container.getBoundingClientRect();
|
||||
const move = (ev: PointerEvent) => {
|
||||
const frac =
|
||||
node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height;
|
||||
node.ratio = Math.max(0.1, Math.min(0.9, frac));
|
||||
if (slotA.current) slotA.current.style.flexGrow = String(node.ratio);
|
||||
if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio);
|
||||
};
|
||||
const up = (ev: PointerEvent) => {
|
||||
try {
|
||||
divider.releasePointerCapture(ev.pointerId);
|
||||
} catch {
|
||||
// already released
|
||||
}
|
||||
divider.removeEventListener("pointermove", move);
|
||||
divider.removeEventListener("pointerup", up);
|
||||
rest.store.getState().commitLayout();
|
||||
};
|
||||
divider.addEventListener("pointermove", move);
|
||||
divider.addEventListener("pointerup", up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`mv-split flex h-full min-h-0 w-full ${node.dir === "row" ? "flex-row" : "flex-col"}`}
|
||||
>
|
||||
<div ref={slotA} className="mv-slot min-h-0 min-w-0" style={{ flex: `${node.ratio} 1 0` }}>
|
||||
<SplitNode node={node.a} {...rest} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"mv-divider flex-none bg-border-strong hover:bg-accent " +
|
||||
(node.dir === "row" ? "w-[3px] cursor-col-resize" : "h-[3px] cursor-row-resize")
|
||||
}
|
||||
onPointerDown={onDividerPointerDown}
|
||||
/>
|
||||
<div ref={slotB} className="mv-slot min-h-0 min-w-0" style={{ flex: `${1 - node.ratio} 1 0` }}>
|
||||
<SplitNode node={node.b} {...rest} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pane({
|
||||
leaf,
|
||||
isFocused,
|
||||
panes,
|
||||
store,
|
||||
sessions,
|
||||
onDisconnect,
|
||||
previewedSessionId,
|
||||
commitPreview,
|
||||
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
||||
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
||||
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
|
||||
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mv-pane flex h-full min-h-0 min-w-0 flex-col" +
|
||||
(isFocused ? " is-focused" : "") +
|
||||
(isPreview ? " is-preview" : "")
|
||||
}
|
||||
data-leaf-id={leaf.id}
|
||||
data-session-id={leaf.sessionId ?? undefined}
|
||||
onPointerDownCapture={() => {
|
||||
// Clicking into a previewed pane is the operator choosing it
|
||||
// (docs/PREVIEW.md).
|
||||
if (isPreview) commitPreview(leaf.sessionId!);
|
||||
store.getState().focusLeaf(leaf.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
||||
>
|
||||
{leaf.sessionId ? (
|
||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||
) : (
|
||||
<span className="text-faint">Empty pane</span>
|
||||
)}
|
||||
<div className="mv-pane-tools flex items-center gap-sp-1">
|
||||
{leaf.sessionId && onDisconnect && (
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Disconnect"
|
||||
onClick={() => onDisconnect(leaf.sessionId!)}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Split right"
|
||||
onClick={() => splitFocused("row")}
|
||||
>
|
||||
▥
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Split down"
|
||||
onClick={() => splitFocused("col")}
|
||||
>
|
||||
▤
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Close pane"
|
||||
onClick={() => store.getState().closePane(leaf.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mv-pane-host min-h-0 flex-1">
|
||||
{leaf.sessionId ? (
|
||||
<PaneContainer
|
||||
ref={(handle) => {
|
||||
if (handle) panes.set(leaf.id, handle);
|
||||
else panes.delete(leaf.id);
|
||||
}}
|
||||
sessionId={leaf.sessionId}
|
||||
session={session}
|
||||
focus={isFocused && previewedSessionId === null}
|
||||
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
||||
/>
|
||||
) : (
|
||||
<div className="mv-pane-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
||||
Empty pane — pick a session from the sidebar, or spawn one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { PaneContainer } from "./PaneContainer.js";
|
||||
|
||||
vi.mock("../terminal/TerminalPane.js", () => ({
|
||||
TerminalPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
|
||||
<div data-testid="terminal-pane" data-session-id={sessionId} data-focus={String(focus)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../structured/StructuredPane.js", () => ({
|
||||
StructuredPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
|
||||
<div data-testid="structured-pane" data-session-id={sessionId} data-focus={String(focus)} />
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function session(over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/home/n",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("PaneContainer", () => {
|
||||
it("renders TerminalPane when the session has no view (defaults to Terminal)", () => {
|
||||
render(<PaneContainer sessionId="s1" session={session()} />);
|
||||
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
|
||||
expect(screen.queryByTestId("structured-pane")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders TerminalPane when the session isn't known yet", () => {
|
||||
render(<PaneContainer sessionId="s1" />);
|
||||
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders StructuredPane for a Structured session", () => {
|
||||
render(<PaneContainer sessionId="s1" session={session({ view: "Structured" })} focus={false} />);
|
||||
const pane = screen.getByTestId("structured-pane");
|
||||
expect(pane.dataset.sessionId).toBe("s1");
|
||||
expect(pane.dataset.focus).toBe("false");
|
||||
expect(screen.queryByTestId("terminal-pane")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Dispatches a leaf's session to the right body — TerminalPane for a PTY
|
||||
// session, StructuredPane for an ACP one (session.view, #133/#140/#249).
|
||||
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import type { Session, Uuid } from "../../types.js";
|
||||
import { StructuredPane, type StructuredPaneHandle } from "../structured/StructuredPane.js";
|
||||
import { TerminalPane, type TerminalPaneHandle } from "../terminal/TerminalPane.js";
|
||||
|
||||
export interface PaneContainerHandle {
|
||||
focusTerminal(): void;
|
||||
/** No-op for a structured pane — it has no PTY (pane.ts's PaneConnection contract). */
|
||||
sendInput(data: string): void;
|
||||
}
|
||||
|
||||
export interface PaneContainerProps {
|
||||
sessionId: Uuid;
|
||||
session?: Session;
|
||||
/** Grabs the keyboard once connected. A sidebar preview passes `false`. */
|
||||
focus?: boolean;
|
||||
onClosed?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PaneContainer = forwardRef<PaneContainerHandle, PaneContainerProps>(function PaneContainer(
|
||||
{ sessionId, session, focus, onClosed, className },
|
||||
ref,
|
||||
) {
|
||||
const terminalRef = useRef<TerminalPaneHandle>(null);
|
||||
const structuredRef = useRef<StructuredPaneHandle>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusTerminal: () => {
|
||||
terminalRef.current?.focusTerminal();
|
||||
structuredRef.current?.focusComposer();
|
||||
},
|
||||
sendInput: (data: string) => terminalRef.current?.sendInput(data),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return session?.view === "Structured" ? (
|
||||
<StructuredPane ref={structuredRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
|
||||
) : (
|
||||
<TerminalPane ref={terminalRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { PaneTitle } from "./PaneTitle.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function session(over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/home/n",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("PaneTitle", () => {
|
||||
it("falls back to a short session id when the session isn't known yet", () => {
|
||||
render(<PaneTitle sessionId="abcdef12-3456-7890-abcd-ef1234567890" />);
|
||||
expect(screen.getByText(/abcdef12/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the name alone when the command matches it", () => {
|
||||
render(<PaneTitle sessionId="s1" session={session({ command: "claude", metadata: { name: "claude" } })} />);
|
||||
expect(screen.getByText("claude")).toBeTruthy();
|
||||
expect(document.querySelector(".at-cmd")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the command alongside a custom name", () => {
|
||||
render(
|
||||
<PaneTitle sessionId="s1" session={session({ command: "hx src/main.rs", metadata: { name: "editor" } })} />,
|
||||
);
|
||||
expect(screen.getByText("editor")).toBeTruthy();
|
||||
expect(document.querySelector(".at-cmd")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("never injects markup from a session name", () => {
|
||||
render(<PaneTitle sessionId="s1" session={session({ metadata: { name: "<img onerror=x>" } })} />);
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
expect(screen.getByText("<img onerror=x>")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// React port of paneTitle.ts: a pane header's session identity — the name,
|
||||
// then the command as its tool mark when recognised, same as the sidebar
|
||||
// card's own CommandFragment (SessionCard.tsx). JSX escapes text nodes, so
|
||||
// unlike the vanilla string-builder this needs no manual HTML escaping.
|
||||
|
||||
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
|
||||
import { sessionName, type Session, type Uuid } from "../../types.js";
|
||||
|
||||
export interface PaneTitleProps {
|
||||
sessionId: Uuid;
|
||||
session?: Session;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
|
||||
if (!session) {
|
||||
return (
|
||||
<span className={className ?? "attach-title"} title={sessionId}>
|
||||
session <span className="sid">{sessionId.slice(0, 8)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const name = sessionName(session);
|
||||
const showCommand = Boolean(session.command) && session.command !== name;
|
||||
const { tool, program } = splitCommand(session.command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
|
||||
return (
|
||||
<span className={className ?? "attach-title"} title={`${name} · ${session.command} (${sessionId})`}>
|
||||
<span className="at-name">{name}</span>
|
||||
{showCommand && (
|
||||
<span className="at-cmd">
|
||||
{icon ? <span className="contents" dangerouslySetInnerHTML={{ __html: icon }} /> : <span>{program}</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user