Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f801b12e87 |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git fetch:*)",
|
|
||||||
"Bash(git merge:*)",
|
|
||||||
"Bash(npx tsc:*)",
|
|
||||||
"Bash(npm run build:*)",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(./node_modules/.bin/tsc:*)",
|
|
||||||
"Bash(npm test:*)",
|
|
||||||
"Bash(cargo check:*)",
|
|
||||||
"Bash(cargo clippy:*)",
|
|
||||||
"Bash(cargo test:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -92,12 +92,6 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
|
|||||||
the two matchers must agree on, and the fixture table they are tested against.
|
the two matchers must agree on, and the fixture table they are tested against.
|
||||||
Read it before changing either bar or either matcher.
|
Read it before changing either bar or either matcher.
|
||||||
|
|
||||||
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
|
|
||||||
run as a daemon child over ACP instead of in a tmux pane) — the eight
|
|
||||||
transcript row kinds, the two projections and their cursor/replay semantics,
|
|
||||||
the handshake and declined capabilities, and the approval nonce rules. Read
|
|
||||||
it before changing anything that renders a structured session.
|
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
Generated
+56
-749
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ repository = "https://git.naps.pt/naps62/maestro"
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
|
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
@@ -18,7 +17,6 @@ crossterm = { version = "0.28", features = ["event-stream"] }
|
|||||||
dirs = "5"
|
dirs = "5"
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
maestro-acp = { path = "crates/maestro-acp" }
|
|
||||||
maestro-config = { path = "crates/maestro-config" }
|
maestro-config = { path = "crates/maestro-config" }
|
||||||
# `default-features = false` so each consumer opts into `haiku-autoname`
|
# `default-features = false` so each consumer opts into `haiku-autoname`
|
||||||
# through its own feature of the same name (#131); a workspace entry that
|
# through its own feature of the same name (#131); a workspace entry that
|
||||||
@@ -40,11 +38,9 @@ tempfile = "3"
|
|||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
||||||
tokio-tungstenite = "0.21"
|
tokio-tungstenite = "0.21"
|
||||||
tokio-util = { version = "0.7", features = ["compat"] }
|
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
unicode-width = "0.1"
|
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
ratatui = "0.28"
|
ratatui = "0.28"
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "maestro-acp"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
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
|
|
||||||
tokio-util.workspace = true
|
|
||||||
tracing.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile.workspace = true
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,262 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""A deterministic stand-in for `claude-code-acp` (#134).
|
|
||||||
|
|
||||||
Speaks just enough JSON-RPC over stdio for the daemon's handshake:
|
|
||||||
answers `initialize` and `session/new`, logs every method it receives, and
|
|
||||||
exits on SIGTERM. Tests point `PATH` at a directory holding this file under
|
|
||||||
the adapter's name, so the real adapter (which needs Claude auth and is
|
|
||||||
slow) never runs in the deterministic suite.
|
|
||||||
|
|
||||||
Environment knobs, all optional:
|
|
||||||
|
|
||||||
FAKE_ACP_LOG append one line per event to this file:
|
|
||||||
start pid=<pid> cwd=<cwd>
|
|
||||||
env <NAME>=<value> (for FAKE_ACP_ECHO_ENV)
|
|
||||||
caps <json of clientCapabilities>
|
|
||||||
recv <method>
|
|
||||||
signal SIGTERM
|
|
||||||
FAKE_ACP_ECHO_ENV name of one env var to log at start
|
|
||||||
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)
|
|
||||||
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
|
|
||||||
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
|
|
||||||
FAKE_ACP_CANCEL_SETTLES=1 complete the outstanding prompt on cancel
|
|
||||||
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
|
|
||||||
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
|
|
||||||
notifications ("replayed <i>") before answering,
|
|
||||||
the way a real agent streams history back
|
|
||||||
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
|
|
||||||
FAKE_ACP_DELAY_PROMPT_MS=n wait before answering `session/prompt`
|
|
||||||
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
|
|
||||||
|
|
||||||
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
|
|
||||||
counting from 1) and logs `emit tick <n>`, so a test can make the agent
|
|
||||||
speak on demand (#135).
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
LOG = os.environ.get("FAKE_ACP_LOG")
|
|
||||||
|
|
||||||
|
|
||||||
def log(line):
|
|
||||||
if not LOG:
|
|
||||||
return
|
|
||||||
with open(LOG, "a") as f:
|
|
||||||
f.write(line + "\n")
|
|
||||||
f.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def send(obj):
|
|
||||||
sys.stdout.write(json.dumps(obj) + "\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def on_sigterm(_signum, _frame):
|
|
||||||
log("signal SIGTERM")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, on_sigterm)
|
|
||||||
|
|
||||||
session_id = f"fake-{os.getpid()}"
|
|
||||||
ticks = 0
|
|
||||||
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):
|
|
||||||
return {"jsonrpc": "2.0", "method": "session/update", "params": {
|
|
||||||
"sessionId": session_id,
|
|
||||||
"update": {"sessionUpdate": "agent_message_chunk",
|
|
||||||
"content": {"type": "text", "text": text}},
|
|
||||||
}}
|
|
||||||
|
|
||||||
|
|
||||||
def on_sigusr1(_signum, _frame):
|
|
||||||
global ticks
|
|
||||||
ticks += 1
|
|
||||||
send(chunk(f"tick {ticks}"))
|
|
||||||
log(f"emit tick {ticks}")
|
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGUSR1, on_sigusr1)
|
|
||||||
|
|
||||||
log(f"start pid={os.getpid()} cwd={os.getcwd()}")
|
|
||||||
echo = os.environ.get("FAKE_ACP_ECHO_ENV")
|
|
||||||
if echo:
|
|
||||||
log(f"env {echo}={os.environ.get(echo, '')}")
|
|
||||||
|
|
||||||
for raw in sys.stdin:
|
|
||||||
raw = raw.strip()
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
msg = json.loads(raw)
|
|
||||||
method = msg.get("method")
|
|
||||||
if method is None:
|
|
||||||
# A response to something we sent; nothing to do.
|
|
||||||
if msg.get("id") == pending_permission_id:
|
|
||||||
log("permission response " + json.dumps(msg.get("result"), sort_keys=True))
|
|
||||||
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
|
||||||
"result": {"stopReason": "end_turn"}})
|
|
||||||
pending_prompt_id = None
|
|
||||||
pending_permission_id = None
|
|
||||||
continue
|
|
||||||
log(f"recv {method}")
|
|
||||||
rid = msg.get("id")
|
|
||||||
params = msg.get("params") or {}
|
|
||||||
|
|
||||||
if method == "initialize":
|
|
||||||
log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True))
|
|
||||||
if os.environ.get("FAKE_ACP_INIT_FAIL") == "1":
|
|
||||||
send({"jsonrpc": "2.0", "id": rid,
|
|
||||||
"error": {"code": -32000, "message": "fake init failure"}})
|
|
||||||
continue
|
|
||||||
send({"jsonrpc": "2.0", "id": rid, "result": {
|
|
||||||
"protocolVersion": params.get("protocolVersion", 1),
|
|
||||||
"agentCapabilities": {
|
|
||||||
"loadSession": os.environ.get("FAKE_ACP_NO_LOAD") != "1"},
|
|
||||||
"agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"},
|
|
||||||
"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": settings_response(True)})
|
|
||||||
send(chunk("hello from fake"))
|
|
||||||
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
|
||||||
sys.exit(0)
|
|
||||||
elif method == "session/load":
|
|
||||||
session_id = params.get("sessionId")
|
|
||||||
log(f"recv session/load sessionId={session_id}")
|
|
||||||
if os.environ.get("FAKE_ACP_LOAD_FAIL") == "1":
|
|
||||||
send({"jsonrpc": "2.0", "id": rid,
|
|
||||||
"error": {"code": -32000, "message": "fake load failure"}})
|
|
||||||
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": 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"))
|
|
||||||
if (os.environ.get("FAKE_ACP_CANCEL_SETTLES") == "1"
|
|
||||||
and pending_prompt_id is not None):
|
|
||||||
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
|
||||||
"result": {"stopReason": "cancelled"}})
|
|
||||||
pending_prompt_id = None
|
|
||||||
pending_permission_id = None
|
|
||||||
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
|
|
||||||
sys.exit(0)
|
|
||||||
elif method == "session/prompt":
|
|
||||||
if os.environ.get("FAKE_ACP_PERMISSION_AFTER_CANCEL") == "1":
|
|
||||||
if pending_prompt_id is None:
|
|
||||||
pending_prompt_id = rid
|
|
||||||
delayed_permission = True
|
|
||||||
continue
|
|
||||||
if delayed_permission:
|
|
||||||
delayed_permission = False
|
|
||||||
pending_permission_id = "permission-1"
|
|
||||||
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
|
||||||
"method": "session/request_permission", "params": {
|
|
||||||
"sessionId": session_id,
|
|
||||||
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
|
||||||
"options": [
|
|
||||||
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
|
||||||
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
|
||||||
]
|
|
||||||
}})
|
|
||||||
continue
|
|
||||||
if os.environ.get("FAKE_ACP_REQUEST_PERMISSION") == "1":
|
|
||||||
pending_prompt_id = rid
|
|
||||||
pending_permission_id = "permission-1"
|
|
||||||
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
|
||||||
"method": "session/request_permission", "params": {
|
|
||||||
"sessionId": session_id,
|
|
||||||
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
|
||||||
"options": [
|
|
||||||
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
|
||||||
{"optionId": "session", "name": "Allow session", "kind": "allow_always"},
|
|
||||||
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
|
||||||
]
|
|
||||||
}})
|
|
||||||
continue
|
|
||||||
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
|
|
||||||
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
|
|
||||||
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
|
|
||||||
prompt_failures += 1
|
|
||||||
send({"jsonrpc": "2.0", "id": rid,
|
|
||||||
"error": {"code": -32000, "message": "fake prompt failure"}})
|
|
||||||
else:
|
|
||||||
send({"jsonrpc": "2.0", "id": rid,
|
|
||||||
"result": {"stopReason": "end_turn"}})
|
|
||||||
elif rid is not None:
|
|
||||||
send({"jsonrpc": "2.0", "id": rid,
|
|
||||||
"error": {"code": -32601, "message": f"method not found: {method}"}})
|
|
||||||
|
|
||||||
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
|
|
||||||
while True:
|
|
||||||
signal.pause()
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
|
|
||||||
//! 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::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn fake_agent() -> PathBuf {
|
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fake_acp_agent.py")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A temp `bin/` holding the fake agent under the adapter's name, plus a
|
|
||||||
/// log file the agent appends to. `PATH` for the spawn is just that dir.
|
|
||||||
struct Fake {
|
|
||||||
dir: TempDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Fake {
|
|
||||||
fn new() -> Self {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_path(&self) -> PathBuf {
|
|
||||||
self.dir.path().join("agent.log")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log(&self) -> String {
|
|
||||||
std::fs::read_to_string(self.log_path()).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fake's `bin/` first, so it shadows any real adapter, then the
|
|
||||||
/// process `PATH` so the script's `python3` still resolves.
|
|
||||||
fn env(&self) -> HashMap<String, String> {
|
|
||||||
let path = format!(
|
|
||||||
"{}:{}",
|
|
||||||
self.dir.path().join("bin").display(),
|
|
||||||
std::env::var("PATH").unwrap_or_default()
|
|
||||||
);
|
|
||||||
HashMap::from([
|
|
||||||
("PATH".to_string(), path),
|
|
||||||
(
|
|
||||||
"FAKE_ACP_LOG".to_string(),
|
|
||||||
self.log_path().to_string_lossy().into_owned(),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config(&self, cwd: &Path) -> SpawnConfig {
|
|
||||||
let mut config = SpawnConfig::new(cwd, self.env());
|
|
||||||
config.handshake_timeout = Duration::from_secs(10);
|
|
||||||
config
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_log(&self, needle: &str) {
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
while Instant::now() < deadline {
|
|
||||||
if self.log().contains(needle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
||||||
}
|
|
||||||
panic!("log never contained {needle:?}:\n{}", self.log());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pid(&self) -> u32 {
|
|
||||||
self.log()
|
|
||||||
.lines()
|
|
||||||
.find_map(|line| line.strip_prefix("start pid="))
|
|
||||||
.and_then(|rest| rest.split(' ').next())
|
|
||||||
.and_then(|pid| pid.parse().ok())
|
|
||||||
.expect("pid in log")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_exit(&self) {
|
|
||||||
let pid = self.pid();
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
while pid_alive(pid) && Instant::now() < deadline {
|
|
||||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
||||||
}
|
|
||||||
assert!(!pid_alive(pid), "child {pid} still alive");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pid_alive(pid: u32) -> bool {
|
|
||||||
Path::new(&format!("/proc/{pid}")).exists()
|
|
||||||
&& std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
|
||||||
.map(|s| !s.contains(") Z "))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn spawn_runs_adapter_in_cwd_with_env_and_restrictive_caps() {
|
|
||||||
let fake = Fake::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let mut config = fake.config(cwd.path());
|
|
||||||
config
|
|
||||||
.env
|
|
||||||
.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
|
||||||
config
|
|
||||||
.env
|
|
||||||
.insert("MAESTRO_SESSION_ID".into(), "sess-123".into());
|
|
||||||
|
|
||||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
|
||||||
assert!(session.session_id().starts_with("fake-"));
|
|
||||||
assert!(pid_alive(session.pid()));
|
|
||||||
|
|
||||||
let log = fake.log();
|
|
||||||
let cwd_canon = cwd.path().canonicalize().unwrap();
|
|
||||||
assert!(
|
|
||||||
log.contains(&format!("cwd={}", cwd_canon.display())),
|
|
||||||
"child cwd missing from log:\n{log}"
|
|
||||||
);
|
|
||||||
assert!(log.contains("env MAESTRO_SESSION_ID=sess-123"), "{log}");
|
|
||||||
assert!(log.contains("recv initialize"), "{log}");
|
|
||||||
assert!(log.contains("recv session/new"), "{log}");
|
|
||||||
let caps = log
|
|
||||||
.lines()
|
|
||||||
.find_map(|l| l.strip_prefix("caps "))
|
|
||||||
.expect("caps line");
|
|
||||||
let caps: serde_json::Value = serde_json::from_str(caps).unwrap();
|
|
||||||
assert_eq!(caps["fs"]["readTextFile"], false);
|
|
||||||
assert_eq!(caps["fs"]["writeTextFile"], false);
|
|
||||||
assert_eq!(caps["terminal"], false);
|
|
||||||
|
|
||||||
session
|
|
||||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn missing_adapter_names_the_install_command() {
|
|
||||||
let empty = TempDir::new().unwrap();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let env = HashMap::from([(
|
|
||||||
"PATH".to_string(),
|
|
||||||
empty.path().to_string_lossy().into_owned(),
|
|
||||||
)]);
|
|
||||||
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
|
|
||||||
.await
|
|
||||||
.expect_err("no adapter on PATH");
|
|
||||||
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();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let session = maestro_acp::spawn(fake.config(cwd.path()))
|
|
||||||
.await
|
|
||||||
.expect("spawn");
|
|
||||||
|
|
||||||
let mut updates = session.take_updates().expect("first take");
|
|
||||||
assert!(session.take_updates().is_none(), "handed out once");
|
|
||||||
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
|
||||||
.await
|
|
||||||
.expect("update within 5s")
|
|
||||||
.expect("channel open");
|
|
||||||
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
|
||||||
match first.update {
|
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
|
||||||
ContentBlock::Text(t) => assert_eq!(t.text, "hello from fake"),
|
|
||||||
other => panic!("unexpected content {other:?}"),
|
|
||||||
},
|
|
||||||
other => panic!("unexpected update {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
session
|
|
||||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn advertised_settings_are_normalized_and_mutable() {
|
|
||||||
let fake = Fake::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let mut config = fake.config(cwd.path());
|
|
||||||
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
|
|
||||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
|
||||||
let mut updates = session.take_updates().expect("updates");
|
|
||||||
|
|
||||||
let initial = loop {
|
|
||||||
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
|
||||||
.await
|
|
||||||
.expect("settings update")
|
|
||||||
.expect("channel open");
|
|
||||||
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
|
|
||||||
break options.config_options;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
assert_eq!(initial.len(), 3);
|
|
||||||
assert!(initial
|
|
||||||
.iter()
|
|
||||||
.any(|option| option.id.0.as_ref() == "effort"));
|
|
||||||
assert!(initial
|
|
||||||
.iter()
|
|
||||||
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
|
|
||||||
assert!(initial
|
|
||||||
.iter()
|
|
||||||
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
|
|
||||||
let model = initial
|
|
||||||
.iter()
|
|
||||||
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
|
|
||||||
.expect("legacy model option");
|
|
||||||
let model = serde_json::to_value(model).unwrap();
|
|
||||||
assert_eq!(model["options"][0]["name"], "Opus");
|
|
||||||
assert!(model["options"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.any(|option| option["value"] == "fable"));
|
|
||||||
|
|
||||||
let changed = session
|
|
||||||
.set_config(
|
|
||||||
"__maestro_legacy_model".into(),
|
|
||||||
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
|
|
||||||
)
|
|
||||||
.expect("queue model change");
|
|
||||||
changed
|
|
||||||
.await
|
|
||||||
.expect("response delivered")
|
|
||||||
.expect("model changed");
|
|
||||||
fake.wait_for_log("recv session/set_model").await;
|
|
||||||
|
|
||||||
session
|
|
||||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn terminate_sends_cancel_then_sigterm() {
|
|
||||||
let fake = Fake::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
|
||||||
.await
|
|
||||||
.expect("spawn");
|
|
||||||
let pid = session.pid();
|
|
||||||
|
|
||||||
session
|
|
||||||
.terminate(Duration::from_millis(200), Duration::from_secs(2))
|
|
||||||
.await;
|
|
||||||
assert!(session.has_exited());
|
|
||||||
fake.wait_for_log("signal SIGTERM").await;
|
|
||||||
|
|
||||||
let log = fake.log();
|
|
||||||
let cancel = log.find("recv session/cancel").expect("cancel logged");
|
|
||||||
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
|
|
||||||
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
|
|
||||||
assert!(!pid_alive(pid), "child {pid} still alive");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn terminate_is_satisfied_by_an_exit_on_cancel() {
|
|
||||||
let fake = Fake::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let mut config = fake.config(cwd.path());
|
|
||||||
config
|
|
||||||
.env
|
|
||||||
.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
|
|
||||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
|
||||||
|
|
||||||
session
|
|
||||||
.terminate(Duration::from_secs(2), Duration::from_secs(2))
|
|
||||||
.await;
|
|
||||||
assert!(session.has_exited());
|
|
||||||
let log = fake.log();
|
|
||||||
assert!(log.contains("recv session/cancel"), "{log}");
|
|
||||||
assert!(!log.contains("signal SIGTERM"), "cancel was enough:\n{log}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
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()).adapter(AcpAdapter::Codex);
|
|
||||||
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
|
|
||||||
|
|
||||||
let err = maestro_acp::spawn(config).await.expect_err("init fails");
|
|
||||||
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
|
||||||
assert!(err.to_string().contains("fake init failure"), "{err}");
|
|
||||||
// Closing the connection gives the fake EOF on stdin; the SIGTERM that
|
|
||||||
// follows may find it already gone. Either way it must not survive.
|
|
||||||
let pid: u32 = fake
|
|
||||||
.log()
|
|
||||||
.lines()
|
|
||||||
.find_map(|l| l.strip_prefix("start pid="))
|
|
||||||
.and_then(|rest| rest.split(' ').next())
|
|
||||||
.and_then(|p| p.parse().ok())
|
|
||||||
.expect("pid in log");
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
while pid_alive(pid) && Instant::now() < deadline {
|
|
||||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
||||||
}
|
|
||||||
assert!(!pid_alive(pid), "child {pid} outlived the failed spawn");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn early_exit_is_observed_after_a_successful_handshake() {
|
|
||||||
let fake = Fake::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let mut config = fake.config(cwd.path());
|
|
||||||
config
|
|
||||||
.env
|
|
||||||
.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
|
|
||||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
|
||||||
|
|
||||||
let status = tokio::time::timeout(Duration::from_secs(5), session.wait())
|
|
||||||
.await
|
|
||||||
.expect("exit within 5s")
|
|
||||||
.expect("status");
|
|
||||||
assert!(status.success(), "{status}");
|
|
||||||
assert!(session.has_exited());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn child_exit_before_handshake_reports_exited_early() {
|
|
||||||
// A "adapter" that dies before answering anything.
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let bin = dir.path().join("bin");
|
|
||||||
std::fs::create_dir_all(&bin).unwrap();
|
|
||||||
let script = bin.join(ADAPTER_BIN);
|
|
||||||
std::fs::write(&script, "#!/bin/sh\necho 'not logged in' >&2\nexit 3\n").unwrap();
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
||||||
}
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]);
|
|
||||||
let mut config = SpawnConfig::new(cwd.path(), env);
|
|
||||||
config.handshake_timeout = Duration::from_secs(10);
|
|
||||||
|
|
||||||
let err = maestro_acp::spawn(config).await.expect_err("child died");
|
|
||||||
match &err {
|
|
||||||
AcpError::ExitedEarly { status, stderr, .. } => {
|
|
||||||
assert!(status.contains('3'), "{status}");
|
|
||||||
assert!(stderr.contains("not logged in"), "{stderr}");
|
|
||||||
}
|
|
||||||
other => panic!("unexpected {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn drop_terminates_and_reaps_the_child() {
|
|
||||||
let fake = Fake::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let mut config = fake.config(cwd.path());
|
|
||||||
config.env.insert("FAKE_ACP_IGNORE_EOF".into(), "1".into());
|
|
||||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
|
||||||
|
|
||||||
drop(session);
|
|
||||||
|
|
||||||
fake.wait_for_log("signal SIGTERM").await;
|
|
||||||
fake.wait_for_exit().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
|
||||||
match &n.update {
|
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
|
||||||
ContentBlock::Text(t) => t.text.clone(),
|
|
||||||
other => panic!("unexpected content {other:?}"),
|
|
||||||
},
|
|
||||||
other => panic!("unexpected update {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
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())
|
|
||||||
.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");
|
|
||||||
assert_eq!(session.session_id(), "fake-old");
|
|
||||||
let log = fake.log();
|
|
||||||
assert!(
|
|
||||||
log.contains("recv session/load sessionId=fake-old"),
|
|
||||||
"{log}"
|
|
||||||
);
|
|
||||||
assert!(!log.contains("recv session/new"), "{log}");
|
|
||||||
|
|
||||||
// The three replayed chunks never reach the receiver: the event log
|
|
||||||
// already holds them. The first notification after the response does.
|
|
||||||
let mut updates = session.take_updates().expect("updates");
|
|
||||||
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
|
||||||
.await
|
|
||||||
.expect("update within 5s")
|
|
||||||
.expect("channel open");
|
|
||||||
assert_eq!(chunk_text(&first), "hello after load");
|
|
||||||
// Nothing queued behind it either.
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
assert!(updates.try_recv().is_err(), "replay leaked through");
|
|
||||||
|
|
||||||
session
|
|
||||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn load_session_requires_the_load_capability() {
|
|
||||||
let fake = Fake::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
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)
|
|
||||||
.await
|
|
||||||
.expect_err("no loadSession");
|
|
||||||
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
|
||||||
assert!(err.to_string().contains("loadSession"), "{err}");
|
|
||||||
assert!(!fake.log().contains("recv session/load"), "{}", fake.log());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn load_session_error_fails_the_spawn() {
|
|
||||||
let fake = Fake::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
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");
|
|
||||||
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
|
||||||
assert!(err.to_string().contains("fake load failure"), "{err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End to end against the installed `claude-code-acp`. Needs Claude auth,
|
|
||||||
/// takes seconds, so it is opt-in: `cargo test -p maestro-acp -- --ignored`.
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore]
|
|
||||||
async fn real_adapter_handshake() {
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let session = maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()))
|
|
||||||
.await
|
|
||||||
.expect("real claude-code-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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End to end against the maintained Codex adapter. It needs a locally
|
|
||||||
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore]
|
|
||||||
async fn real_codex_adapter_handshake() {
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let session =
|
|
||||||
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
|
|
||||||
.await
|
|
||||||
.expect("real codex-acp handshake");
|
|
||||||
assert!(!session.session_id().is_empty());
|
|
||||||
assert!(pid_alive(session.pid()));
|
|
||||||
session
|
|
||||||
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
|
||||||
.await;
|
|
||||||
assert!(session.has_exited());
|
|
||||||
}
|
|
||||||
@@ -19,12 +19,6 @@ use tokio_tungstenite::tungstenite::Message;
|
|||||||
pub enum AttachError {
|
pub enum AttachError {
|
||||||
#[error("ws: {0}")]
|
#[error("ws: {0}")]
|
||||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||||
#[error("daemon returned {status} ({code}): {message}")]
|
|
||||||
Rejected {
|
|
||||||
status: String,
|
|
||||||
code: String,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error("termios: {0}")]
|
#[error("termios: {0}")]
|
||||||
@@ -38,21 +32,6 @@ pub enum AttachError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AttachError {
|
impl AttachError {
|
||||||
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
|
||||||
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
|
|
||||||
if let Some(body) = response.body().as_deref() {
|
|
||||||
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
|
|
||||||
return Self::Rejected {
|
|
||||||
status: response.status().to_string(),
|
|
||||||
code: error_body.error.code,
|
|
||||||
message: error_body.error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self::WebSocket(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when the failure was an inability to reach the WS endpoint at the
|
/// True when the failure was an inability to reach the WS endpoint at the
|
||||||
/// transport level (connection refused), as opposed to an HTTP-level
|
/// transport level (connection refused), as opposed to an HTTP-level
|
||||||
/// rejection from a daemon that did answer.
|
/// rejection from a daemon that did answer.
|
||||||
@@ -132,9 +111,7 @@ fn current_winsize() -> Option<(u16, u16)> {
|
|||||||
/// byte that exits cleanly without killing the session.
|
/// byte that exits cleanly without killing the session.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||||
let (ws, _resp) = tokio_tungstenite::connect_async(url)
|
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
||||||
.await
|
|
||||||
.map_err(AttachError::from_websocket)?;
|
|
||||||
let (mut sink, mut stream) = ws.split();
|
let (mut sink, mut stream) = ws.split();
|
||||||
|
|
||||||
let raw = RawTty::enable()?;
|
let raw = RawTty::enable()?;
|
||||||
@@ -256,25 +233,4 @@ mod tests {
|
|||||||
Err(AttachError::InvalidDetachKey(_))
|
Err(AttachError::InvalidDetachKey(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn attach_surfaces_structured_handshake_errors() {
|
|
||||||
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
|
|
||||||
"not_terminal",
|
|
||||||
"session is not a terminal session",
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let response = tokio_tungstenite::tungstenite::http::Response::builder()
|
|
||||||
.status(409)
|
|
||||||
.body(Some(body))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let error =
|
|
||||||
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// Send text to a running session. Terminal sessions receive raw
|
/// Type text into a running session, as if it had been typed on an
|
||||||
/// keystrokes; structured sessions receive an ACP prompt.
|
/// attached terminal.
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
@@ -92,14 +92,6 @@ pub struct SpawnArgs {
|
|||||||
/// there.
|
/// there.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub existing_branch: bool,
|
pub existing_branch: bool,
|
||||||
/// Run a Claude Code or Codex session in a plain terminal instead of the
|
|
||||||
/// structured (ACP) view it otherwise gets by default.
|
|
||||||
#[arg(long, conflicts_with = "structured")]
|
|
||||||
pub terminal: bool,
|
|
||||||
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
|
|
||||||
/// Codex commands have an adapter; the daemon refuses anything else.
|
|
||||||
#[arg(long)]
|
|
||||||
pub structured: bool,
|
|
||||||
/// After spawning, attach immediately.
|
/// After spawning, attach immediately.
|
||||||
#[arg(long, short = 'a')]
|
#[arg(long, short = 'a')]
|
||||||
pub attach: bool,
|
pub attach: bool,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
Session, SessionStatus, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -51,33 +51,15 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
};
|
};
|
||||||
|
|
||||||
let body = CreateSessionRequest {
|
let body = CreateSessionRequest {
|
||||||
view: spawn_view(&args),
|
|
||||||
cwd: args.cwd.clone(),
|
cwd: args.cwd.clone(),
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
env,
|
env,
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
auto_approve: false,
|
|
||||||
};
|
};
|
||||||
client.create_session(&body).await
|
client.create_session(&body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The view a `maestro spawn` asks for: structured whenever the command
|
|
||||||
/// resolves to an agent with an ACP adapter, since that is the richer session
|
|
||||||
/// and the one both other clients now default to. `--terminal` opts out;
|
|
||||||
/// `--structured` forces the request through even for a command this side
|
|
||||||
/// does not recognise, leaving the refusal to the daemon.
|
|
||||||
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
|
||||||
if args.terminal {
|
|
||||||
return SessionView::Terminal;
|
|
||||||
}
|
|
||||||
if args.structured || supports_structured_view(&args.command) {
|
|
||||||
SessionView::Structured
|
|
||||||
} else {
|
|
||||||
SessionView::Terminal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
/// the work went.
|
/// the work went.
|
||||||
@@ -130,12 +112,9 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
|||||||
/// `maestro send`. Answers with the session that was typed into, so the
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
/// caller can name it once the daemon has accepted the text.
|
/// caller can name it once the daemon has accepted the text.
|
||||||
///
|
///
|
||||||
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||||
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||||
/// there — ACP prompts are always complete messages).
|
/// the shape a heredoc or a pipe wants.
|
||||||
///
|
|
||||||
/// 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)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_send(
|
pub async fn run_send(
|
||||||
client: &DaemonClient,
|
client: &DaemonClient,
|
||||||
@@ -158,28 +137,19 @@ pub async fn run_send(
|
|||||||
return Err(CliError::NothingToSend);
|
return Err(CliError::NothingToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We always need the session list: even a bare UUID needs the view field
|
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||||
// to decide which endpoint to hit.
|
Ok(id) => id,
|
||||||
let sessions = client.list_sessions().await?;
|
Err(_) => {
|
||||||
let id = resolve_session(&sessions, &args.session)?;
|
let sessions = client.list_sessions().await?;
|
||||||
let view = sessions
|
resolve_session(&sessions, &args.session)?
|
||||||
.iter()
|
}
|
||||||
.find(|s| s.id == id)
|
};
|
||||||
.map(|s| s.view)
|
|
||||||
.unwrap_or(SessionView::Terminal);
|
|
||||||
|
|
||||||
match view {
|
let req = SendInputRequest {
|
||||||
SessionView::Structured => {
|
text,
|
||||||
client.send_structured_prompt(id, &text).await?;
|
submit: !args.no_enter,
|
||||||
}
|
};
|
||||||
SessionView::Terminal => {
|
client.send_input(id, &req).await?;
|
||||||
let req = SendInputRequest {
|
|
||||||
text,
|
|
||||||
submit: !args.no_enter,
|
|
||||||
};
|
|
||||||
client.send_input(id, &req).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,8 +178,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||||
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
for s in sorted {
|
for s in sorted {
|
||||||
@@ -219,16 +189,11 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
SessionStatus::Running => "running",
|
SessionStatus::Running => "running",
|
||||||
SessionStatus::Dead => "dead",
|
SessionStatus::Dead => "dead",
|
||||||
};
|
};
|
||||||
let view = match s.view {
|
|
||||||
SessionView::Terminal => "terminal",
|
|
||||||
SessionView::Structured => "structured",
|
|
||||||
};
|
|
||||||
let activity = match s.activity {
|
let activity = match s.activity {
|
||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
ActivityState::AwaitingInput => "awaiting",
|
ActivityState::AwaitingInput => "awaiting",
|
||||||
ActivityState::Error => "error",
|
ActivityState::Error => "error",
|
||||||
ActivityState::Starting => "starting",
|
|
||||||
};
|
};
|
||||||
// Show the live foreground command alongside the spawn command when
|
// Show the live foreground command alongside the spawn command when
|
||||||
// the daemon has observed one that differs (NAP-340).
|
// the daemon has observed one that differs (NAP-340).
|
||||||
@@ -241,8 +206,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
let blocked = blocked_label(s);
|
let blocked = blocked_label(s);
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||||
s.id, status, view, activity, dir, command
|
s.id, status, activity, dir, command
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -264,70 +229,13 @@ mod tests {
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::DaemonInfo;
|
use super::DaemonInfo;
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
use crate::cli::{Cli, Command};
|
|
||||||
|
|
||||||
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
|
||||||
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
|
||||||
argv.extend_from_slice(extra);
|
|
||||||
let cli = Cli::try_parse_from(argv).expect("parses");
|
|
||||||
let Command::Spawn(args) = cli.command else {
|
|
||||||
panic!("expected spawn")
|
|
||||||
};
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The CLI follows the two interactive clients: a command with an ACP
|
|
||||||
/// adapter spawns structured unless `--terminal` says otherwise, and
|
|
||||||
/// `--structured` still forces the request for a command this side does
|
|
||||||
/// not recognise.
|
|
||||||
#[test]
|
|
||||||
fn spawn_defaults_to_structured_for_supported_agents() {
|
|
||||||
use super::spawn_view;
|
|
||||||
assert_eq!(
|
|
||||||
spawn_view(&spawn_args(&["claude"])),
|
|
||||||
SessionView::Structured
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
spawn_view(&spawn_args(&["npx codex exec"])),
|
|
||||||
SessionView::Structured
|
|
||||||
);
|
|
||||||
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
|
||||||
assert_eq!(
|
|
||||||
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
|
||||||
SessionView::Terminal
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
|
||||||
SessionView::Structured
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_refuses_both_view_flags_at_once() {
|
|
||||||
let err = Cli::try_parse_from([
|
|
||||||
"maestro",
|
|
||||||
"spawn",
|
|
||||||
"--cwd",
|
|
||||||
"/tmp",
|
|
||||||
"--command",
|
|
||||||
"claude",
|
|
||||||
"--structured",
|
|
||||||
"--terminal",
|
|
||||||
])
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(err.to_string().contains("--terminal"), "{err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
cwd: PathBuf::from(cwd),
|
cwd: PathBuf::from(cwd),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
@@ -344,7 +252,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,22 +283,6 @@ mod tests {
|
|||||||
assert!(s.contains("idle"), "{s}");
|
assert!(s.contains("idle"), "{s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn table_exposes_terminal_and_structured_views() {
|
|
||||||
let terminal = session("/tmp", "bash", 1);
|
|
||||||
let mut structured = session("/tmp", "claude", 2);
|
|
||||||
structured.view = SessionView::Structured;
|
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
|
||||||
let out = String::from_utf8(buf).unwrap();
|
|
||||||
let lines: Vec<&str> = out.lines().collect();
|
|
||||||
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
|
||||||
|
|
||||||
assert!(lines[header + 1].contains("terminal"), "{out}");
|
|
||||||
assert!(lines[header + 2].contains("structured"), "{out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||||
|
|
||||||
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
use maestro_protocol::{DaemonClient, DaemonInfo};
|
||||||
use maestro_testing::{session, MockDaemon};
|
use maestro_testing::{session, MockDaemon};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ async fn info_carries_version_and_presets() {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
auto_approve: false,
|
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -92,13 +91,11 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
let created = mock
|
let created = mock
|
||||||
.client()
|
.client()
|
||||||
.create_session(&maestro_protocol::CreateSessionRequest {
|
.create_session(&maestro_protocol::CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/home/mock".into(),
|
cwd: "/home/mock".into(),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -202,35 +199,6 @@ async fn send_resolves_a_session_by_name() {
|
|||||||
assert_eq!(sent, id);
|
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]
|
#[tokio::test]
|
||||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
let mock = MockDaemon::start().await;
|
let mock = MockDaemon::start().await;
|
||||||
|
|||||||
@@ -313,19 +313,16 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
auto_approve: false,
|
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "opencode".into(),
|
label: "opencode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
auto_approve: false,
|
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "zsh".into(),
|
id: "zsh".into(),
|
||||||
label: "Shell".into(),
|
label: "Shell".into(),
|
||||||
command: "zsh".into(),
|
command: "zsh".into(),
|
||||||
auto_approve: false,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -337,7 +334,6 @@ pub fn custom_preset() -> SpawnPreset {
|
|||||||
id: CUSTOM_PRESET_ID.into(),
|
id: CUSTOM_PRESET_ID.into(),
|
||||||
label: "Custom…".into(),
|
label: "Custom…".into(),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
auto_approve: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +515,6 @@ auto_install = false
|
|||||||
id: "fish".into(),
|
id: "fish".into(),
|
||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
auto_approve: false,
|
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -556,7 +551,6 @@ auto_install = false
|
|||||||
id: " ".into(),
|
id: " ".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
auto_approve: false,
|
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -567,7 +561,6 @@ auto_install = false
|
|||||||
id: "x".into(),
|
id: "x".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
auto_approve: false,
|
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -585,13 +578,11 @@ auto_install = false
|
|||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a".into(),
|
label: "a".into(),
|
||||||
command: "a".into(),
|
command: "a".into(),
|
||||||
auto_approve: false,
|
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a2".into(),
|
label: "a2".into(),
|
||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
auto_approve: false,
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -606,7 +597,6 @@ auto_install = false
|
|||||||
id: "custom".into(),
|
id: "custom".into(),
|
||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
auto_approve: false,
|
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ dirs.workspace = true
|
|||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
include_dir = { workspace = true, optional = true }
|
include_dir = { workspace = true, optional = true }
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
maestro-acp.workspace = true
|
|
||||||
mime_guess = { workspace = true, optional = true }
|
mime_guess = { workspace = true, optional = true }
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
maestro-hooks = { workspace = true, default-features = false }
|
maestro-hooks = { workspace = true, default-features = false }
|
||||||
@@ -43,7 +42,6 @@ tracing-subscriber.workspace = true
|
|||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
libc.workspace = true
|
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|||||||
@@ -66,23 +66,6 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive a structured session's sidebar state from its ACP projection.
|
|
||||||
pub fn decide_structured(
|
|
||||||
turn_active: bool,
|
|
||||||
pending_approval: bool,
|
|
||||||
turn_failed: bool,
|
|
||||||
) -> ActivityState {
|
|
||||||
if turn_failed {
|
|
||||||
ActivityState::Error
|
|
||||||
} else if pending_approval {
|
|
||||||
ActivityState::AwaitingInput
|
|
||||||
} else if turn_active {
|
|
||||||
ActivityState::Active
|
|
||||||
} else {
|
|
||||||
ActivityState::Idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long a session's pane must be continuously focused (a client's pane,
|
/// How long a session's pane must be continuously focused (a client's pane,
|
||||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||||
/// operator has looked, so the rail stops asking for attention.
|
/// operator has looked, so the rail stops asking for attention.
|
||||||
@@ -153,17 +136,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
|
||||||
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
|
||||||
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
|
||||||
assert_eq!(
|
|
||||||
decide_structured(true, true, false),
|
|
||||||
ActivityState::AwaitingInput
|
|
||||||
);
|
|
||||||
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn report_is_in_force_until_exactly_its_expiry() {
|
fn report_is_in_force_until_exactly_its_expiry() {
|
||||||
let ttl = Duration::from_secs(300);
|
let ttl = Duration::from_secs(300);
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
projects.start_background_refresh();
|
projects.start_background_refresh();
|
||||||
|
|
||||||
let router = local_api::router(local_api::state_for(
|
let router = local_api::router(local_api::state_for(
|
||||||
manager.clone(),
|
manager,
|
||||||
config.hostname.clone(),
|
config.hostname.clone(),
|
||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
@@ -118,22 +118,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
let bound = listener.local_addr()?;
|
let bound = listener.local_addr()?;
|
||||||
tracing::info!(addr = %bound, "API listening");
|
tracing::info!(addr = %bound, "API listening");
|
||||||
|
|
||||||
// Ctrl-C at a terminal or SIGTERM from systemd: either one is a
|
let shutdown = async {
|
||||||
// shutdown, and both must reach the adapter children below (#134). The
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
// unit's `KillMode=process` signals only the daemon, so nothing else
|
tracing::info!("ctrl-c received — shutting down");
|
||||||
// would end them.
|
|
||||||
let shutdown_manager = manager.clone();
|
|
||||||
let shutdown = async move {
|
|
||||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
||||||
.expect("install SIGTERM handler");
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received — shutting down"),
|
|
||||||
_ = sigterm.recv() => tracing::info!("SIGTERM received — shutting down"),
|
|
||||||
}
|
|
||||||
// Axum waits for upgraded WebSockets to drain. Structured children
|
|
||||||
// must be settled before that wait, while the signal path still owns
|
|
||||||
// the shutdown order (#179).
|
|
||||||
shutdown_manager.shutdown().await;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
axum::serve(listener, router)
|
axum::serve(listener, router)
|
||||||
|
|||||||
@@ -80,22 +80,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
fn non_repo_dir_is_none() {
|
||||||
// The fixture has no `.git`, so `find_root` must not return a path
|
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
||||||
// inside it. On hosts where an ancestor of the temp dir *is* a repo
|
// the walk can't accidentally hit a real repo above the test's cwd.
|
||||||
// (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 tmp = tempfile::tempdir().unwrap();
|
||||||
let sub = tmp.path().join("plain/dir");
|
let sub = tmp.path().join("plain/dir");
|
||||||
fs::create_dir_all(&sub).unwrap();
|
fs::create_dir_all(&sub).unwrap();
|
||||||
match find_root(&sub) {
|
assert_eq!(find_root(&sub), None);
|
||||||
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]
|
#[test]
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ pub mod hooks;
|
|||||||
pub mod local_api;
|
pub mod local_api;
|
||||||
pub mod panes;
|
pub mod panes;
|
||||||
pub mod proc;
|
pub mod proc;
|
||||||
pub mod projection;
|
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod release;
|
pub mod release;
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::body::Bytes;
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get, post};
|
use axum::routing::{any, delete, get, post};
|
||||||
@@ -22,14 +21,12 @@ use maestro_config::SpawnPreset;
|
|||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||||
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
|
use crate::session::SessionManager;
|
||||||
use crate::sidecar::EventCursor;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LocalApiState {
|
pub struct LocalApiState {
|
||||||
@@ -67,20 +64,7 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::NAMES, get(names))
|
.route(routes::NAMES, get(names))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::EVENTS, any(events_ws))
|
.route(routes::EVENTS, any(events_ws))
|
||||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
.route(routes::patterns::ATTACH, any(attach_ws));
|
||||||
.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),
|
|
||||||
);
|
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
{
|
{
|
||||||
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
||||||
@@ -180,14 +164,12 @@ fn dir_error_status(code: &str) -> StatusCode {
|
|||||||
fn session_error_status(code: &str) -> StatusCode {
|
fn session_error_status(code: &str) -> StatusCode {
|
||||||
match code {
|
match code {
|
||||||
"not_found" => StatusCode::NOT_FOUND,
|
"not_found" => StatusCode::NOT_FOUND,
|
||||||
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
// The session outlived its pane: nothing to type into, and nothing
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
// the daemon can do about it.
|
// the daemon can do about it.
|
||||||
"not_running" => StatusCode::CONFLICT,
|
"not_running" => StatusCode::CONFLICT,
|
||||||
"not_terminal" => StatusCode::CONFLICT,
|
|
||||||
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,10 +297,11 @@ async fn rename_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal
|
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
||||||
/// session's state. Structured sessions acknowledge and ignore these reports
|
/// The report holds above the daemon's inference for a TTL, then inference
|
||||||
/// because their activity comes from ACP. Unknown session is 404; an unknown
|
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
||||||
/// state value is rejected by the JSON decoder (422). Answers `204 No Content`.
|
/// forever. Unknown session is 404; an unknown state value is rejected by the
|
||||||
|
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
|
||||||
async fn report_activity(
|
async fn report_activity(
|
||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
@@ -385,199 +368,6 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
|||||||
(status, Json(ApiError::new(code, message))).into_response()
|
(status, Json(ApiError::new(code, message))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ReplayQuery {
|
|
||||||
since: Option<u64>,
|
|
||||||
before: Option<u64>,
|
|
||||||
limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_STRUCTURED_REPLAY_LIMIT: usize = 500;
|
|
||||||
|
|
||||||
async fn structured_replay(
|
|
||||||
State(s): State<LocalApiState>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Query(query): Query<ReplayQuery>,
|
|
||||||
) -> Response {
|
|
||||||
if query.since.is_some() && query.before.is_some() {
|
|
||||||
return error_response(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"invalid_cursor",
|
|
||||||
"use either since or before, not both",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let cursor = match query.before {
|
|
||||||
Some(before) => EventCursor::Before(Some(before)),
|
|
||||||
None => EventCursor::Since(query.since.unwrap_or(0)),
|
|
||||||
};
|
|
||||||
match s
|
|
||||||
.manager
|
|
||||||
.read_acp_projection_replay(
|
|
||||||
id,
|
|
||||||
cursor,
|
|
||||||
query
|
|
||||||
.limit
|
|
||||||
.unwrap_or(100)
|
|
||||||
.clamp(1, MAX_STRUCTURED_REPLAY_LIMIT),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(events)) => Json(events).into_response(),
|
|
||||||
// A terminal session, or a structured session whose adapter is no
|
|
||||||
// longer live, cannot provide a coherent replay continuation.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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) | Ok(QueueAcpPrompt::Pending) => {
|
|
||||||
StatusCode::NO_CONTENT.into_response()
|
|
||||||
}
|
|
||||||
Ok(QueueAcpPrompt::InFlight) => error_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"turn_in_flight",
|
|
||||||
"the structured session already has an active turn",
|
|
||||||
),
|
|
||||||
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"not_structured",
|
|
||||||
"session is not a live structured session",
|
|
||||||
),
|
|
||||||
Ok(QueueAcpPrompt::Failed(message)) => {
|
|
||||||
error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let info = e.to_error_info();
|
|
||||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
|
||||||
match s.manager.cancel_acp_turn(id).await {
|
|
||||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
|
||||||
Ok(false) => 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_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)>,
|
|
||||||
Json(decision): Json<StructuredApprovalDecision>,
|
|
||||||
) -> Response {
|
|
||||||
let decision = match decision {
|
|
||||||
StructuredApprovalDecision::AllowOnce => crate::session::ApprovalDecision::AllowOnce,
|
|
||||||
StructuredApprovalDecision::AllowForSession => {
|
|
||||||
crate::session::ApprovalDecision::AllowForSession
|
|
||||||
}
|
|
||||||
StructuredApprovalDecision::Deny => crate::session::ApprovalDecision::Deny,
|
|
||||||
};
|
|
||||||
match s.manager.resolve_acp_approval(id, &nonce, decision).await {
|
|
||||||
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
|
|
||||||
Ok(None) => error_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"not_structured",
|
|
||||||
"session is not a live structured session",
|
|
||||||
),
|
|
||||||
Err(e) => {
|
|
||||||
let info = e.to_error_info();
|
|
||||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||||
/// scanner starts with its cache cold; wire
|
/// scanner starts with its cache cold; wire
|
||||||
@@ -615,23 +405,12 @@ async fn attach_ws(
|
|||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
ws: axum::extract::WebSocketUpgrade,
|
ws: axum::extract::WebSocketUpgrade,
|
||||||
) -> Result<Response, Response> {
|
) -> Result<Response, Response> {
|
||||||
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
||||||
Ok(subscription) => subscription,
|
return Err(error_response(
|
||||||
Err(SessionError::NotFound(_)) => {
|
StatusCode::NOT_FOUND,
|
||||||
return Err(error_response(
|
"unknown_session",
|
||||||
StatusCode::NOT_FOUND,
|
"no such session on this daemon",
|
||||||
"unknown_session",
|
));
|
||||||
"no such session on this daemon",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let info = error.to_error_info();
|
|
||||||
return Err(error_response(
|
|
||||||
session_error_status(&info.code),
|
|
||||||
&info.code,
|
|
||||||
&info.message,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let manager = s.manager.clone();
|
let manager = s.manager.clone();
|
||||||
Ok(ws.on_upgrade(move |socket| async move {
|
Ok(ws.on_upgrade(move |socket| async move {
|
||||||
@@ -639,75 +418,6 @@ async fn attach_ws(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::result_large_err)]
|
|
||||||
async fn structured_ws(
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(s): State<LocalApiState>,
|
|
||||||
ws: axum::extract::WebSocketUpgrade,
|
|
||||||
) -> Result<Response, Response> {
|
|
||||||
let Some((snapshot, state, rx)) = s
|
|
||||||
.manager
|
|
||||||
.subscribe_acp_projection(id, EventCursor::Since(0))
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
let info = e.to_error_info();
|
|
||||||
error_response(session_error_status(&info.code), &info.code, &info.message)
|
|
||||||
})?
|
|
||||||
else {
|
|
||||||
return Err(error_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"not_structured",
|
|
||||||
"session is not a live structured session",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
Ok(ws.on_upgrade(move |socket| async move {
|
|
||||||
let _ = pump_structured(socket, snapshot, state, rx).await;
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn pump_structured(
|
|
||||||
socket: axum::extract::ws::WebSocket,
|
|
||||||
snapshot: maestro_protocol::TranscriptSnapshot,
|
|
||||||
state: maestro_protocol::ReducedState,
|
|
||||||
mut rx: tokio::sync::broadcast::Receiver<crate::projection::ProjectionUpdate>,
|
|
||||||
) -> Result<(), axum::Error> {
|
|
||||||
use axum::extract::ws::Message;
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
|
|
||||||
let (mut sink, mut stream) = socket.split();
|
|
||||||
for event in [
|
|
||||||
StructuredEvent::TranscriptSnapshot { snapshot },
|
|
||||||
StructuredEvent::ReducedState { state },
|
|
||||||
] {
|
|
||||||
if sink
|
|
||||||
.send(Message::Text(
|
|
||||||
serde_json::to_string(&event).expect("structured event encode"),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
update = rx.recv() => match update {
|
|
||||||
Ok(update) => {
|
|
||||||
let event = StructuredEvent::ProjectionUpdate { transcript: update.transcript, state: update.state };
|
|
||||||
if sink.send(Message::Text(serde_json::to_string(&event).expect("structured event encode"))).await.is_err() { break; }
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
},
|
|
||||||
frame = stream.next() => match frame {
|
|
||||||
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
|
|
||||||
Some(Ok(_)) => {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+112
-2325
File diff suppressed because it is too large
Load Diff
@@ -5,23 +5,10 @@
|
|||||||
//! original `cwd`, command, env, metadata, etc. Files are deleted on
|
//! original `cwd`, command, env, metadata, etc. Files are deleted on
|
||||||
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still
|
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still
|
||||||
//! advertised but with empty metadata (handled by the caller, not here).
|
//! advertised but with empty metadata (handled by the caller, not here).
|
||||||
//!
|
|
||||||
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
|
|
||||||
//! sidecar: every `session/update` notification and daemon approval row is one
|
|
||||||
//! JSON object, appended before it is broadcast. The log is the source of
|
|
||||||
//! truth for the transcript; `seq` is the line's 1-based position, so a
|
|
||||||
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
|
|
||||||
//! that does not parse — including the partial line a hard kill leaves at
|
|
||||||
//! the end — keeps its seq and is skipped on read. `delete` removes the log
|
|
||||||
//! with the sidecar; `load_all` never reads one as a sidecar.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::fs::{File, OpenOptions};
|
|
||||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use maestro_acp::schema::SessionNotification;
|
use maestro_protocol::Session;
|
||||||
use maestro_protocol::{Session, TranscriptRow};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -51,120 +38,6 @@ impl SidecarStore {
|
|||||||
self.dir.join(format!("{id}.json"))
|
self.dir.join(format!("{id}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the per-session event log lives (#135).
|
|
||||||
pub fn events_path_for(&self, id: Uuid) -> PathBuf {
|
|
||||||
self.dir.join(format!("{id}.events.jsonl"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Durable evidence that graceful daemon shutdown ended this structured
|
|
||||||
/// session and the next daemon may resume it.
|
|
||||||
pub fn recovery_path_for(&self, id: Uuid) -> PathBuf {
|
|
||||||
self.dir.join(format!("{id}.recover"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark `id` as eligible for one structured-session recovery attempt.
|
|
||||||
pub fn mark_for_recovery(&self, id: Uuid) -> Result<(), SidecarError> {
|
|
||||||
std::fs::create_dir_all(&self.dir)?;
|
|
||||||
std::fs::write(self.recovery_path_for(id), [])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consume the graceful-shutdown recovery marker for `id`.
|
|
||||||
pub fn take_recovery_marker(&self, id: Uuid) -> Result<bool, SidecarError> {
|
|
||||||
match std::fs::remove_file(self.recovery_path_for(id)) {
|
|
||||||
Ok(()) => Ok(true),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the event log for `id` for appending, creating it (and the
|
|
||||||
/// directory) when missing. One handle per live session: the caller
|
|
||||||
/// keeps it for the session's lifetime rather than reopening per
|
|
||||||
/// notification.
|
|
||||||
///
|
|
||||||
/// A log whose last line has no newline — what a hard kill mid-write
|
|
||||||
/// leaves behind — is terminated first, so the next append starts a
|
|
||||||
/// fresh line instead of gluing onto the fragment. The fragment keeps
|
|
||||||
/// the seq of its line and reads as malformed.
|
|
||||||
pub fn open_events(&self, id: Uuid) -> Result<EventLog, SidecarError> {
|
|
||||||
std::fs::create_dir_all(&self.dir)?;
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.append(true)
|
|
||||||
.open(self.events_path_for(id))?;
|
|
||||||
let mut lines = 0u64;
|
|
||||||
let mut last = None;
|
|
||||||
let mut reader = BufReader::new(&mut file);
|
|
||||||
let mut buf = [0u8; 64 * 1024];
|
|
||||||
loop {
|
|
||||||
let n = reader.read(&mut buf)?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lines += buf[..n].iter().filter(|&&b| b == b'\n').count() as u64;
|
|
||||||
last = Some(buf[n - 1]);
|
|
||||||
}
|
|
||||||
if last.is_some_and(|b| b != b'\n') {
|
|
||||||
file.write_all(b"\n")?;
|
|
||||||
lines += 1;
|
|
||||||
}
|
|
||||||
file.seek(SeekFrom::End(0))?;
|
|
||||||
Ok(EventLog {
|
|
||||||
file: Box::new(file),
|
|
||||||
last_seq: lines,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read events from the log for `id`, at most `limit`, in ascending seq
|
|
||||||
/// order. A missing log is an empty log. Lines that fail to parse are
|
|
||||||
/// skipped but still consume a seq, so cursors stay stable.
|
|
||||||
pub fn read_events(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
cursor: EventCursor,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<LoggedEvent>, SidecarError> {
|
|
||||||
let file = match File::open(self.events_path_for(id)) {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
if limit == 0 {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut out: VecDeque<LoggedEvent> = VecDeque::new();
|
|
||||||
let mut seq = 0u64;
|
|
||||||
for line in BufReader::new(file).split(b'\n') {
|
|
||||||
let line = line?;
|
|
||||||
seq += 1;
|
|
||||||
let keep = match cursor {
|
|
||||||
EventCursor::Since(since) => seq > since,
|
|
||||||
EventCursor::Before(Some(before)) => seq < before,
|
|
||||||
EventCursor::Before(None) => true,
|
|
||||||
};
|
|
||||||
if !keep {
|
|
||||||
if matches!(cursor, EventCursor::Before(Some(_))) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(event) = PersistedEvent::decode(&line) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
out.push_back(LoggedEvent { seq, event });
|
|
||||||
match cursor {
|
|
||||||
EventCursor::Since(_) if out.len() >= limit => break,
|
|
||||||
EventCursor::Before(_) if out.len() > limit => {
|
|
||||||
out.pop_front();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically write `session` to `<dir>/<id>.json`. Creates the directory
|
/// Atomically write `session` to `<dir>/<id>.json`. Creates the directory
|
||||||
/// if it does not exist.
|
/// if it does not exist.
|
||||||
pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
|
pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
|
||||||
@@ -186,13 +59,13 @@ impl SidecarStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the sidecar, event log, and recovery marker for `id`. No-op for
|
/// Delete the sidecar for `id`. No-op if it does not exist.
|
||||||
/// whichever does not exist.
|
|
||||||
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
||||||
remove_if_present(&self.events_path_for(id))?;
|
match std::fs::remove_file(self.path_for(id)) {
|
||||||
remove_if_present(&self.dir.join(format!("{id}.approvals.jsonl")))?;
|
Ok(()) => Ok(()),
|
||||||
remove_if_present(&self.recovery_path_for(id))?;
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
remove_if_present(&self.path_for(id))
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read every sidecar JSON in the directory. Files that fail to parse are
|
/// Read every sidecar JSON in the directory. Files that fail to parse are
|
||||||
@@ -208,8 +81,6 @@ impl SidecarStore {
|
|||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
// `.events.jsonl` logs (#135) sit in the same directory and are
|
|
||||||
// never sidecars.
|
|
||||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -224,425 +95,3 @@ impl SidecarStore {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_if_present(path: &Path) -> Result<(), SidecarError> {
|
|
||||||
match std::fs::remove_file(path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which slice of a session's event log to read.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum EventCursor {
|
|
||||||
/// Forward: events with `seq` strictly greater than the cursor, the
|
|
||||||
/// first `limit` of them. `Since(0)` is the whole log from the start.
|
|
||||||
/// What a client catching up after a reconnect asks for.
|
|
||||||
Since(u64),
|
|
||||||
/// Backward: events with `seq` strictly less than the cursor, the last
|
|
||||||
/// `limit` of them, still returned in ascending order. `None` means the
|
|
||||||
/// tail of the log, for a first paint. The next page passes the smallest
|
|
||||||
/// seq it received.
|
|
||||||
Before(Option<u64>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One line of a session's event log.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct LoggedEvent {
|
|
||||||
/// 1-based line position in the log; the cursor for [`EventCursor`].
|
|
||||||
pub seq: u64,
|
|
||||||
pub event: PersistedEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single entry in the structured event history.
|
|
||||||
///
|
|
||||||
/// ACP notifications retain their original JSON representation, so existing
|
|
||||||
/// event logs remain readable. Daemon-generated rows use an envelope so they
|
|
||||||
/// can share the same cursor sequence without pretending to be ACP traffic.
|
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
||||||
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
|
|
||||||
pub enum PersistedEvent {
|
|
||||||
User {
|
|
||||||
row: TranscriptRow,
|
|
||||||
},
|
|
||||||
Approval {
|
|
||||||
row: TranscriptRow,
|
|
||||||
},
|
|
||||||
Turn {
|
|
||||||
row: TranscriptRow,
|
|
||||||
},
|
|
||||||
#[serde(skip)]
|
|
||||||
Notification(Box<SessionNotification>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PersistedEvent {
|
|
||||||
fn decode(line: &[u8]) -> Result<Self, serde_json::Error> {
|
|
||||||
serde_json::from_slice::<SessionNotification>(line)
|
|
||||||
.map(|notification| Self::Notification(Box::new(notification)))
|
|
||||||
.or_else(|_| serde_json::from_slice(line))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SessionNotification> for PersistedEvent {
|
|
||||||
fn from(notification: SessionNotification) -> Self {
|
|
||||||
Self::Notification(Box::new(notification))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&SessionNotification> for PersistedEvent {
|
|
||||||
fn from(notification: &SessionNotification) -> Self {
|
|
||||||
Self::Notification(Box::new(notification.clone()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&PersistedEvent> for PersistedEvent {
|
|
||||||
fn from(event: &PersistedEvent) -> Self {
|
|
||||||
event.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What an [`EventLog`] appends onto: something seekable by length and
|
|
||||||
/// truncatable back to one, so a failed append can be rolled off the end
|
|
||||||
/// again. A real [`File`] in production; an in-memory fault double in tests
|
|
||||||
/// (#184) — the same [`EventLog::append`] rollback logic runs over both, so
|
|
||||||
/// the behavior under test is the production behavior, not a stand-in for
|
|
||||||
/// it.
|
|
||||||
pub(crate) trait LogSink: Write + Send {
|
|
||||||
fn len(&mut self) -> std::io::Result<u64>;
|
|
||||||
fn truncate(&mut self, len: u64) -> std::io::Result<()>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LogSink for File {
|
|
||||||
fn len(&mut self) -> std::io::Result<u64> {
|
|
||||||
Ok(self.metadata()?.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
|
|
||||||
self.set_len(len)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outcome of a failed [`EventLog::append`].
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum AppendError {
|
|
||||||
/// The write or flush failed, but the log was rolled back to exactly the
|
|
||||||
/// byte it was at before the call: `last_seq` and the file agree, and
|
|
||||||
/// this same [`EventLog`] is safe to keep appending to. The event itself
|
|
||||||
/// was never persisted or broadcast.
|
|
||||||
#[error("event append failed, rolled back cleanly: {0}")]
|
|
||||||
Failed(#[source] SidecarError),
|
|
||||||
/// The append failed *and* the rollback did not restore the log to its
|
|
||||||
/// prior length, so the file's line count and `last_seq` may now
|
|
||||||
/// disagree. The caller must not append to this [`EventLog`] again —
|
|
||||||
/// treat the session as dead rather than promise consistency it cannot
|
|
||||||
/// keep.
|
|
||||||
#[error("event append failed ({cause}) and rollback did not restore consistency ({rollback})")]
|
|
||||||
Fatal {
|
|
||||||
cause: SidecarError,
|
|
||||||
rollback: SidecarError,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SidecarError> for AppendError {
|
|
||||||
fn from(error: SidecarError) -> Self {
|
|
||||||
AppendError::Failed(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An open, append-only handle on one session's event log. Obtained from
|
|
||||||
/// [`SidecarStore::open_events`]; held for the session's lifetime.
|
|
||||||
pub struct EventLog {
|
|
||||||
file: Box<dyn LogSink>,
|
|
||||||
last_seq: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for EventLog {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("EventLog")
|
|
||||||
.field("last_seq", &self.last_seq)
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLog {
|
|
||||||
/// Wrap an arbitrary sink as an [`EventLog`], for tests that need to
|
|
||||||
/// inject a deterministic append failure (#184) — a partial write, a
|
|
||||||
/// flush that fails after the bytes are already down, or a rollback that
|
|
||||||
/// cannot itself complete.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn for_test(file: impl LogSink + 'static, last_seq: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
file: Box::new(file),
|
|
||||||
last_seq,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The seq of the last line, `0` for an empty log.
|
|
||||||
pub fn last_seq(&self) -> u64 {
|
|
||||||
self.last_seq
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append one notification as a line and return its seq. The line is
|
|
||||||
/// handed to the kernel in a single write before this returns, so a
|
|
||||||
/// caller that broadcasts afterwards never announces an event the log
|
|
||||||
/// would not replay — short of the machine losing power.
|
|
||||||
///
|
|
||||||
/// A `write_all` that errors partway can leave an unterminated prefix on
|
|
||||||
/// disk; a `flush` that errors after a *complete* `write_all` leaves
|
|
||||||
/// ambiguity about whether that full, well-formed line should count —
|
|
||||||
/// counting it would silently shift every later seq by one, since
|
|
||||||
/// `last_seq` was never bumped for it. Both cases are rolled back to the
|
|
||||||
/// exact pre-append length before returning, so `last_seq` and the file
|
|
||||||
/// never disagree and the same [`EventLog`] can be appended to again
|
|
||||||
/// immediately. If the rollback itself fails, the log's consistency can
|
|
||||||
/// no longer be promised and [`AppendError::Fatal`] is returned instead.
|
|
||||||
pub fn append(&mut self, event: impl Into<PersistedEvent>) -> Result<u64, AppendError> {
|
|
||||||
let event = event.into();
|
|
||||||
let mut line = match &event {
|
|
||||||
PersistedEvent::Notification(notification) => serde_json::to_vec(notification)
|
|
||||||
.map_err(|e| AppendError::from(SidecarError::from(e)))?,
|
|
||||||
_ => {
|
|
||||||
serde_json::to_vec(&event).map_err(|e| AppendError::from(SidecarError::from(e)))?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
line.push(b'\n');
|
|
||||||
|
|
||||||
let pre_len = self
|
|
||||||
.file
|
|
||||||
.len()
|
|
||||||
.map_err(|e| AppendError::from(SidecarError::from(e)))?;
|
|
||||||
|
|
||||||
if let Err(cause) = self.file.write_all(&line) {
|
|
||||||
return Err(self.rollback_after(pre_len, cause));
|
|
||||||
}
|
|
||||||
if let Err(cause) = self.file.flush() {
|
|
||||||
return Err(self.rollback_after(pre_len, cause));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.last_seq += 1;
|
|
||||||
Ok(self.last_seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback_after(&mut self, pre_len: u64, cause: std::io::Error) -> AppendError {
|
|
||||||
match self.file.truncate(pre_len) {
|
|
||||||
Ok(()) => AppendError::Failed(SidecarError::Io(cause)),
|
|
||||||
Err(rollback) => AppendError::Fatal {
|
|
||||||
cause: SidecarError::Io(cause),
|
|
||||||
rollback: SidecarError::Io(rollback),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bytes shared between a [`FaultyLogSink`] moved into an [`EventLog`] and
|
|
||||||
/// the test that planted it, so the test can inspect what actually ended up
|
|
||||||
/// on the "disk" after a fault without reaching into `EventLog`'s private
|
|
||||||
/// `file` field.
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub(crate) struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl SharedBuf {
|
|
||||||
pub(crate) fn snapshot(&self) -> Vec<u8> {
|
|
||||||
self.0.lock().unwrap().clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An in-memory [`LogSink`] double that injects one deterministic fault — a
|
|
||||||
/// short write, a flush that fails after a full write, or a rollback that
|
|
||||||
/// cannot itself complete — then behaves like a healthy sink from then on.
|
|
||||||
/// Lets a test prove the *same* [`EventLog`] recovers, rather than one with a
|
|
||||||
/// handle swapped in from a healthy disk, which production never does
|
|
||||||
/// (#184).
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct FaultyLogSink {
|
|
||||||
pub(crate) buf: SharedBuf,
|
|
||||||
/// Accept this many bytes on the next `write` call, then fail the
|
|
||||||
/// *following* call — a short write followed by an error, exactly what
|
|
||||||
/// `write_all` sees from a real fd that dies mid-line. `Write::write`
|
|
||||||
/// must not report an error on a call that made partial progress, so
|
|
||||||
/// the fault is delivered one call later, via `pending_write_error`.
|
|
||||||
pub(crate) fail_write_after: Option<usize>,
|
|
||||||
pub(crate) pending_write_error: bool,
|
|
||||||
pub(crate) fail_flush_once: bool,
|
|
||||||
pub(crate) fail_truncate: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl Write for FaultyLogSink {
|
|
||||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
|
||||||
if self.pending_write_error {
|
|
||||||
self.pending_write_error = false;
|
|
||||||
return Err(std::io::Error::other("fault: write failed mid-line"));
|
|
||||||
}
|
|
||||||
if let Some(allowed) = self.fail_write_after.take() {
|
|
||||||
let allowed = allowed.min(data.len());
|
|
||||||
if allowed == 0 {
|
|
||||||
// No progress was made, so it's fine to report the error
|
|
||||||
// from this same call.
|
|
||||||
return Err(std::io::Error::other("fault: write failed mid-line"));
|
|
||||||
}
|
|
||||||
self.buf
|
|
||||||
.0
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.extend_from_slice(&data[..allowed]);
|
|
||||||
self.pending_write_error = true;
|
|
||||||
return Ok(allowed);
|
|
||||||
}
|
|
||||||
self.buf.0.lock().unwrap().extend_from_slice(data);
|
|
||||||
Ok(data.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush(&mut self) -> std::io::Result<()> {
|
|
||||||
if self.fail_flush_once {
|
|
||||||
self.fail_flush_once = false;
|
|
||||||
return Err(std::io::Error::other(
|
|
||||||
"fault: flush failed after a full write",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl LogSink for FaultyLogSink {
|
|
||||||
fn len(&mut self) -> std::io::Result<u64> {
|
|
||||||
Ok(self.buf.0.lock().unwrap().len() as u64)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
|
|
||||||
if self.fail_truncate {
|
|
||||||
return Err(std::io::Error::other("fault: rollback truncate failed"));
|
|
||||||
}
|
|
||||||
self.buf.0.lock().unwrap().truncate(len as usize);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn sample_event(id: &str) -> PersistedEvent {
|
|
||||||
PersistedEvent::Approval {
|
|
||||||
row: TranscriptRow {
|
|
||||||
id: id.to_owned(),
|
|
||||||
kind: maestro_protocol::TranscriptRowKind::Approval,
|
|
||||||
data: serde_json::json!({"tool": "bash"}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replays `bytes` as a real on-disk log via the production read path,
|
|
||||||
/// so "live and durable agree" is checked with the same code a
|
|
||||||
/// reconnecting client would hit, not a hand-rolled line splitter.
|
|
||||||
fn replay(bytes: &[u8]) -> Vec<LoggedEvent> {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
std::fs::create_dir_all(store.dir()).unwrap();
|
|
||||||
std::fs::write(store.events_path_for(id), bytes).unwrap();
|
|
||||||
store.read_events(id, EventCursor::Since(0), 100).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_rolls_back_a_partial_write_and_the_same_instance_recovers() {
|
|
||||||
let buf = SharedBuf::default();
|
|
||||||
let writer = FaultyLogSink {
|
|
||||||
buf: buf.clone(),
|
|
||||||
fail_write_after: Some(5),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut log = EventLog::for_test(writer, 0);
|
|
||||||
|
|
||||||
let err = log.append(sample_event("r1")).unwrap_err();
|
|
||||||
assert!(
|
|
||||||
matches!(err, AppendError::Failed(_)),
|
|
||||||
"a repairable rollback must not be reported fatal"
|
|
||||||
);
|
|
||||||
assert_eq!(log.last_seq(), 0, "cursor must stay put on a failed append");
|
|
||||||
assert!(
|
|
||||||
buf.snapshot().is_empty(),
|
|
||||||
"the partial prefix a failed write_all leaves behind must be truncated away"
|
|
||||||
);
|
|
||||||
|
|
||||||
let seq = log
|
|
||||||
.append(sample_event("r1"))
|
|
||||||
.expect("the same instance must be able to append again after a clean rollback");
|
|
||||||
assert_eq!(seq, 1);
|
|
||||||
assert_eq!(log.last_seq(), 1);
|
|
||||||
|
|
||||||
let bytes = buf.snapshot();
|
|
||||||
assert_eq!(
|
|
||||||
bytes.iter().filter(|&&b| b == b'\n').count(),
|
|
||||||
1,
|
|
||||||
"exactly one well-formed line, not a partial prefix glued to the recovered append"
|
|
||||||
);
|
|
||||||
|
|
||||||
let replayed = replay(&bytes);
|
|
||||||
assert_eq!(replayed.len(), 1);
|
|
||||||
assert_eq!(replayed[0].seq, log.last_seq());
|
|
||||||
let PersistedEvent::Approval { row } = &replayed[0].event else {
|
|
||||||
panic!("approval event");
|
|
||||||
};
|
|
||||||
assert_eq!(row.id, "r1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_rolls_back_when_flush_fails_after_a_full_write() {
|
|
||||||
let buf = SharedBuf::default();
|
|
||||||
let writer = FaultyLogSink {
|
|
||||||
buf: buf.clone(),
|
|
||||||
fail_flush_once: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut log = EventLog::for_test(writer, 0);
|
|
||||||
|
|
||||||
let err = log.append(sample_event("r1")).unwrap_err();
|
|
||||||
assert!(matches!(err, AppendError::Failed(_)));
|
|
||||||
assert_eq!(log.last_seq(), 0);
|
|
||||||
assert!(
|
|
||||||
buf.snapshot().is_empty(),
|
|
||||||
"a full-but-unflushed line must not survive uncounted by last_seq — it would \
|
|
||||||
silently shift every later seq by one"
|
|
||||||
);
|
|
||||||
|
|
||||||
let seq = log
|
|
||||||
.append(sample_event("r1"))
|
|
||||||
.expect("the same instance must recover once flush stops failing");
|
|
||||||
assert_eq!(seq, 1);
|
|
||||||
|
|
||||||
let bytes = buf.snapshot();
|
|
||||||
let replayed = replay(&bytes);
|
|
||||||
assert_eq!(replayed.len(), 1);
|
|
||||||
assert_eq!(replayed[0].seq, log.last_seq());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_reports_fatal_when_rollback_cannot_restore_consistency() {
|
|
||||||
let buf = SharedBuf::default();
|
|
||||||
let writer = FaultyLogSink {
|
|
||||||
buf,
|
|
||||||
fail_write_after: Some(3),
|
|
||||||
fail_truncate: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut log = EventLog::for_test(writer, 0);
|
|
||||||
|
|
||||||
let err = log.append(sample_event("r1")).unwrap_err();
|
|
||||||
assert!(
|
|
||||||
matches!(err, AppendError::Fatal { .. }),
|
|
||||||
"an unrepairable rollback must be surfaced as fatal, never silently treated as \
|
|
||||||
a clean recoverable failure"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
log.last_seq(),
|
|
||||||
0,
|
|
||||||
"a fatal append must not advance the cursor either"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,13 +42,11 @@ async fn fixture(ttl: Duration) -> Fixture {
|
|||||||
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
||||||
manager
|
manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -75,13 +75,11 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
|||||||
let s = h
|
let s = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd_path,
|
cwd: cwd_path,
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -50,13 +50,11 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ use tempfile::TempDir;
|
|||||||
|
|
||||||
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -29,7 +28,6 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
|||||||
branch: branch.into(),
|
branch: branch.into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{"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."}}}
|
|
||||||
@@ -4,17 +4,12 @@ mod common;
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::{Ipv4Addr, SocketAddr};
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
use std::path::Path;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::DaemonFixture;
|
use common::DaemonFixture;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_daemon::local_api;
|
use maestro_daemon::local_api;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||||
ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView,
|
|
||||||
WorktreeSpec,
|
|
||||||
};
|
|
||||||
use tempfile::TempDir;
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
@@ -91,649 +86,6 @@ fn ws_url(addr: SocketAddr, path: &str) -> String {
|
|||||||
format!("ws://{addr}{path}")
|
format!("ws://{addr}{path}")
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FakeAcp {
|
|
||||||
dir: TempDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FakeAcp {
|
|
||||||
fn new() -> Self {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let bin = dir.path().join("bin");
|
|
||||||
std::fs::create_dir_all(&bin).unwrap();
|
|
||||||
let adapter = bin.join("claude-code-acp");
|
|
||||||
std::fs::copy(
|
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py"),
|
|
||||||
&adapter,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
||||||
Self { dir }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env(&self) -> HashMap<String, String> {
|
|
||||||
HashMap::from([
|
|
||||||
(
|
|
||||||
"PATH".into(),
|
|
||||||
format!(
|
|
||||||
"{}:{}",
|
|
||||||
self.dir.path().join("bin").display(),
|
|
||||||
std::env::var("PATH").unwrap()
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"FAKE_ACP_LOG".into(),
|
|
||||||
self.dir.path().join("agent.log").display().to_string(),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pid(&self) -> u32 {
|
|
||||||
std::fs::read_to_string(self.dir.path().join("agent.log"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.lines()
|
|
||||||
.filter_map(|line| {
|
|
||||||
line.strip_prefix("start pid=")?
|
|
||||||
.split(' ')
|
|
||||||
.next()?
|
|
||||||
.parse()
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.next_back()
|
|
||||||
.expect("fake ACP started")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log(&self) -> String {
|
|
||||||
std::fs::read_to_string(self.dir.path().join("agent.log")).unwrap_or_default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
|
||||||
structured_session_with_env(h, fake.env()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
|
||||||
let id = h
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
view: SessionView::Structured,
|
|
||||||
cwd: "/tmp".into(),
|
|
||||||
command: "claude".into(),
|
|
||||||
env,
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
auto_approve: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.id;
|
|
||||||
wait_for_session_activity(h, id, ActivityState::Idle).await;
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
|
||||||
loop {
|
|
||||||
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
if sessions
|
|
||||||
.iter()
|
|
||||||
.find(|session| session.id == id)
|
|
||||||
.is_some_and(|session| session.activity == expected)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| panic!("session {id} did not reach {expected:?}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_ws_sends_snapshot_then_live_projection() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let id = structured_session(&h, &fake).await;
|
|
||||||
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let first = ws.next().await.unwrap().unwrap().into_text().unwrap();
|
|
||||||
let second = ws.next().await.unwrap().unwrap().into_text().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<serde_json::Value>(&first).unwrap()["type"],
|
|
||||||
"transcript_snapshot"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<serde_json::Value>(&second).unwrap()["type"],
|
|
||||||
"reduced_state"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/prompt"),
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!({"prompt": "hello"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
let live = tokio::time::timeout(Duration::from_secs(1), ws.next())
|
|
||||||
.await
|
|
||||||
.expect("projection update arrives on the subscribed stream")
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.into_text()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<serde_json::Value>(&live).unwrap()["type"],
|
|
||||||
"projection_update"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_attachments_preserve_bytes_and_reject_paths() {
|
|
||||||
let h = spawn("attachments").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let id = structured_session(&h, &fake).await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let endpoint = url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/attachments"),
|
|
||||||
);
|
|
||||||
let bytes = vec![0, 255, 10, 13];
|
|
||||||
let response = client
|
|
||||||
.post(&endpoint)
|
|
||||||
.query(&[("name", "sample.bin")])
|
|
||||||
.body(bytes.clone())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
let body: serde_json::Value = response.json().await.unwrap();
|
|
||||||
let path = body["path"].as_str().unwrap();
|
|
||||||
assert!(Path::new(path).is_absolute());
|
|
||||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
|
||||||
for name in ["../escape", "a/b", "a\\b", "..", ""] {
|
|
||||||
let response = client
|
|
||||||
.post(&endpoint)
|
|
||||||
.query(&[("name", name)])
|
|
||||||
.body("text")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 400, "{name}");
|
|
||||||
}
|
|
||||||
let response = client
|
|
||||||
.post(&endpoint)
|
|
||||||
.query(&[("name", "large.bin")])
|
|
||||||
.body(vec![0; 20 * 1024 * 1024 + 1])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 413);
|
|
||||||
let response = client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
|
|
||||||
))
|
|
||||||
.query(&[("name", "file.txt")])
|
|
||||||
.body("text")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let mut env = fake.env();
|
|
||||||
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "200".into());
|
|
||||||
let id = structured_session_with_env(&h, env).await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let first = client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/prompt"),
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!({"prompt": "first"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
let second = client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/prompt"),
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!({"prompt": "second"}))
|
|
||||||
.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");
|
|
||||||
|
|
||||||
let cancel = client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/cancel"),
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.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);
|
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
|
||||||
loop {
|
|
||||||
if fake.log().matches("recv session/prompt").count() == 2 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("both prompt commands reach ACP");
|
|
||||||
let log = fake.log();
|
|
||||||
let first_prompt = log.find("recv session/prompt").unwrap();
|
|
||||||
let cancel = log.find("recv session/cancel").unwrap();
|
|
||||||
let second_prompt = log.rfind("recv session/prompt").unwrap();
|
|
||||||
assert!(first_prompt < cancel && cancel < second_prompt, "{log}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_approval_is_secret_single_use_and_race_safe() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let mut env = fake.env();
|
|
||||||
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
|
|
||||||
let id = structured_session_with_env(&h, env).await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
ws.next().await.unwrap().unwrap();
|
|
||||||
ws.next().await.unwrap().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/prompt")
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!({"prompt":"needs permission"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.status(),
|
|
||||||
reqwest::StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
|
|
||||||
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
|
||||||
loop {
|
|
||||||
let event: serde_json::Value =
|
|
||||||
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
|
||||||
.unwrap();
|
|
||||||
if let Some(nonce) = event["state"]["pending_approval"]["nonce"].as_str() {
|
|
||||||
assert_eq!(event["transcript"]["row"]["kind"], "approval");
|
|
||||||
assert!(event["transcript"]["row"]["data"].get("nonce").is_none());
|
|
||||||
break nonce.to_owned();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
wait_for_session_activity(&h, id, ActivityState::AwaitingInput).await;
|
|
||||||
assert!(
|
|
||||||
!fake.log().contains(&nonce),
|
|
||||||
"daemon nonce leaked to ACP agent"
|
|
||||||
);
|
|
||||||
|
|
||||||
let path = format!("/api/sessions/{id}/structured/approvals/{nonce}");
|
|
||||||
let (a, b) = tokio::join!(
|
|
||||||
client
|
|
||||||
.post(url(h.addr, &path))
|
|
||||||
.json(&serde_json::json!("allow_once"))
|
|
||||||
.send(),
|
|
||||||
client
|
|
||||||
.post(url(h.addr, &path))
|
|
||||||
.json(&serde_json::json!("deny"))
|
|
||||||
.send(),
|
|
||||||
);
|
|
||||||
assert_eq!(a.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
assert_eq!(b.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
assert_eq!(
|
|
||||||
client
|
|
||||||
.post(url(h.addr, &path))
|
|
||||||
.json(&serde_json::json!("deny"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.status(),
|
|
||||||
reqwest::StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
|
||||||
loop {
|
|
||||||
if fake.log().contains("permission response") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let log = fake.log();
|
|
||||||
assert!(
|
|
||||||
log.contains("\"optionId\": \"once\"") || log.contains("\"optionId\": \"deny\""),
|
|
||||||
"{log}"
|
|
||||||
);
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), async {
|
|
||||||
loop {
|
|
||||||
let event: serde_json::Value =
|
|
||||||
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
|
||||||
.unwrap();
|
|
||||||
if event["state"]["pending_approval"].is_null() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("resolved approval clears the reduced state");
|
|
||||||
wait_for_session_activity(&h, id, ActivityState::Idle).await;
|
|
||||||
assert_eq!(
|
|
||||||
client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!(
|
|
||||||
"/api/sessions/{}/structured/approvals/{nonce}",
|
|
||||||
Uuid::new_v4()
|
|
||||||
)
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!("deny"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.status(),
|
|
||||||
reqwest::StatusCode::NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_prompt_failure_clears_the_turn() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let mut env = fake.env();
|
|
||||||
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
|
|
||||||
let id = structured_session_with_env(&h, env).await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let path = format!("/api/sessions/{id}/structured/prompt");
|
|
||||||
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
ws.next().await.unwrap().unwrap();
|
|
||||||
ws.next().await.unwrap().unwrap();
|
|
||||||
let first = client
|
|
||||||
.post(url(h.addr, &path))
|
|
||||||
.json(&serde_json::json!({"prompt":"fails"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
let mut saw_active_turn = false;
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), async {
|
|
||||||
while let Some(Ok(message)) = ws.next().await {
|
|
||||||
let event: serde_json::Value =
|
|
||||||
serde_json::from_str(&message.into_text().unwrap()).unwrap();
|
|
||||||
let turn = &event["state"]["turn"];
|
|
||||||
if turn["responding"] == true {
|
|
||||||
saw_active_turn = true;
|
|
||||||
}
|
|
||||||
if saw_active_turn && turn["responding"] == false && turn["thinking"] == false {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
panic!("structured websocket closed before the failed turn cleared");
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("failed turn clears promptly");
|
|
||||||
wait_for_session_activity(&h, id, ActivityState::Error).await;
|
|
||||||
let second = client
|
|
||||||
.post(url(h.addr, &path))
|
|
||||||
.json(&serde_json::json!({"prompt":"available"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(second.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
assert!(second.bytes().await.unwrap().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let prompt = |id| {
|
|
||||||
client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/prompt"),
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!({"prompt": "hello"}))
|
|
||||||
};
|
|
||||||
|
|
||||||
let unknown = prompt(Uuid::new_v4()).send().await.unwrap();
|
|
||||||
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
|
||||||
let error: ApiError = unknown.json().await.unwrap();
|
|
||||||
assert_eq!(error.error.code, "not_found");
|
|
||||||
|
|
||||||
let terminal = h
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
view: SessionView::Terminal,
|
|
||||||
cwd: "/tmp".into(),
|
|
||||||
command: "sh".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
auto_approve: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let non_structured = prompt(terminal.id).send().await.unwrap();
|
|
||||||
assert_eq!(non_structured.status(), reqwest::StatusCode::CONFLICT);
|
|
||||||
let error: ApiError = non_structured.json().await.unwrap();
|
|
||||||
assert_eq!(error.error.code, "not_structured");
|
|
||||||
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let dead = structured_session(&h, &fake).await;
|
|
||||||
h.manager.shutdown().await;
|
|
||||||
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
|
|
||||||
let unavailable = prompt(dead).send().await.unwrap();
|
|
||||||
assert_eq!(unavailable.status(), reqwest::StatusCode::CONFLICT);
|
|
||||||
let error: ApiError = unavailable.json().await.unwrap();
|
|
||||||
assert_eq!(error.error.code, "not_structured");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_replay_honors_cursors_and_limit() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let id = structured_session(&h, &fake).await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
|
||||||
loop {
|
|
||||||
let events: Vec<serde_json::Value> = client
|
|
||||||
.get(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/replay?since=0&limit=1"),
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
if !events.is_empty() {
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
assert_eq!(events[0]["cursor"], 1);
|
|
||||||
assert!(events[0].get("notification").is_none());
|
|
||||||
|
|
||||||
let invalid = client
|
|
||||||
.get(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/replay?since=0&before=2"),
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_replay_pages_projected_events_in_cursor_order() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let id = structured_session(&h, &fake).await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
unsafe { libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1) };
|
|
||||||
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
|
||||||
loop {
|
|
||||||
let events: Vec<serde_json::Value> = client
|
|
||||||
.get(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/replay?before=99&limit=2"),
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
if events.len() == 2 {
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
events
|
|
||||||
.iter()
|
|
||||||
.map(|event| event["cursor"].as_u64().unwrap())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![1, 2]
|
|
||||||
);
|
|
||||||
let boundary: Vec<serde_json::Value> = client
|
|
||||||
.get(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/replay?before=2&limit=10"),
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(boundary.len(), 1);
|
|
||||||
assert_eq!(boundary[0]["cursor"], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let unknown = client
|
|
||||||
.get(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{}/structured/replay", Uuid::new_v4()),
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
|
||||||
let terminal = h
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
view: SessionView::Terminal,
|
|
||||||
cwd: "/tmp".into(),
|
|
||||||
command: "sh".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
auto_approve: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let response = client
|
|
||||||
.get(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{}/structured/replay", terminal.id),
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
|
||||||
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let dead = structured_session(&h, &fake).await;
|
|
||||||
h.manager.shutdown().await;
|
|
||||||
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
|
|
||||||
let response = client
|
|
||||||
.get(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{dead}/structured/replay"),
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
|
||||||
let error: ApiError = response.json().await.unwrap();
|
|
||||||
assert_eq!(error.error.code, "not_structured");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn structured_ws_rejects_a_dead_session() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let id = structured_session(&h, &fake).await;
|
|
||||||
h.manager.shutdown().await;
|
|
||||||
assert_eq!(h.manager.status(id).await, Some(SessionStatus::Dead));
|
|
||||||
|
|
||||||
let err = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
|
||||||
.await
|
|
||||||
.expect_err("dead structured session must not upgrade");
|
|
||||||
let message = err.to_string();
|
|
||||||
assert!(
|
|
||||||
message.contains("409") || message.to_lowercase().contains("conflict"),
|
|
||||||
"{message}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn info_returns_daemon_identity() {
|
async fn info_returns_daemon_identity() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -822,13 +174,11 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
assert!(empty.is_empty());
|
assert!(empty.is_empty());
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
};
|
};
|
||||||
let created: Session = reqwest::Client::new()
|
let created: Session = reqwest::Client::new()
|
||||||
.post(url(h.addr, "/api/sessions"))
|
.post(url(h.addr, "/api/sessions"))
|
||||||
@@ -1003,9 +353,7 @@ async fn names_honors_count_and_caps_it() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gitroot_probes_path() {
|
async fn gitroot_probes_path() {
|
||||||
// #26: resolves the query against the daemon home dir and walks up for a
|
// #26: resolves the query against the daemon home dir and walks up for a
|
||||||
// `.git`. The tempdir itself has no `.git`, so the result must either be
|
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
||||||
// 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 h = spawn("naps-laptop").await;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let resp: serde_json::Value = reqwest::get(url(
|
let resp: serde_json::Value = reqwest::get(url(
|
||||||
@@ -1020,16 +368,7 @@ async fn gitroot_probes_path() {
|
|||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
|
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
||||||
// 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]
|
#[tokio::test]
|
||||||
@@ -1051,13 +390,11 @@ async fn patch_session_renames_it() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1095,13 +432,11 @@ async fn post_focus_stamps_last_focused_at() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1130,13 +465,11 @@ async fn delete_owned_session_kills_it() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1153,13 +486,11 @@ async fn delete_owned_session_kills_it() {
|
|||||||
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||||
h.manager
|
h.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1264,7 +595,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1274,7 +604,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1318,7 +647,6 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1328,7 +656,6 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1353,13 +680,11 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1390,39 +715,6 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
assert!(saw_hello, "PTY did not echo through the attach WS");
|
assert!(saw_hello, "PTY did not echo through the attach WS");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let id = structured_session(&h, &fake).await;
|
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
|
||||||
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
|
|
||||||
.json(&maestro_protocol::SendInputRequest {
|
|
||||||
text: "ignored".into(),
|
|
||||||
submit: false,
|
|
||||||
})
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
|
||||||
let error: ApiError = response.json().await.unwrap();
|
|
||||||
assert_eq!(error.error.code, "not_terminal");
|
|
||||||
assert_eq!(error.error.message, "session is not a terminal session");
|
|
||||||
|
|
||||||
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
|
|
||||||
.await
|
|
||||||
.expect_err("structured session must not upgrade to a PTY socket");
|
|
||||||
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
|
|
||||||
panic!("expected an HTTP handshake rejection, got {error}");
|
|
||||||
};
|
|
||||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
|
||||||
let body = response.body().as_deref().expect("ApiError response body");
|
|
||||||
let error: ApiError = serde_json::from_slice(body).unwrap();
|
|
||||||
assert_eq!(error.error.code, "not_terminal");
|
|
||||||
assert_eq!(error.error.message, "session is not a terminal session");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn attach_ws_for_unknown_session_returns_404() {
|
async fn attach_ws_for_unknown_session_returns_404() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -1456,13 +748,11 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
let created = h
|
let created = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1490,79 +780,6 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
|
||||||
let h = spawn("konishi").await;
|
|
||||||
let fake = FakeAcp::new();
|
|
||||||
let mut env = fake.env();
|
|
||||||
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "500".into());
|
|
||||||
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
|
|
||||||
.await
|
|
||||||
.expect("events ws");
|
|
||||||
assert!(matches!(
|
|
||||||
next_event(&mut ws).await,
|
|
||||||
maestro_protocol::CatalogEvent::Snapshot { .. }
|
|
||||||
));
|
|
||||||
|
|
||||||
let id = structured_session_with_env(&h, env).await;
|
|
||||||
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
|
||||||
panic!("expected structured SessionAdded")
|
|
||||||
};
|
|
||||||
assert_eq!(session.activity, ActivityState::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();
|
|
||||||
let prompt = client
|
|
||||||
.post(url(
|
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/prompt"),
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!({"prompt": "hello"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(prompt.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
let report = client
|
|
||||||
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
|
||||||
.json(&serde_json::json!({"state": "Error"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(report.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
wait_for_session_activity(&h, id, ActivityState::Active).await;
|
|
||||||
|
|
||||||
let mut activities = Vec::new();
|
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
|
||||||
loop {
|
|
||||||
if let maestro_protocol::CatalogEvent::SessionUpdated { session } =
|
|
||||||
next_event(&mut ws).await
|
|
||||||
{
|
|
||||||
activities.push(session.activity);
|
|
||||||
if session.activity == ActivityState::Idle {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("structured turn returns to idle");
|
|
||||||
assert!(
|
|
||||||
activities.contains(&ActivityState::Active),
|
|
||||||
"{activities:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!activities.contains(&ActivityState::Error),
|
|
||||||
"{activities:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
h.manager.kill(id, false).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
||||||
/// two clients connected at different times both get their own Snapshot.
|
/// two clients connected at different times both get their own Snapshot.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1575,13 +792,11 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
|||||||
|
|
||||||
h.manager
|
h.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -51,13 +51,11 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -64,13 +64,11 @@ impl Fx {
|
|||||||
|
|
||||||
fn request(&self, command: &str) -> CreateSessionRequest {
|
fn request(&self, command: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: self.tmp.path().to_path_buf(),
|
cwd: self.tmp.path().to_path_buf(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,11 @@ async fn create_then_kill_round_trip() {
|
|||||||
let session = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -65,13 +63,11 @@ async fn create_appends_to_spawn_history() {
|
|||||||
let session = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -96,13 +92,11 @@ async fn create_without_history_logs_nothing() {
|
|||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
fx.manager
|
fx.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -120,13 +114,11 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -161,7 +153,6 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
// The shell prints the marker before doing anything else and
|
// The shell prints the marker before doing anything else and
|
||||||
// then sleeps so the session stays alive. Before NAP-253 this
|
// then sleeps so the session stays alive. Before NAP-253 this
|
||||||
@@ -170,7 +161,6 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -210,7 +200,6 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
// Print a line, then switch to the alternate screen the way a
|
// Print a line, then switch to the alternate screen the way a
|
||||||
// full-screen program does on startup. Only the second marker is
|
// full-screen program does on startup. Only the second marker is
|
||||||
@@ -220,7 +209,6 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -270,7 +258,6 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
// Alternate screen, then button-event tracking (?1002) with SGR
|
// Alternate screen, then button-event tracking (?1002) with SGR
|
||||||
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
||||||
@@ -279,7 +266,6 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
|||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -325,13 +311,11 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -389,13 +373,11 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -441,13 +423,11 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -508,7 +488,6 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
|
|
||||||
fx.manager
|
fx.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -518,7 +497,6 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
branch: "feat/indicator".into(),
|
branch: "feat/indicator".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -602,7 +580,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -612,7 +589,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
branch: "feat/keep".into(),
|
branch: "feat/keep".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -641,7 +617,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -651,7 +626,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
|||||||
branch: "feat/guarded".into(),
|
branch: "feat/guarded".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -706,7 +680,6 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -716,7 +689,6 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
branch: "feat/pushed".into(),
|
branch: "feat/pushed".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -757,7 +729,6 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -767,7 +738,6 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/forced".into(),
|
branch: "feat/forced".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -804,7 +774,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -814,7 +783,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
branch: "feat/clear".into(),
|
branch: "feat/clear".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -846,7 +814,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -856,7 +823,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
branch: "feat/rusty".into(),
|
branch: "feat/rusty".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -889,7 +855,6 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -899,7 +864,6 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
branch: "feat/exited".into(),
|
branch: "feat/exited".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -952,7 +916,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -962,7 +925,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
branch: "feat/clean".into(),
|
branch: "feat/clean".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1014,13 +976,11 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1050,7 +1010,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
let first = fx
|
let first = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1060,7 +1019,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/one".into(),
|
branch: "feat/one".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create first");
|
.expect("create first");
|
||||||
@@ -1069,7 +1027,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
let second = fx
|
let second = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: inner_worktree.clone(),
|
cwd: inner_worktree.clone(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1079,7 +1036,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
branch: "feat/two".into(),
|
branch: "feat/two".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create second");
|
.expect("create second");
|
||||||
@@ -1109,13 +1065,11 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1149,13 +1103,11 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1176,13 +1128,11 @@ async fn set_focused_true_stamps_last_focused_at() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -1226,13 +1176,11 @@ async fn attaching_stamps_last_focused_at() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use std::collections::HashMap;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_daemon::sidecar::{PersistedEvent, SidecarStore};
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
use maestro_protocol::{Session, SessionStatus, SessionView, TranscriptRow, TranscriptRowKind};
|
use maestro_protocol::{Session, SessionStatus};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -32,8 +32,6 @@ fn sample(id: Uuid) -> Session {
|
|||||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
view: SessionView::Terminal,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,30 +66,15 @@ fn delete_removes_file_and_is_idempotent() {
|
|||||||
let s = sample(id);
|
let s = sample(id);
|
||||||
|
|
||||||
store.write(&s).unwrap();
|
store.write(&s).unwrap();
|
||||||
store.mark_for_recovery(id).unwrap();
|
|
||||||
assert!(store.path_for(id).exists());
|
assert!(store.path_for(id).exists());
|
||||||
assert!(store.recovery_path_for(id).exists());
|
|
||||||
|
|
||||||
store.delete(id).unwrap();
|
store.delete(id).unwrap();
|
||||||
assert!(!store.path_for(id).exists());
|
assert!(!store.path_for(id).exists());
|
||||||
assert!(!store.recovery_path_for(id).exists());
|
|
||||||
|
|
||||||
// second delete is a no-op
|
// second delete is a no-op
|
||||||
store.delete(id).unwrap();
|
store.delete(id).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recovery_marker_is_consumed_once() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
|
|
||||||
assert!(!store.take_recovery_marker(id).unwrap());
|
|
||||||
store.mark_for_recovery(id).unwrap();
|
|
||||||
assert!(store.take_recovery_marker(id).unwrap());
|
|
||||||
assert!(!store.take_recovery_marker(id).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -120,37 +103,6 @@ fn load_all_returns_empty_when_directory_missing() {
|
|||||||
assert!(store.load_all().unwrap().is_empty());
|
assert!(store.load_all().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_missing_view_key_reads_as_terminal() {
|
|
||||||
// A pre-#133 sidecar on disk has no `view` field at all.
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
|
|
||||||
let mut value = serde_json::to_value(sample(id)).unwrap();
|
|
||||||
value.as_object_mut().unwrap().remove("view");
|
|
||||||
std::fs::write(store.path_for(id), serde_json::to_vec(&value).unwrap()).unwrap();
|
|
||||||
|
|
||||||
let loaded = store.read(id).expect("read").expect("present");
|
|
||||||
assert_eq!(loaded.view, SessionView::Terminal);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sidecar_structured_view_round_trips() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let mut s = sample(id);
|
|
||||||
s.view = SessionView::Structured;
|
|
||||||
|
|
||||||
store.write(&s).expect("write");
|
|
||||||
let loaded = store.read(id).expect("read");
|
|
||||||
assert_eq!(
|
|
||||||
loaded.as_ref().map(|s| s.view),
|
|
||||||
Some(SessionView::Structured)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_creates_parent_directory() {
|
fn write_creates_parent_directory() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -161,237 +113,3 @@ fn write_creates_parent_directory() {
|
|||||||
assert!(nested.exists());
|
assert!(nested.exists());
|
||||||
assert!(store.path_for(s.id).exists());
|
assert!(store.path_for(s.id).exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- per-session event log (#135) ----
|
|
||||||
|
|
||||||
use maestro_acp::schema::{ContentBlock, SessionNotification, SessionUpdate, TextContent};
|
|
||||||
use maestro_daemon::sidecar::EventCursor;
|
|
||||||
|
|
||||||
fn note(text: &str) -> SessionNotification {
|
|
||||||
SessionNotification::new(
|
|
||||||
"acp-1",
|
|
||||||
SessionUpdate::AgentMessageChunk(maestro_acp::schema::ContentChunk::new(
|
|
||||||
ContentBlock::Text(TextContent::new(text)),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text_of(n: &SessionNotification) -> &str {
|
|
||||||
match &n.update {
|
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
|
||||||
ContentBlock::Text(t) => &t.text,
|
|
||||||
other => panic!("unexpected content {other:?}"),
|
|
||||||
},
|
|
||||||
other => panic!("unexpected update {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
|
||||||
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
|
|
||||||
panic!("expected ACP notification")
|
|
||||||
};
|
|
||||||
text_of(notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_assigns_line_position_as_seq_and_reads_back() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
|
|
||||||
let mut log = store.open_events(id).expect("open");
|
|
||||||
assert_eq!(log.last_seq(), 0);
|
|
||||||
assert_eq!(log.append(note("a")).unwrap(), 1);
|
|
||||||
assert_eq!(log.append(note("b")).unwrap(), 2);
|
|
||||||
assert_eq!(log.append(note("c")).unwrap(), 3);
|
|
||||||
assert_eq!(log.last_seq(), 3);
|
|
||||||
|
|
||||||
let path = store.events_path_for(id);
|
|
||||||
assert!(path.ends_with(format!("{id}.events.jsonl")), "{path:?}");
|
|
||||||
let raw = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(raw.lines().count(), 3, "one object per line:\n{raw}");
|
|
||||||
for line in raw.lines() {
|
|
||||||
let v: Value = serde_json::from_str(line).expect("each line is one JSON object");
|
|
||||||
assert_eq!(v["sessionId"], "acp-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
|
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
|
|
||||||
assert_eq!(
|
|
||||||
all.iter().map(event_text).collect::<Vec<_>>(),
|
|
||||||
vec!["a", "b", "c"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn since_reads_forward_and_before_reads_backward_with_limit() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let mut log = store.open_events(id).unwrap();
|
|
||||||
for i in 1..=6 {
|
|
||||||
log.append(note(&format!("n{i}"))).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// `since` is exclusive: what a client that already holds seq 2 needs.
|
|
||||||
let fwd = store.read_events(id, EventCursor::Since(2), 3).unwrap();
|
|
||||||
assert_eq!(fwd.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4, 5]);
|
|
||||||
// Past the end: nothing, not an error.
|
|
||||||
assert!(store
|
|
||||||
.read_events(id, EventCursor::Since(6), 3)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty());
|
|
||||||
|
|
||||||
// `before` is exclusive and pages backward: the `limit` events closest
|
|
||||||
// to the cursor, still in ascending seq order.
|
|
||||||
let back = store
|
|
||||||
.read_events(id, EventCursor::Before(Some(5)), 2)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(back.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4]);
|
|
||||||
// No cursor: the tail of the log, for a first paint.
|
|
||||||
let tail = store.read_events(id, EventCursor::Before(None), 2).unwrap();
|
|
||||||
assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![5, 6]);
|
|
||||||
// Paging past the start runs out cleanly.
|
|
||||||
let start = store
|
|
||||||
.read_events(id, EventCursor::Before(Some(2)), 10)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(start.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
|
|
||||||
assert!(store
|
|
||||||
.read_events(id, EventCursor::Before(Some(1)), 10)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_log_reads_as_empty() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
assert!(store
|
|
||||||
.read_events(id, EventCursor::Since(0), 10)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty());
|
|
||||||
assert!(store
|
|
||||||
.read_events(id, EventCursor::Before(None), 10)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_lines_are_skipped_but_keep_their_seq() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let mut log = store.open_events(id).unwrap();
|
|
||||||
log.append(note("first")).unwrap();
|
|
||||||
drop(log);
|
|
||||||
|
|
||||||
// Garbage in the middle and a line that is JSON but not a notification.
|
|
||||||
let path = store.events_path_for(id);
|
|
||||||
let mut raw = std::fs::read_to_string(&path).unwrap();
|
|
||||||
raw.push_str("{not json\n");
|
|
||||||
raw.push_str("{\"unrelated\": true}\n");
|
|
||||||
std::fs::write(&path, raw).unwrap();
|
|
||||||
let mut log = store.open_events(id).unwrap();
|
|
||||||
assert_eq!(log.last_seq(), 3);
|
|
||||||
assert_eq!(log.append(note("fourth")).unwrap(), 4);
|
|
||||||
|
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
|
|
||||||
assert_eq!(event_text(&all[1]), "fourth");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
|
|
||||||
// A hard kill mid-write leaves a partial line with no newline.
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let mut log = store.open_events(id).unwrap();
|
|
||||||
log.append(note("whole")).unwrap();
|
|
||||||
drop(log);
|
|
||||||
|
|
||||||
let path = store.events_path_for(id);
|
|
||||||
let mut raw = std::fs::read(&path).unwrap();
|
|
||||||
raw.extend_from_slice(br#"{"sessionId":"acp-1","update":{"sessionUpd"#);
|
|
||||||
std::fs::write(&path, &raw).unwrap();
|
|
||||||
|
|
||||||
// Reads: the partial line is skipped, nothing else is lost.
|
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
|
|
||||||
|
|
||||||
// Reopening terminates the partial line so the next append does not
|
|
||||||
// glue onto it; the partial keeps a seq of its own.
|
|
||||||
let mut log = store.open_events(id).unwrap();
|
|
||||||
assert_eq!(log.last_seq(), 2);
|
|
||||||
assert_eq!(log.append(note("after")).unwrap(), 3);
|
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
|
||||||
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
|
|
||||||
assert_eq!(event_text(&all[1]), "after");
|
|
||||||
let raw = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(raw.lines().count(), 3, "{raw}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delete_removes_the_event_log_and_load_all_ignores_it() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
store.write(&sample(id)).unwrap();
|
|
||||||
let mut log = store.open_events(id).unwrap();
|
|
||||||
log.append(note("x")).unwrap();
|
|
||||||
drop(log);
|
|
||||||
let legacy_approvals = dir.path().join(format!("{id}.approvals.jsonl"));
|
|
||||||
std::fs::write(&legacy_approvals, b"obsolete").unwrap();
|
|
||||||
assert!(store.events_path_for(id).exists());
|
|
||||||
|
|
||||||
let loaded = store.load_all().unwrap();
|
|
||||||
assert_eq!(loaded.len(), 1, "the .jsonl must not be read as a sidecar");
|
|
||||||
|
|
||||||
store.delete(id).unwrap();
|
|
||||||
assert!(!store.path_for(id).exists());
|
|
||||||
assert!(!store.events_path_for(id).exists());
|
|
||||||
assert!(!legacy_approvals.exists());
|
|
||||||
// Idempotent with the log already gone.
|
|
||||||
store.delete(id).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn approval_rows_share_event_cursors_with_legacy_acp_lines() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let store = SidecarStore::new(dir.path());
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let legacy = serde_json::to_vec(¬e("before")).unwrap();
|
|
||||||
std::fs::create_dir_all(dir.path()).unwrap();
|
|
||||||
std::fs::write(store.events_path_for(id), [legacy, b"\n".to_vec()].concat()).unwrap();
|
|
||||||
|
|
||||||
let mut log = store.open_events(id).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
log.append(PersistedEvent::Approval {
|
|
||||||
row: TranscriptRow {
|
|
||||||
id: "approval:secret".into(),
|
|
||||||
kind: TranscriptRowKind::Approval,
|
|
||||||
data: serde_json::json!({"tool": "bash"}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.unwrap(),
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert_eq!(log.append(note("after")).unwrap(), 3);
|
|
||||||
|
|
||||||
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
all.iter().map(|event| event.seq).collect::<Vec<_>>(),
|
|
||||||
[1, 2, 3]
|
|
||||||
);
|
|
||||||
assert!(matches!(all[1].event, PersistedEvent::Approval { .. }));
|
|
||||||
assert_eq!(event_text(&all[0]), "before");
|
|
||||||
assert_eq!(event_text(&all[2]), "after");
|
|
||||||
|
|
||||||
let since = store.read_events(id, EventCursor::Since(1), 1).unwrap();
|
|
||||||
assert_eq!(since[0].seq, 2);
|
|
||||||
let before = store
|
|
||||||
.read_events(id, EventCursor::Before(Some(3)), 1)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(before[0].seq, 2);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -405,17 +405,13 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `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]
|
#[test]
|
||||||
fn add_new_suffixes_branch_name_on_branch_collision() {
|
fn add_fails_when_branch_already_exists() {
|
||||||
let repo_dir = TempDir::new().unwrap();
|
let repo_dir = TempDir::new().unwrap();
|
||||||
let repo = repo_dir.path().to_path_buf();
|
let repo = repo_dir.path().to_path_buf();
|
||||||
init_repo(&repo);
|
init_repo(&repo);
|
||||||
|
|
||||||
// pre-create the branch, unused by any worktree.
|
// pre-create the branch
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
@@ -427,17 +423,9 @@ fn add_new_suffixes_branch_name_on_branch_collision() {
|
|||||||
let wt_root = TempDir::new().unwrap();
|
let wt_root = TempDir::new().unwrap();
|
||||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
|
|
||||||
let path = mgr
|
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
|
||||||
.add_new(&repo, "feat/dup")
|
let msg = format!("{err}");
|
||||||
.expect("sidesteps the branch collision");
|
assert!(msg.contains("git exited"), "{msg}");
|
||||||
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
|
|
||||||
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`
|
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
libc.workspace = true
|
|
||||||
maestro-protocol = { workspace = true, features = ["client"] }
|
maestro-protocol = { workspace = true, features = ["client"] }
|
||||||
maestro-testing.workspace = true
|
maestro-testing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -87,14 +87,6 @@ impl Daemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send the daemon the same SIGTERM systemd uses, without waiting for its
|
|
||||||
/// graceful HTTP shutdown to finish.
|
|
||||||
pub fn send_sigterm(&self) {
|
|
||||||
let child = self.child.as_ref().expect("daemon is running");
|
|
||||||
let result = unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGTERM) };
|
|
||||||
assert_eq!(result, 0, "send SIGTERM to daemon");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-spawn the daemon on the same port + state. The previous tmux
|
/// Re-spawn the daemon on the same port + state. The previous tmux
|
||||||
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
|
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
|
||||||
/// its sessions.
|
/// its sessions.
|
||||||
|
|||||||
+25
-162
@@ -15,7 +15,7 @@ use std::time::{Duration, Instant};
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_e2e::util::wait_for_async;
|
use maestro_e2e::util::wait_for_async;
|
||||||
use maestro_e2e::{Daemon, TestClient};
|
use maestro_e2e::{Daemon, TestClient};
|
||||||
use maestro_protocol::{CreateSessionRequest, SessionStatus, SessionView, WorktreeSpec};
|
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
|
|
||||||
@@ -27,71 +27,14 @@ async fn bring_up() -> (Daemon, TestClient) {
|
|||||||
|
|
||||||
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FakeAcp {
|
|
||||||
_dir: TempDir,
|
|
||||||
env: HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fake_acp(log: &std::path::Path) -> FakeAcp {
|
|
||||||
let dir = tempfile::tempdir().expect("fake adapter dir");
|
|
||||||
let adapter = dir.path().join("claude-code-acp");
|
|
||||||
std::fs::copy(
|
|
||||||
concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/../maestro-acp/tests/fake_acp_agent.py"
|
|
||||||
),
|
|
||||||
&adapter,
|
|
||||||
)
|
|
||||||
.expect("copy fake adapter");
|
|
||||||
let mut permissions = std::fs::metadata(&adapter)
|
|
||||||
.expect("adapter metadata")
|
|
||||||
.permissions();
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
permissions.set_mode(0o755);
|
|
||||||
}
|
|
||||||
std::fs::set_permissions(&adapter, permissions).expect("make fake adapter executable");
|
|
||||||
let env = HashMap::from([
|
|
||||||
(
|
|
||||||
"PATH".into(),
|
|
||||||
format!(
|
|
||||||
"{}:{}",
|
|
||||||
dir.path().display(),
|
|
||||||
std::env::var("PATH").unwrap_or_default()
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("FAKE_ACP_LOG".into(), log.to_string_lossy().into_owned()),
|
|
||||||
]);
|
|
||||||
FakeAcp { _dir: dir, env }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fake_acp_pid(log: &std::path::Path) -> Option<u32> {
|
|
||||||
std::fs::read_to_string(log).ok()?.lines().find_map(|line| {
|
|
||||||
line.strip_prefix("start pid=")?
|
|
||||||
.split(' ')
|
|
||||||
.next()?
|
|
||||||
.parse()
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pid_alive(pid: u32) -> bool {
|
|
||||||
std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
|
||||||
.map(|stat| !stat.contains(") Z "))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A worktree spec built through its wire shape. The daemon and clients ship
|
/// A worktree spec built through its wire shape. The daemon and clients ship
|
||||||
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
|
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
|
||||||
/// object carries only the fields the lifecycle actually honours.
|
/// object carries only the fields the lifecycle actually honours.
|
||||||
@@ -106,13 +49,11 @@ fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool)
|
|||||||
/// Spawn request carrying a worktree spec against `base`.
|
/// Spawn request carrying a worktree spec against `base`.
|
||||||
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: base.to_path_buf(),
|
cwd: base.to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||||
auto_approve: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,13 +143,11 @@ async fn kill_removes_clean_worktree() {
|
|||||||
init_repo(base.path());
|
init_repo(base.path());
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: base.path().to_path_buf(),
|
cwd: base.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||||
auto_approve: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -257,13 +196,11 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
|
|||||||
.join("unsaved");
|
.join("unsaved");
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: base.path().to_path_buf(),
|
cwd: base.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||||
auto_approve: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
@@ -317,13 +254,11 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
run_git(&["branch", "feature/existing"], base.path());
|
run_git(&["branch", "feature/existing"], base.path());
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: base.path().to_path_buf(),
|
cwd: base.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||||
auto_approve: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client
|
let s = client
|
||||||
@@ -388,11 +323,10 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
|||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #66/#77: a second spawn onto the same branch used to be rejected outright;
|
/// #66: a second spawn onto a branch that already has a worktree is rejected
|
||||||
/// since the branch-collision suffixing landed, it succeeds instead — the
|
/// with a structured worktree error; the first worktree stays intact.
|
||||||
/// second worktree lands on `<branch>-2` and the first stays untouched.
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn second_spawn_on_same_branch_gets_suffixed() {
|
async fn second_spawn_on_same_branch_is_rejected() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
let base = TempDir::new().unwrap();
|
||||||
@@ -402,33 +336,28 @@ async fn second_spawn_on_same_branch_gets_suffixed() {
|
|||||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||||
.await
|
.await
|
||||||
.expect("first spawn");
|
.expect("first spawn");
|
||||||
let second = client
|
let dup = client
|
||||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||||
.await
|
.await;
|
||||||
.expect("second spawn suffixes instead of rejecting");
|
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 suffixed = base.path().join("worktrees").join("feature").join("dup-2");
|
// Exactly one registration, one directory, still usable.
|
||||||
assert_eq!(
|
// Count branch records only: the worktree path now contains the branch
|
||||||
second.cwd, suffixed,
|
// name too (`worktrees/feature/dup`), so a substring match over every line
|
||||||
"second worktree must land on the suffixed path"
|
// would count the same tree twice.
|
||||||
);
|
let holders = git_worktree_list(base.path())
|
||||||
assert!(suffixed.is_dir());
|
.iter()
|
||||||
|
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
|
||||||
// Both registrations exist: the original branch, untouched, and the
|
.count();
|
||||||
// suffixed one the second spawn created.
|
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
||||||
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!(
|
assert!(
|
||||||
base.path()
|
base.path()
|
||||||
.join("worktrees")
|
.join("worktrees")
|
||||||
@@ -439,8 +368,7 @@ async fn second_spawn_on_same_branch_gets_suffixed() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let _origin = throwaway_origin(base.path());
|
let _origin = throwaway_origin(base.path());
|
||||||
client.kill_session(first.id).await.expect("kill first");
|
client.kill_session(first.id).await.expect("kill");
|
||||||
client.kill_session(second.id).await.expect("kill second");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||||
@@ -826,13 +754,11 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
|
|
||||||
let _dirty = client
|
let _dirty = client
|
||||||
.spawn_session(&CreateSessionRequest {
|
.spawn_session(&CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: dirty_repo.path().to_path_buf(),
|
cwd: dirty_repo.path().to_path_buf(),
|
||||||
command: "sh -c 'echo unsaved > scratch.txt'".into(),
|
command: "sh -c 'echo unsaved > scratch.txt'".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("spawn dirty");
|
.expect("spawn dirty");
|
||||||
@@ -876,13 +802,11 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
|
|
||||||
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
|
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||||
auto_approve: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,67 +985,6 @@ async fn daemon_restart_recovers_session() {
|
|||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn sigterm_stops_structured_child_before_events_ws_drains() {
|
|
||||||
let (mut daemon, client) = bring_up().await;
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let log = cwd.path().join("fake-acp.log");
|
|
||||||
let fake = fake_acp(&log);
|
|
||||||
let session = client
|
|
||||||
.spawn_session(&CreateSessionRequest {
|
|
||||||
view: SessionView::Structured,
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
|
||||||
command: "claude".into(),
|
|
||||||
env: fake.env.clone(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
auto_approve: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("spawn structured session");
|
|
||||||
let ws_url = daemon.client().events_url().expect("events URL");
|
|
||||||
let (_events_ws, _) = tokio_tungstenite::connect_async(ws_url)
|
|
||||||
.await
|
|
||||||
.expect("events WS handshake");
|
|
||||||
wait_for_async("fake ACP starts", Duration::from_secs(5), || {
|
|
||||||
let log = log.clone();
|
|
||||||
async move { fake_acp_pid(&log).is_some() }
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let pid = fake_acp_pid(&log).expect("fake ACP pid");
|
|
||||||
|
|
||||||
daemon.send_sigterm();
|
|
||||||
|
|
||||||
wait_for_async(
|
|
||||||
"fake ACP exits after SIGTERM",
|
|
||||||
Duration::from_secs(5),
|
|
||||||
|| async { !pid_alive(pid) },
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let sidecar_path = daemon
|
|
||||||
.state_dir()
|
|
||||||
.join("sessions")
|
|
||||||
.join(format!("{}.json", session.id));
|
|
||||||
wait_for_async(
|
|
||||||
"dead structured session sidecar",
|
|
||||||
Duration::from_secs(5),
|
|
||||||
|| {
|
|
||||||
let sidecar_path = sidecar_path.clone();
|
|
||||||
async move {
|
|
||||||
std::fs::read(&sidecar_path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|json| {
|
|
||||||
serde_json::from_slice::<maestro_protocol::Session>(&json).ok()
|
|
||||||
})
|
|
||||||
.is_some_and(|saved| saved.status == SessionStatus::Dead)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
daemon.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn concurrent_spawns_each_get_unique_ids() {
|
async fn concurrent_spawns_each_get_unique_ids() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crate::routes::{self, UrlError};
|
|||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||||
SetFocusRequest, StructuredApprovalDecision, StructuredPromptRequest,
|
SetFocusRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -105,11 +105,6 @@ impl DaemonClient {
|
|||||||
Ok(routes::attach_url(&self.base, id)?)
|
Ok(routes::attach_url(&self.base, id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `WS /api/structured/:id` URL for this daemon.
|
|
||||||
pub fn structured_url(&self, id: Uuid) -> Result<String, ClientError> {
|
|
||||||
Ok(routes::structured_url(&self.base, id)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- typed calls --------------------------------------------------------
|
// -- typed calls --------------------------------------------------------
|
||||||
|
|
||||||
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
||||||
@@ -184,57 +179,6 @@ impl DaemonClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send one prompt to a structured session. The daemon rejects a second
|
|
||||||
/// prompt while the preceding ACP turn is still active.
|
|
||||||
pub async fn send_structured_prompt(&self, id: Uuid, prompt: &str) -> Result<(), ClientError> {
|
|
||||||
let req = StructuredPromptRequest {
|
|
||||||
prompt: prompt.to_string(),
|
|
||||||
};
|
|
||||||
let resp = self
|
|
||||||
.send(
|
|
||||||
Method::POST,
|
|
||||||
&routes::structured_prompt(id),
|
|
||||||
&[],
|
|
||||||
Some(&req),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
check(resp).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel the structured session's active ACP turn.
|
|
||||||
pub async fn cancel_structured_turn(&self, id: Uuid) -> Result<(), ClientError> {
|
|
||||||
let resp = self
|
|
||||||
.send(
|
|
||||||
Method::POST,
|
|
||||||
&routes::structured_cancel(id),
|
|
||||||
&[],
|
|
||||||
None::<&()>,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
check(resp).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the currently pending structured-session permission request.
|
|
||||||
pub async fn resolve_structured_approval(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
nonce: &str,
|
|
||||||
decision: StructuredApprovalDecision,
|
|
||||||
) -> Result<(), ClientError> {
|
|
||||||
let resp = self
|
|
||||||
.send(
|
|
||||||
Method::POST,
|
|
||||||
&routes::structured_approval(id, nonce),
|
|
||||||
&[],
|
|
||||||
Some(&decision),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
check(resp).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||||
self.get(routes::INFO, &[]).await
|
self.get(routes::INFO, &[]).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,32 +166,45 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
|||||||
cur
|
cur
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Command wrappers that run another program: the program is what follows.
|
||||||
|
const WRAPPERS: [&str; 8] = [
|
||||||
|
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
|
||||||
|
];
|
||||||
|
|
||||||
/// The program a command line runs, basenamed and stripped of its arguments —
|
/// The program a command line runs, basenamed and stripped of its arguments —
|
||||||
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
||||||
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
||||||
/// `npx`, `pnpm dlx`, …) are looked through, and quoting/escaping is honoured
|
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
|
||||||
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
|
|
||||||
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
|
|
||||||
/// substitution, an unterminated quote) falls back to the naive first word
|
|
||||||
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
|
|
||||||
pub fn command_program(command: &str) -> String {
|
pub fn command_program(command: &str) -> String {
|
||||||
match resolve(command) {
|
let words: Vec<&str> = command.split_whitespace().collect();
|
||||||
Some(r) => match r.words.get(r.program_index) {
|
let mut i = 0;
|
||||||
Some(word) => basename(word).to_string(),
|
while i < words.len() {
|
||||||
// Nothing but wrappers and assignments: say what was there
|
let word = words[i];
|
||||||
// rather than rendering an empty fragment.
|
if is_env_assignment(word) {
|
||||||
None => command.trim().to_string(),
|
i += 1;
|
||||||
},
|
continue;
|
||||||
None => legacy_first_word(command),
|
}
|
||||||
|
let wrapper = basename(word).to_lowercase();
|
||||||
|
if WRAPPERS.contains(&wrapper.as_str()) {
|
||||||
|
i += 1;
|
||||||
|
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match words.get(i) {
|
||||||
|
Some(word) => basename(word).to_string(),
|
||||||
|
// Nothing but wrappers and assignments: say what was there rather than
|
||||||
|
// rendering an empty fragment.
|
||||||
|
None => command.trim().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
||||||
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
||||||
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
||||||
/// `claude-code-acp` — the daemon's actual structured-session child — is
|
|
||||||
/// deliberately absent: the daemon launches it directly and ignores
|
|
||||||
/// `req.command`, so it is never itself a valid user-facing launch form.
|
|
||||||
const BY_PROGRAM: [(&str, &str); 17] = [
|
const BY_PROGRAM: [(&str, &str); 17] = [
|
||||||
("claude", "claude"),
|
("claude", "claude"),
|
||||||
("claude-code", "claude"),
|
("claude-code", "claude"),
|
||||||
@@ -213,500 +226,29 @@ const BY_PROGRAM: [(&str, &str); 17] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
||||||
/// `opencode`, ...), or `None` when the program is not one we have a mark for
|
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
|
||||||
/// — including a command line [`resolve`] would only guess at. The command
|
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
|
||||||
/// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
|
/// the same tools the web's `splitCommand().tool` does.
|
||||||
/// tools the web's `splitCommand().tool` does.
|
|
||||||
pub fn command_tool(command: &str) -> Option<&'static str> {
|
pub fn command_tool(command: &str) -> Option<&'static str> {
|
||||||
let r = resolve(command)?;
|
let program = command_program(command).to_lowercase();
|
||||||
let program = basename(r.words.get(r.program_index)?).to_lowercase();
|
|
||||||
BY_PROGRAM
|
BY_PROGRAM
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(name, _)| *name == program)
|
.find(|(name, _)| *name == program)
|
||||||
.map(|(_, tool)| *tool)
|
.map(|(_, tool)| *tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a command line resolves to an agent Maestro can run as a
|
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||||
/// structured (ACP) session — Claude Code or Codex, the two adapters
|
fn is_env_assignment(word: &str) -> bool {
|
||||||
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
|
let mut chars = word.chars();
|
||||||
/// 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() {
|
match chars.next() {
|
||||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||||
_ => return false,
|
_ => return false,
|
||||||
}
|
}
|
||||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
let rest: String = chars.collect();
|
||||||
|
match rest.split_once('=') {
|
||||||
|
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn basename(word: &str) -> &str {
|
fn basename(word: &str) -> &str {
|
||||||
@@ -726,7 +268,6 @@ mod tests {
|
|||||||
|
|
||||||
fn session(cwd: &str) -> Session {
|
fn session(cwd: &str) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
|
||||||
id: Uuid::from_u128(1),
|
id: Uuid::from_u128(1),
|
||||||
cwd: PathBuf::from(cwd),
|
cwd: PathBuf::from(cwd),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
@@ -743,7 +284,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,132 +495,38 @@ mod tests {
|
|||||||
"claude"
|
"claude"
|
||||||
);
|
);
|
||||||
assert_eq!(command_program(" zsh -l "), "zsh");
|
assert_eq!(command_program(" zsh -l "), "zsh");
|
||||||
}
|
assert_eq!(command_program("hx src/main.rs"), "hx");
|
||||||
|
|
||||||
/// The #199 resolver contract: every case in the shared parity fixture,
|
|
||||||
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
|
|
||||||
/// resolver regression in either language fails its own suite.
|
|
||||||
#[test]
|
|
||||||
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]
|
#[test]
|
||||||
fn structured_view_support_follows_the_resolved_program() {
|
fn command_program_looks_through_paths_env_and_wrappers() {
|
||||||
assert!(supports_structured_view("claude --resume"));
|
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
|
||||||
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
|
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
|
||||||
assert!(supports_structured_view("npx claude-code"));
|
assert_eq!(command_program("npx opencode"), "opencode");
|
||||||
assert!(!supports_structured_view("opencode"));
|
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
|
||||||
assert!(!supports_structured_view("bash -l"));
|
}
|
||||||
assert!(!supports_structured_view("claude-code-acp"));
|
|
||||||
|
#[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,33 +116,6 @@ pub struct Session {
|
|||||||
/// sorts a never-focused session after every session that has one.
|
/// sorts a never-focused session after every session that has one.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_focused_at: Option<DateTime<Utc>>,
|
pub last_focused_at: Option<DateTime<Utc>>,
|
||||||
/// Whether this session's process is a plain terminal (PTY bytes through
|
|
||||||
/// tmux) or a structured agent driven over ACP rows (#133). Fixed at
|
|
||||||
/// spawn time and never changes for the life of the session. Defaults to
|
|
||||||
/// `Terminal` on the wire so every existing client and payload from a
|
|
||||||
/// daemon that predates the field keeps working untouched.
|
|
||||||
#[serde(default)]
|
|
||||||
pub view: SessionView,
|
|
||||||
/// 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
|
|
||||||
/// the hook-reported Claude Code conversation id (#161): that one is what
|
|
||||||
/// `claude --resume` takes, this one is what the ACP adapter takes, and
|
|
||||||
/// the two are minted by different processes with different lifetimes.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub acp_session_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A session's process shape (#133): a plain terminal, or a structured agent
|
|
||||||
/// driven over ACP rows. Chosen at spawn and immutable for the session's
|
|
||||||
/// life. `Terminal` is the default — every sidecar and `CreateSessionRequest`
|
|
||||||
/// written before this field existed meant a terminal session.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
pub enum SessionView {
|
|
||||||
#[default]
|
|
||||||
Terminal,
|
|
||||||
Structured,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||||
@@ -156,9 +129,9 @@ pub enum SessionStatus {
|
|||||||
Dead,
|
Dead,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session activity, orthogonal to [`SessionStatus`] (NAP-340). Terminal
|
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||||
/// sessions use pane/process inference and agent reports; structured sessions
|
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
||||||
/// use their ACP turn lifecycle (docs/SIDEBAR.md).
|
/// inspection — never by parsing the terminal byte stream.
|
||||||
///
|
///
|
||||||
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
||||||
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
||||||
@@ -166,19 +139,16 @@ pub enum SessionStatus {
|
|||||||
/// forgiven.
|
/// forgiven.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||||
pub enum ActivityState {
|
pub enum ActivityState {
|
||||||
/// Producing output, the foreground process is busy, or an ACP turn is active.
|
/// Producing output, or the foreground process is busy.
|
||||||
Active,
|
Active,
|
||||||
/// Foreground process alive but quiescent. The forward-compatible default.
|
/// Foreground process alive but quiescent. The forward-compatible default.
|
||||||
#[default]
|
#[default]
|
||||||
Idle,
|
Idle,
|
||||||
/// Blocked on terminal input or waiting for an ACP approval.
|
/// Blocked on a `read()` from the pty — waiting on the user.
|
||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
/// An agent reported a failure, or a structured session/turn failed.
|
/// The agent running in the session reported a failure (#102). Never
|
||||||
|
/// inferred by the daemon; only set when an agent reports it (#105).
|
||||||
Error,
|
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 {
|
impl<'de> Deserialize<'de> for ActivityState {
|
||||||
@@ -191,7 +161,6 @@ impl<'de> Deserialize<'de> for ActivityState {
|
|||||||
"Active" => Self::Active,
|
"Active" => Self::Active,
|
||||||
"AwaitingInput" => Self::AwaitingInput,
|
"AwaitingInput" => Self::AwaitingInput,
|
||||||
"Error" => Self::Error,
|
"Error" => Self::Error,
|
||||||
"Starting" => Self::Starting,
|
|
||||||
// Known or not, any other spelling degrades to the neutral default
|
// Known or not, any other spelling degrades to the neutral default
|
||||||
_ => Self::Idle,
|
_ => Self::Idle,
|
||||||
})
|
})
|
||||||
@@ -230,10 +199,9 @@ where
|
|||||||
"Idle" => Ok(ActivityState::Idle),
|
"Idle" => Ok(ActivityState::Idle),
|
||||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||||
"Error" => Ok(ActivityState::Error),
|
"Error" => Ok(ActivityState::Error),
|
||||||
"Starting" => Ok(ActivityState::Starting),
|
|
||||||
other => Err(D::Error::unknown_variant(
|
other => Err(D::Error::unknown_variant(
|
||||||
other,
|
other,
|
||||||
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
&["Active", "Idle", "AwaitingInput", "Error"],
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,15 +352,6 @@ pub struct CreateSessionRequest {
|
|||||||
pub metadata: HashMap<String, Value>,
|
pub metadata: HashMap<String, Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worktree: Option<WorktreeSpec>,
|
pub worktree: Option<WorktreeSpec>,
|
||||||
/// The process shape to spawn (#133). Defaulted so every existing client
|
|
||||||
/// and `curl` against the API keeps requesting a `Terminal` session
|
|
||||||
/// 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.
|
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
||||||
@@ -532,11 +491,6 @@ pub struct SpawnPreset {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
/// When `true`, the daemon auto-approves every ACP permission request
|
|
||||||
/// for structured sessions spawned from this preset instead of surfacing
|
|
||||||
/// them to the client.
|
|
||||||
#[serde(default)]
|
|
||||||
pub auto_approve: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build provenance of the running daemon: the commit it was compiled
|
/// Build provenance of the running daemon: the commit it was compiled
|
||||||
@@ -769,150 +723,6 @@ pub enum CatalogEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Structured-session projections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// One client-neutral item in a structured session transcript.
|
|
||||||
///
|
|
||||||
/// `data` preserves the ACP payload without committing clients to an ACP
|
|
||||||
/// dependency. Rendering choices, including markdown and tool-card treatment,
|
|
||||||
/// stay with the client.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct TranscriptRow {
|
|
||||||
/// Stable within one session, so later deltas can address this row.
|
|
||||||
pub id: String,
|
|
||||||
pub kind: TranscriptRowKind,
|
|
||||||
pub data: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The semantic shape of a [`TranscriptRow`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TranscriptRowKind {
|
|
||||||
User,
|
|
||||||
AgentText,
|
|
||||||
AgentThought,
|
|
||||||
ToolCall,
|
|
||||||
ToolDiff,
|
|
||||||
Approval,
|
|
||||||
Plan,
|
|
||||||
Notice,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A complete transcript at a durable event-log position.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct TranscriptSnapshot {
|
|
||||||
pub cursor: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub rows: Vec<TranscriptRow>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One durable, client-neutral replay step from a structured session.
|
|
||||||
///
|
|
||||||
/// Results are ordered by increasing `cursor`. `since` returns the first
|
|
||||||
/// matching steps; `before` returns the final matching steps, still increasing.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct StructuredReplayEvent {
|
|
||||||
/// Durable ACP-log position represented by this projected step.
|
|
||||||
pub cursor: u64,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub transcript: Option<TranscriptDelta>,
|
|
||||||
pub state: ReducedState,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A change to a transcript row.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub enum TranscriptDelta {
|
|
||||||
Append { row: TranscriptRow },
|
|
||||||
Patch { row: TranscriptRow },
|
|
||||||
Remove { id: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-turn indicators derived from the ACP stream.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct TurnState {
|
|
||||||
#[serde(default)]
|
|
||||||
pub responding: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
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.
|
|
||||||
///
|
|
||||||
/// Values whose shape ACP owns are carried as JSON so this wire protocol stays
|
|
||||||
/// forward-compatible when ACP adds fields or variants.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ReducedState {
|
|
||||||
#[serde(default)]
|
|
||||||
pub turn: TurnState,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub pending_approval: Option<Value>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub plan: Option<Value>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage: Option<Value>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub context: Option<Value>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub model: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub mode: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub config_options: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Server messages sent over a structured-session WebSocket.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub enum StructuredEvent {
|
|
||||||
TranscriptSnapshot {
|
|
||||||
snapshot: TranscriptSnapshot,
|
|
||||||
},
|
|
||||||
ReducedState {
|
|
||||||
state: ReducedState,
|
|
||||||
},
|
|
||||||
ProjectionUpdate {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
transcript: Option<TranscriptDelta>,
|
|
||||||
state: ReducedState,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HTTP body for `POST /api/sessions/:id/structured/prompt`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
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")]
|
|
||||||
pub enum StructuredApprovalDecision {
|
|
||||||
AllowOnce,
|
|
||||||
AllowForSession,
|
|
||||||
Deny,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Client ↔ Daemon control messages (text frames on the attach WS)
|
// Client ↔ Daemon control messages (text frames on the attach WS)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1052,8 +862,6 @@ mod tests {
|
|||||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
view: SessionView::Terminal,
|
|
||||||
acp_session_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1265,8 +1073,6 @@ mod tests {
|
|||||||
branch: "feat/foo".into(),
|
branch: "feat/foo".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
view: SessionView::Terminal,
|
|
||||||
auto_approve: false,
|
|
||||||
};
|
};
|
||||||
assert_eq!(req, round_trip(&req));
|
assert_eq!(req, round_trip(&req));
|
||||||
}
|
}
|
||||||
@@ -1281,74 +1087,6 @@ mod tests {
|
|||||||
assert!(req.worktree.is_none());
|
assert!(req.worktree.is_none());
|
||||||
assert!(req.env.is_empty());
|
assert!(req.env.is_empty());
|
||||||
assert!(req.metadata.is_empty());
|
assert!(req.metadata.is_empty());
|
||||||
assert_eq!(req.view, SessionView::Terminal);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_view_defaults_to_terminal_on_legacy_payload() {
|
|
||||||
// A pre-#133 daemon or client omits `view`; it must deserialize to
|
|
||||||
// `Terminal` rather than failing, and `Structured` round-trips.
|
|
||||||
let json = serde_json::json!({
|
|
||||||
"id": Uuid::from_u128(1),
|
|
||||||
"cwd": "/tmp",
|
|
||||||
"command": "bash",
|
|
||||||
"status": "Running",
|
|
||||||
"created_at": "2026-05-24T12:00:00Z"
|
|
||||||
});
|
|
||||||
let s: Session = serde_json::from_value(json).unwrap();
|
|
||||||
assert_eq!(s.view, SessionView::Terminal);
|
|
||||||
|
|
||||||
let mut structured = sample_session();
|
|
||||||
structured.view = SessionView::Structured;
|
|
||||||
assert_eq!(structured, round_trip(&structured));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structured_projection_types_round_trip_and_default() {
|
|
||||||
let row = TranscriptRow {
|
|
||||||
id: "tool:read-1".into(),
|
|
||||||
kind: TranscriptRowKind::ToolCall,
|
|
||||||
data: serde_json::json!({"name": "vendor.read", "title": "Read"}),
|
|
||||||
};
|
|
||||||
let snapshot = TranscriptSnapshot {
|
|
||||||
cursor: 7,
|
|
||||||
rows: vec![row.clone()],
|
|
||||||
};
|
|
||||||
assert_eq!(snapshot, round_trip(&snapshot));
|
|
||||||
assert_eq!(
|
|
||||||
TranscriptDelta::Patch { row: row.clone() },
|
|
||||||
round_trip(&TranscriptDelta::Patch { row })
|
|
||||||
);
|
|
||||||
|
|
||||||
let state: ReducedState = serde_json::from_value(serde_json::json!({})).unwrap();
|
|
||||||
assert_eq!(state, ReducedState::default());
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(TranscriptRowKind::AgentThought).unwrap(),
|
|
||||||
serde_json::json!("agent_thought")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acp_session_id_defaults_to_none_and_round_trips() {
|
|
||||||
// A payload from before #134 carries no `acp_session_id`; it must
|
|
||||||
// read as `None`, stay off the wire while unset, and round-trip
|
|
||||||
// once the adapter has handed one back.
|
|
||||||
let json = serde_json::json!({
|
|
||||||
"id": Uuid::from_u128(1),
|
|
||||||
"cwd": "/tmp",
|
|
||||||
"command": "bash",
|
|
||||||
"status": "Running",
|
|
||||||
"created_at": "2026-05-24T12:00:00Z"
|
|
||||||
});
|
|
||||||
let s: Session = serde_json::from_value(json).unwrap();
|
|
||||||
assert_eq!(s.acp_session_id, None);
|
|
||||||
let serialized = serde_json::to_value(&s).unwrap();
|
|
||||||
assert!(serialized.get("acp_session_id").is_none());
|
|
||||||
|
|
||||||
let mut structured = sample_session();
|
|
||||||
structured.view = SessionView::Structured;
|
|
||||||
structured.acp_session_id = Some("sess_01HZX".into());
|
|
||||||
assert_eq!(structured, round_trip(&structured));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A client that predates the new/existing discriminator sends no
|
/// A client that predates the new/existing discriminator sends no
|
||||||
|
|||||||
@@ -71,41 +71,6 @@ pub fn attach(id: Uuid) -> String {
|
|||||||
format!("{API_PREFIX}/attach/{id}")
|
format!("{API_PREFIX}/attach/{id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `WS /api/structured/:id` — transcript/state snapshots and live deltas.
|
|
||||||
pub fn structured(id: Uuid) -> String {
|
|
||||||
format!("{API_PREFIX}/structured/{id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /api/sessions/:id/structured/replay` — cursor-bounded ACP history.
|
|
||||||
pub fn structured_replay(id: Uuid) -> String {
|
|
||||||
format!("{SESSIONS}/{id}/structured/replay")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/structured/prompt` — send an ACP prompt.
|
|
||||||
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}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Axum-style patterns for the two parameterised routes, so the server
|
/// Axum-style patterns for the two parameterised routes, so the server
|
||||||
/// registers the same paths [`session`] and [`attach`] build.
|
/// registers the same paths [`session`] and [`attach`] build.
|
||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
@@ -115,13 +80,6 @@ pub mod patterns {
|
|||||||
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
|
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
|
||||||
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
|
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
|
||||||
pub const ATTACH: &str = "/api/attach/:id";
|
pub const ATTACH: &str = "/api/attach/:id";
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||||
@@ -171,11 +129,6 @@ pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
|||||||
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
|
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full `WS /api/structured/:id` URL for a daemon reachable at `http_base`.
|
|
||||||
pub fn structured_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
|
||||||
Ok(format!("{}{}", ws_base(http_base)?, structured(id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -225,10 +178,6 @@ mod tests {
|
|||||||
events_url("http://host:7070/").unwrap(),
|
events_url("http://host:7070/").unwrap(),
|
||||||
"ws://host:7070/api/events"
|
"ws://host:7070/api/events"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
structured_url("http://host:7070/", id()).unwrap(),
|
|
||||||
"ws://host:7070/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -257,22 +206,6 @@ mod tests {
|
|||||||
attach(id()),
|
attach(id()),
|
||||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
structured(id()),
|
|
||||||
"/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
structured_replay(id()),
|
|
||||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/replay"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
structured_prompt(id()),
|
|
||||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/prompt"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
structured_cancel(id()),
|
|
||||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/cancel"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
||||||
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
@@ -307,22 +240,6 @@ mod tests {
|
|||||||
patterns::ATTACH.trim_end_matches(":id"),
|
patterns::ATTACH.trim_end_matches(":id"),
|
||||||
attach(id()).trim_end_matches(&id().to_string())
|
attach(id()).trim_end_matches(&id().to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
patterns::STRUCTURED.trim_end_matches(":id"),
|
|
||||||
structured(id()).trim_end_matches(&id().to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
patterns::STRUCTURED_REPLAY.trim_end_matches(":id/structured/replay"),
|
|
||||||
session(id()).trim_end_matches(&id().to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
patterns::STRUCTURED_PROMPT.trim_end_matches(":id/structured/prompt"),
|
|
||||||
session(id()).trim_end_matches(&id().to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
patterns::STRUCTURED_CANCEL.trim_end_matches(":id/structured/cancel"),
|
|
||||||
session(id()).trim_end_matches(&id().to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -341,9 +258,5 @@ mod tests {
|
|||||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||||
}
|
}
|
||||||
assert!(attach(id()).starts_with(API_PREFIX));
|
assert!(attach(id()).starts_with(API_PREFIX));
|
||||||
assert!(structured(id()).starts_with(API_PREFIX));
|
|
||||||
assert!(structured_replay(id()).starts_with(API_PREFIX));
|
|
||||||
assert!(structured_prompt(id()).starts_with(API_PREFIX));
|
|
||||||
assert!(structured_cancel(id()).starts_with(API_PREFIX));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ use chrono::Utc;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
|
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
||||||
PROTOCOL_VERSION,
|
|
||||||
};
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
@@ -32,7 +31,6 @@ use uuid::Uuid;
|
|||||||
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
|
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
|
||||||
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
|
||||||
id,
|
id,
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
@@ -49,7 +47,6 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +58,6 @@ struct Inner {
|
|||||||
forced_error: Option<(u16, ApiError)>,
|
forced_error: Option<(u16, ApiError)>,
|
||||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||||
/// Every ACP prompt sent to a structured session, in order.
|
|
||||||
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -95,7 +90,6 @@ impl MockDaemon {
|
|||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -182,16 +176,6 @@ impl MockDaemon {
|
|||||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
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 {
|
pub fn session_count(&self) -> usize {
|
||||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
}
|
}
|
||||||
@@ -280,26 +264,6 @@ async fn send_input(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
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 {
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
if let Some(r) = forced(&s) {
|
if let Some(r) = forced(&s) {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ thiserror.workspace = true
|
|||||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
unicode-width.workspace = true
|
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
vt100.workspace = true
|
vt100.workspace = true
|
||||||
|
|
||||||
|
|||||||
+13
-501
@@ -11,17 +11,13 @@
|
|||||||
//! `dispatch` / the overlay routing.
|
//! `dispatch` / the overlay routing.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Write;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||||
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, SessionView,
|
|
||||||
StructuredApprovalDecision,
|
|
||||||
};
|
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -42,8 +38,6 @@ use crate::render::{draw, RegionMap, RenderInputs};
|
|||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
use crate::structured_pane::StructuredPane;
|
|
||||||
use crate::structured_task::{StructuredHandle, StructuredTask};
|
|
||||||
use crate::text_input::TextInput;
|
use crate::text_input::TextInput;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
@@ -55,42 +49,11 @@ enum OverlayDecision {
|
|||||||
Pass,
|
Pass,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
|
|
||||||
let encoded = base64_encode(text.as_bytes());
|
|
||||||
let mut output = std::io::stdout();
|
|
||||||
write!(output, "\x1b]52;c;{encoded}\x07")?;
|
|
||||||
output.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base64_encode(bytes: &[u8]) -> String {
|
|
||||||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
|
||||||
for chunk in bytes.chunks(3) {
|
|
||||||
let bits = (u32::from(chunk[0]) << 16)
|
|
||||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
|
||||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
|
||||||
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
|
|
||||||
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
|
|
||||||
output.push(if chunk.len() > 1 {
|
|
||||||
TABLE[((bits >> 6) & 0x3f) as usize] as char
|
|
||||||
} else {
|
|
||||||
'='
|
|
||||||
});
|
|
||||||
output.push(if chunk.len() > 2 {
|
|
||||||
TABLE[(bits & 0x3f) as usize] as char
|
|
||||||
} else {
|
|
||||||
'='
|
|
||||||
});
|
|
||||||
}
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
cfg: TuiConfig,
|
cfg: TuiConfig,
|
||||||
home_dir: String,
|
home_dir: String,
|
||||||
catalog: CatalogStore,
|
catalog: CatalogStore,
|
||||||
emulators: SessionEmulators,
|
emulators: SessionEmulators,
|
||||||
structured: HashMap<Uuid, StructuredHandle>,
|
|
||||||
state: AppState,
|
state: AppState,
|
||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
@@ -200,18 +163,6 @@ pub enum Effect {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
StructuredPrompt {
|
|
||||||
id: Uuid,
|
|
||||||
prompt: String,
|
|
||||||
},
|
|
||||||
StructuredCancel {
|
|
||||||
id: Uuid,
|
|
||||||
},
|
|
||||||
StructuredApproval {
|
|
||||||
id: Uuid,
|
|
||||||
nonce: String,
|
|
||||||
decision: StructuredApprovalDecision,
|
|
||||||
},
|
|
||||||
/// Fetch presets / projects / names for a freshly opened form (#64).
|
/// Fetch presets / projects / names for a freshly opened form (#64).
|
||||||
LoadDraft,
|
LoadDraft,
|
||||||
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
||||||
@@ -263,7 +214,6 @@ impl App {
|
|||||||
home_dir: std::env::var("HOME").unwrap_or_default(),
|
home_dir: std::env::var("HOME").unwrap_or_default(),
|
||||||
catalog,
|
catalog,
|
||||||
emulators: SessionEmulators::default(),
|
emulators: SessionEmulators::default(),
|
||||||
structured: HashMap::new(),
|
|
||||||
state: AppState::new(),
|
state: AppState::new(),
|
||||||
draft: None,
|
draft: None,
|
||||||
help,
|
help,
|
||||||
@@ -371,15 +321,6 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.handle_structured_mouse(&key) {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(effects) = self.handle_structured_key(&key) {
|
|
||||||
self.perform(effects).await;
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(effect) = self.handle_pane_scroll(&key) {
|
if let Some(effect) = self.handle_pane_scroll(&key) {
|
||||||
self.perform(effect.into_iter().collect()).await;
|
self.perform(effect.into_iter().collect()).await;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -458,61 +399,6 @@ impl App {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transcript blocks are pointer targets: click selects the whole block,
|
|
||||||
/// gives it the selection surface, and copies its complete text.
|
|
||||||
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
|
|
||||||
let InputKey::Mouse {
|
|
||||||
kind,
|
|
||||||
region: Region::Pane,
|
|
||||||
row,
|
|
||||||
..
|
|
||||||
} = key
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let Some(sid) = self.state.active_session() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(handle) = self.structured.get(&sid) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(pane) = self.region.pane else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let offset = usize::from(row.saturating_sub(pane.y));
|
|
||||||
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
|
|
||||||
if offset >= usize::from(transcript_height) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let mut state = handle.state.lock().expect("structured state mutex");
|
|
||||||
let rendered_line = state.scroll.saturating_add(offset);
|
|
||||||
if !state.select_rendered_line(rendered_line) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let Some(id) = state.selected.as_deref() else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
if *kind == MouseKind::Hover {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let text = crate::structured_pane::copy_text(transcript);
|
|
||||||
drop(state);
|
|
||||||
if text.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
match copy_to_terminal_clipboard(&text) {
|
|
||||||
Ok(()) => self.state.toast("copied transcript block"),
|
|
||||||
Err(error) => self.state.toast(format!("copy failed: {error}")),
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||||
/// the daemon.
|
/// the daemon.
|
||||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||||
@@ -533,44 +419,6 @@ impl App {
|
|||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Effect::StructuredPrompt { id, prompt } => {
|
|
||||||
if let Err(error) = self.daemon.send_structured_prompt(id, &prompt).await {
|
|
||||||
if let Some(handle) = self.structured.get(&id) {
|
|
||||||
handle
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.expect("structured state mutex")
|
|
||||||
.prompt_failed(prompt, error.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Effect::StructuredCancel { id } => {
|
|
||||||
if let Err(error) = self.daemon.cancel_structured_turn(id).await {
|
|
||||||
if let Some(handle) = self.structured.get(&id) {
|
|
||||||
handle.state.lock().expect("structured state mutex").error =
|
|
||||||
Some(error.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Effect::StructuredApproval {
|
|
||||||
id,
|
|
||||||
nonce,
|
|
||||||
decision,
|
|
||||||
} => {
|
|
||||||
if let Err(error) = self
|
|
||||||
.daemon
|
|
||||||
.resolve_structured_approval(id, &nonce, decision)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
if let Some(handle) = self.structured.get(&id) {
|
|
||||||
handle
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.expect("structured state mutex")
|
|
||||||
.approval_failed(&nonce, error.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -715,13 +563,13 @@ impl App {
|
|||||||
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
||||||
// Escape hatch (#156): the literal byte, not the bar.
|
// Escape hatch (#156): the literal byte, not the bar.
|
||||||
Action::SendLiteralCtrlK => {
|
Action::SendLiteralCtrlK => {
|
||||||
effects.extend(self.forward_literal_to_terminal(&InputKey::ctrl_char('k')));
|
effects.extend(self.forward_to_session(&InputKey::ctrl_char('k')));
|
||||||
}
|
}
|
||||||
// Escape hatch (#158): the leader's own byte, built from the
|
// Escape hatch (#158): the leader's own byte, built from the
|
||||||
// configured leader rather than a hardcoded key.
|
// configured leader rather than a hardcoded key.
|
||||||
Action::SendLiteralLeader => {
|
Action::SendLiteralLeader => {
|
||||||
let leader = self.cfg.leader.clone();
|
let leader = self.cfg.leader.clone();
|
||||||
effects.extend(self.forward_literal_to_terminal(&leader));
|
effects.extend(self.forward_to_session(&leader));
|
||||||
}
|
}
|
||||||
Action::OpenSpawnModal => {
|
Action::OpenSpawnModal => {
|
||||||
self.open_spawn_modal();
|
self.open_spawn_modal();
|
||||||
@@ -739,7 +587,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
// A previewed session is already attached: committing
|
// A previewed session is already attached: committing
|
||||||
// to it keeps that stream rather than reopening one.
|
// to it keeps that stream rather than reopening one.
|
||||||
if !self.is_attached(sid) {
|
if self.emulators.get(sid).is_none() {
|
||||||
effects.push(Effect::Attach(sid));
|
effects.push(Effect::Attach(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,9 +595,6 @@ impl App {
|
|||||||
// preview budget.
|
// preview budget.
|
||||||
self.preview_lru.retain(|s| *s != sid);
|
self.preview_lru.retain(|s| *s != sid);
|
||||||
self.state.attach_to_focused(sid);
|
self.state.attach_to_focused(sid);
|
||||||
if self.session_view(sid) == SessionView::Structured {
|
|
||||||
self.state.mode = Mode::Normal;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::KillHighlighted => {
|
Action::KillHighlighted => {
|
||||||
@@ -818,7 +663,7 @@ impl App {
|
|||||||
self.state.preview = Some(sid);
|
self.state.preview = Some(sid);
|
||||||
// Set outside the input path, so nothing else marks the frame stale.
|
// Set outside the input path, so nothing else marks the frame stale.
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
let fresh = !self.is_attached(sid);
|
let fresh = self.emulators.get(sid).is_none();
|
||||||
if fresh {
|
if fresh {
|
||||||
self.preview_lru.retain(|s| *s != sid);
|
self.preview_lru.retain(|s| *s != sid);
|
||||||
self.preview_lru.push(sid);
|
self.preview_lru.push(sid);
|
||||||
@@ -887,27 +732,15 @@ impl App {
|
|||||||
|
|
||||||
/// Open a stream for `sid` if one isn't already running.
|
/// Open a stream for `sid` if one isn't already running.
|
||||||
fn attach(&mut self, sid: Uuid) {
|
fn attach(&mut self, sid: Uuid) {
|
||||||
self.attach_with_view(sid, self.session_view(sid));
|
if self.emulators.get(sid).is_some() {
|
||||||
}
|
|
||||||
|
|
||||||
fn attach_with_view(&mut self, sid: Uuid, view: SessionView) {
|
|
||||||
if self.is_attached(sid) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match view {
|
match self.daemon.attach_url(sid) {
|
||||||
SessionView::Terminal => match self.daemon.attach_url(sid) {
|
Ok(ws_url) => {
|
||||||
Ok(ws_url) => {
|
let handle = SessionTask::spawn(ws_url, 24, 80);
|
||||||
let handle = SessionTask::spawn(ws_url, 24, 80);
|
self.emulators.insert(sid, handle);
|
||||||
self.emulators.insert(sid, handle);
|
}
|
||||||
}
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||||
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
|
||||||
},
|
|
||||||
SessionView::Structured => match self.daemon.structured_url(sid) {
|
|
||||||
Ok(ws_url) => {
|
|
||||||
self.structured.insert(sid, StructuredTask::spawn(ws_url));
|
|
||||||
}
|
|
||||||
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,26 +748,9 @@ impl App {
|
|||||||
/// dropping its vt100 parser.
|
/// dropping its vt100 parser.
|
||||||
fn release_session(&mut self, sid: Uuid) {
|
fn release_session(&mut self, sid: Uuid) {
|
||||||
self.emulators.remove(sid);
|
self.emulators.remove(sid);
|
||||||
if let Some(handle) = self.structured.remove(&sid) {
|
|
||||||
handle.end();
|
|
||||||
}
|
|
||||||
self.last_sent_size.remove(&sid);
|
self.last_sent_size.remove(&sid);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_view(&self, sid: Uuid) -> SessionView {
|
|
||||||
self.catalog
|
|
||||||
.snapshot()
|
|
||||||
.sessions
|
|
||||||
.iter()
|
|
||||||
.find(|session| session.id == sid)
|
|
||||||
.map(|session| session.view)
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_attached(&self, sid: Uuid) -> bool {
|
|
||||||
self.emulators.get(sid).is_some() || self.structured.contains_key(&sid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the session under the sidebar highlight, falling back to the
|
/// Return the session under the sidebar highlight, falling back to the
|
||||||
/// first listed session so the user can attach without arrowing first.
|
/// first listed session so the user can attach without arrowing first.
|
||||||
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
||||||
@@ -1001,16 +817,6 @@ impl App {
|
|||||||
/// attached to send it to.
|
/// attached to send it to.
|
||||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
let sid = self.state.active_session()?;
|
let sid = self.state.active_session()?;
|
||||||
if let Some(handle) = self.structured.get(&sid) {
|
|
||||||
let mut state = handle.state.lock().expect("structured state mutex");
|
|
||||||
if matches!(key, InputKey::Enter) {
|
|
||||||
return state
|
|
||||||
.begin_prompt()
|
|
||||||
.map(|prompt| Effect::StructuredPrompt { id: sid, prompt });
|
|
||||||
}
|
|
||||||
state.edit_composer(key);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let handle = self.emulators.get(sid)?;
|
let handle = self.emulators.get(sid)?;
|
||||||
let bytes = encode_key(key)?;
|
let bytes = encode_key(key)?;
|
||||||
handle
|
handle
|
||||||
@@ -1021,86 +827,6 @@ impl App {
|
|||||||
Some(Effect::Forward { id: sid, bytes })
|
Some(Effect::Forward { id: sid, bytes })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn forward_literal_to_terminal(&mut self, key: &InputKey) -> Option<Effect> {
|
|
||||||
let sid = self.state.active_session()?;
|
|
||||||
if self.session_view(sid) == SessionView::Structured {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.forward_to_session(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_structured_key(&mut self, key: &InputKey) -> Option<Vec<Effect>> {
|
|
||||||
if self.state.focus != Focus::Pane || self.state.preview.is_some() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let sid = self.state.active_session()?;
|
|
||||||
let handle = self.structured.get(&sid)?;
|
|
||||||
let mut state = handle.state.lock().expect("structured state mutex");
|
|
||||||
if self.state.mode == Mode::Insert {
|
|
||||||
if matches!(key, InputKey::Esc) && state.turn_active() {
|
|
||||||
return Some(vec![Effect::StructuredCancel { id: sid }]);
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let effects = match key {
|
|
||||||
InputKey::Char {
|
|
||||||
c: 'j',
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
| InputKey::Down => {
|
|
||||||
state.select(1);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
InputKey::Char {
|
|
||||||
c: 'k',
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
| InputKey::Up => {
|
|
||||||
state.select(-1);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
InputKey::Enter => {
|
|
||||||
state.toggle_selected();
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
InputKey::Esc => {
|
|
||||||
if state.turn_active() {
|
|
||||||
vec![Effect::StructuredCancel { id: sid }]
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InputKey::Char {
|
|
||||||
c: key @ ('a' | 'A' | 'd'),
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
..
|
|
||||||
} => state
|
|
||||||
.approval_decision(*key)
|
|
||||||
.map(|(nonce, decision)| Effect::StructuredApproval {
|
|
||||||
id: sid,
|
|
||||||
nonce,
|
|
||||||
decision,
|
|
||||||
})
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
InputKey::PageUp => {
|
|
||||||
state.scroll_by(self.pane_page_rows() as isize);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
InputKey::PageDown => {
|
|
||||||
state.scroll_by(-(self.pane_page_rows() as isize));
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(effects)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pane_page_rows(&self) -> usize {
|
fn pane_page_rows(&self) -> usize {
|
||||||
self.region
|
self.region
|
||||||
.pane
|
.pane
|
||||||
@@ -1113,14 +839,6 @@ impl App {
|
|||||||
let Some(sid) = self.state.active_session() else {
|
let Some(sid) = self.state.active_session() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(handle) = self.structured.get(&sid) {
|
|
||||||
handle
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.expect("structured state mutex")
|
|
||||||
.scroll_by(rows);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(handle) = self.emulators.get(sid) else {
|
let Some(handle) = self.emulators.get(sid) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1154,14 +872,6 @@ impl App {
|
|||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let sid = self.state.active_session()?;
|
let sid = self.state.active_session()?;
|
||||||
if let Some(handle) = self.structured.get(&sid) {
|
|
||||||
handle
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.expect("structured state mutex")
|
|
||||||
.scroll_by(lines);
|
|
||||||
return Some(None);
|
|
||||||
}
|
|
||||||
let handle = self.emulators.get(sid)?;
|
let handle = self.emulators.get(sid)?;
|
||||||
let pane = self.region.pane?;
|
let pane = self.region.pane?;
|
||||||
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||||
@@ -1584,10 +1294,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.draft = None;
|
self.draft = None;
|
||||||
self.state.complete_spawn(sid);
|
self.state.complete_spawn(sid);
|
||||||
if session.view == SessionView::Structured {
|
self.attach(sid);
|
||||||
self.state.mode = Mode::Normal;
|
|
||||||
}
|
|
||||||
self.attach_with_view(sid, session.view);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// A failed POST belongs in the form it came from (#64), not a
|
// A failed POST belongs in the form it came from (#64), not a
|
||||||
@@ -1648,11 +1355,6 @@ impl App {
|
|||||||
self.emulators
|
self.emulators
|
||||||
.get(sid)
|
.get(sid)
|
||||||
.map(|h| (sid, h.revision(), h.state()))
|
.map(|h| (sid, h.revision(), h.state()))
|
||||||
.or_else(|| {
|
|
||||||
self.structured
|
|
||||||
.get(&sid)
|
|
||||||
.map(|h| (sid, h.revision(), h.connection()))
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
FrameKey {
|
FrameKey {
|
||||||
catalog: self.catalog.version(),
|
catalog: self.catalog.version(),
|
||||||
@@ -1714,17 +1416,6 @@ impl App {
|
|||||||
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let structured_pane = shown_sid.and_then(|sid| {
|
|
||||||
let handle = self.structured.get(&sid)?;
|
|
||||||
let session = snap.sessions.iter().find(|session| session.id == sid)?;
|
|
||||||
Some(StructuredPane {
|
|
||||||
handle: handle.clone(),
|
|
||||||
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
|
||||||
mode: self.state.mode,
|
|
||||||
session_status: session.status,
|
|
||||||
activity_detail: session.activity_detail.clone(),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let pane_title = match shown_sid {
|
let pane_title = match shown_sid {
|
||||||
Some(sid) => {
|
Some(sid) => {
|
||||||
let name = snap
|
let name = snap
|
||||||
@@ -1748,7 +1439,6 @@ impl App {
|
|||||||
home_dir: &self.home_dir,
|
home_dir: &self.home_dir,
|
||||||
pane_title: &pane_title,
|
pane_title: &pane_title,
|
||||||
pane: pane.as_ref(),
|
pane: pane.as_ref(),
|
||||||
structured_pane: structured_pane.as_ref(),
|
|
||||||
state: &self.state,
|
state: &self.state,
|
||||||
draft: self.draft.as_ref(),
|
draft: self.draft.as_ref(),
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
@@ -1995,12 +1685,6 @@ async fn fetch_branches(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clipboard_payload_uses_standard_base64() {
|
|
||||||
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
|
||||||
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
|
|
||||||
}
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::draft::DraftField;
|
use crate::draft::DraftField;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
@@ -2198,7 +1882,6 @@ mod tests {
|
|||||||
sessions: ids
|
sessions: ids
|
||||||
.iter()
|
.iter()
|
||||||
.map(|id| Session {
|
.map(|id| Session {
|
||||||
view: Default::default(),
|
|
||||||
id: *id,
|
id: *id,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -2215,7 +1898,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
@@ -2381,7 +2063,6 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
sessions: vec![Session {
|
sessions: vec![Session {
|
||||||
view: Default::default(),
|
|
||||||
id: sid,
|
id: sid,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -2398,7 +2079,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
@@ -2410,7 +2090,6 @@ mod tests {
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{Session, SessionStatus};
|
use maestro_protocol::{Session, SessionStatus};
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
|
||||||
id: sid,
|
id: sid,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
@@ -2427,7 +2106,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3099,13 +2777,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
app.dispatch(Action::OpenSpawnModal);
|
app.dispatch(Action::OpenSpawnModal);
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: std::path::PathBuf::from("/srv/work"),
|
cwd: std::path::PathBuf::from("/srv/work"),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
auto_approve: false,
|
|
||||||
};
|
};
|
||||||
app.submit_spawn(req).await;
|
app.submit_spawn(req).await;
|
||||||
|
|
||||||
@@ -3122,56 +2798,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn create_response_selects_transport_before_catalog_event() {
|
|
||||||
use axum::routing::post;
|
|
||||||
use axum::Json;
|
|
||||||
|
|
||||||
async fn spawn_session(
|
|
||||||
Json(req): Json<CreateSessionRequest>,
|
|
||||||
) -> Json<maestro_protocol::Session> {
|
|
||||||
let id = match req.view {
|
|
||||||
SessionView::Terminal => Uuid::from_u128(187),
|
|
||||||
SessionView::Structured => Uuid::from_u128(188),
|
|
||||||
};
|
|
||||||
let mut session = test_session(id, req.cwd.to_str().unwrap());
|
|
||||||
session.view = req.view;
|
|
||||||
session.command = req.command;
|
|
||||||
Json(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
let router =
|
|
||||||
axum::Router::new().route(maestro_protocol::routes::SESSIONS, post(spawn_session));
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
||||||
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
|
|
||||||
|
|
||||||
let mut app = make_app_at(&base);
|
|
||||||
let request = |view| CreateSessionRequest {
|
|
||||||
view,
|
|
||||||
cwd: std::path::PathBuf::from("/srv/work"),
|
|
||||||
command: "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;
|
|
||||||
assert!(app.catalog.snapshot().sessions.is_empty());
|
|
||||||
assert!(app.emulators.get(terminal_id).is_some());
|
|
||||||
assert!(!app.structured.contains_key(&terminal_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The full data load against a live daemon stub: presets replace the
|
/// The full data load against a live daemon stub: presets replace the
|
||||||
/// built-ins, projects land sorted, and the first generated name seeds
|
/// built-ins, projects land sorted, and the first generated name seeds
|
||||||
/// the field.
|
/// the field.
|
||||||
@@ -3569,7 +3195,6 @@ mod tests {
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||||
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
|
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
|
||||||
view: Default::default(),
|
|
||||||
id: Uuid::from_u128(n),
|
id: Uuid::from_u128(n),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -3589,7 +3214,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: focused,
|
last_focused_at: focused,
|
||||||
acp_session_id: None,
|
|
||||||
};
|
};
|
||||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
let older = Utc::now() - chrono::Duration::seconds(60);
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
@@ -3851,7 +3475,6 @@ mod tests {
|
|||||||
let sid = Uuid::from_u128(7);
|
let sid = Uuid::from_u128(7);
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
sessions: vec![Session {
|
sessions: vec![Session {
|
||||||
view: Default::default(),
|
|
||||||
id: sid,
|
id: sid,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -3869,7 +3492,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::KillHighlighted);
|
app.dispatch(Action::KillHighlighted);
|
||||||
@@ -3889,7 +3511,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
sessions: vec![Session {
|
sessions: vec![Session {
|
||||||
view: Default::default(),
|
|
||||||
id: sid,
|
id: sid,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -3906,7 +3527,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4064,112 +3684,4 @@ mod tests {
|
|||||||
assert!(app.command_bar.is_none());
|
assert!(app.command_bar.is_none());
|
||||||
assert!(app.state.overlay().is_none());
|
assert!(app.state.overlay().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn structured_app(sid: Uuid) -> App {
|
|
||||||
let mut app = app_with_highlighted_session(sid);
|
|
||||||
let mut session = app.catalog.snapshot().sessions[0].clone();
|
|
||||||
session.view = SessionView::Structured;
|
|
||||||
app.catalog
|
|
||||||
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
|
||||||
app.structured.insert(
|
|
||||||
sid,
|
|
||||||
StructuredHandle::test_handle(crate::structured_task::StructuredState::default()),
|
|
||||||
);
|
|
||||||
app.state.attach_to_focused(sid);
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn session_view_selects_the_structured_connection_and_cleanup_stops_it() {
|
|
||||||
let sid = Uuid::from_u128(141);
|
|
||||||
let mut app = app_with_highlighted_session(sid);
|
|
||||||
let mut session = app.catalog.snapshot().sessions[0].clone();
|
|
||||||
session.view = SessionView::Structured;
|
|
||||||
app.catalog
|
|
||||||
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
|
||||||
|
|
||||||
app.attach(sid);
|
|
||||||
assert!(app.structured.contains_key(&sid));
|
|
||||||
assert!(app.emulators.get(sid).is_none());
|
|
||||||
let handle = app.structured.get(&sid).unwrap().clone();
|
|
||||||
app.release_session(sid);
|
|
||||||
assert_eq!(
|
|
||||||
handle.connection(),
|
|
||||||
crate::session_task::SessionState::Ended
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structured_normal_mode_routes_navigation_expansion_and_approval_by_row() {
|
|
||||||
use maestro_protocol::{ReducedState, TranscriptRow, TranscriptRowKind};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
let sid = Uuid::from_u128(141);
|
|
||||||
let mut app = structured_app(sid);
|
|
||||||
app.dispatch(Action::AttachHighlighted);
|
|
||||||
assert_eq!(app.state.focus, Focus::Pane);
|
|
||||||
assert_eq!(app.state.mode, Mode::Normal);
|
|
||||||
let handle = app.structured.get(&sid).unwrap().clone();
|
|
||||||
{
|
|
||||||
let mut state = handle.state.lock().unwrap();
|
|
||||||
state.rows = vec![
|
|
||||||
TranscriptRow {
|
|
||||||
id: "text".into(),
|
|
||||||
kind: TranscriptRowKind::AgentText,
|
|
||||||
data: json!({"content":{"type":"text","text":"hello"}}),
|
|
||||||
},
|
|
||||||
TranscriptRow {
|
|
||||||
id: "approval".into(),
|
|
||||||
kind: TranscriptRowKind::Approval,
|
|
||||||
data: json!({"options":[{"kind":"allow_once"}]}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.selected = Some("text".into());
|
|
||||||
state.reduced = ReducedState {
|
|
||||||
pending_approval: Some(json!({"nonce":"n"})),
|
|
||||||
..ReducedState::default()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
app.handle_structured_key(&InputKey::char('j')),
|
|
||||||
Some(vec![])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
app.handle_structured_key(&InputKey::char('a')),
|
|
||||||
Some(vec![Effect::StructuredApproval {
|
|
||||||
id: sid,
|
|
||||||
nonce: "n".into(),
|
|
||||||
decision: StructuredApprovalDecision::AllowOnce,
|
|
||||||
}])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
handle.state.lock().unwrap().selected.as_deref(),
|
|
||||||
Some("approval")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structured_insert_mode_edits_submits_and_cancels_without_pty_bytes() {
|
|
||||||
let sid = Uuid::from_u128(141);
|
|
||||||
let mut app = structured_app(sid);
|
|
||||||
assert!(app.emulators.get(sid).is_none());
|
|
||||||
app.forward_to_session(&InputKey::char('h'));
|
|
||||||
app.forward_to_session(&InputKey::char('i'));
|
|
||||||
assert_eq!(
|
|
||||||
app.forward_to_session(&InputKey::Enter),
|
|
||||||
Some(Effect::StructuredPrompt {
|
|
||||||
id: sid,
|
|
||||||
prompt: "hi".into(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
app.handle_structured_key(&InputKey::Esc),
|
|
||||||
Some(vec![Effect::StructuredCancel { id: sid }])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
app.forward_literal_to_terminal(&app.cfg.leader.clone()),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,6 @@ mod tests {
|
|||||||
|
|
||||||
fn session(id: u128, command: &str) -> Session {
|
fn session(id: u128, command: &str) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
@@ -224,7 +223,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -793,7 +793,6 @@ mod tests {
|
|||||||
|
|
||||||
fn session(n: u128, command: &str) -> Session {
|
fn session(n: u128, command: &str) -> Session {
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
|
||||||
id: Uuid::from_u128(n),
|
id: Uuid::from_u128(n),
|
||||||
cwd: PathBuf::from("/home/me/dev/api"),
|
cwd: PathBuf::from("/home/me/dev/api"),
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
@@ -810,7 +809,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-382
@@ -41,10 +41,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
|
||||||
};
|
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -88,11 +86,9 @@ pub enum DraftField {
|
|||||||
/// apply immediately, mirroring the web form's per-click choice.
|
/// apply immediately, mirroring the web form's per-click choice.
|
||||||
WorktreeChoice,
|
WorktreeChoice,
|
||||||
Name,
|
Name,
|
||||||
/// Disclosure collapsing the optional view and worktree controls below.
|
/// Disclosure collapsing the worktree/branch controls below (#93).
|
||||||
/// Closed by default; Enter / Space toggles.
|
/// Closed by default; Enter / Space toggles.
|
||||||
Advanced,
|
Advanced,
|
||||||
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
|
|
||||||
StructuredToggle,
|
|
||||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||||
WorktreeToggle,
|
WorktreeToggle,
|
||||||
/// New-vs-existing branch source; Left / Right switches.
|
/// New-vs-existing branch source; Left / Right switches.
|
||||||
@@ -255,14 +251,6 @@ pub struct DraftPane {
|
|||||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||||
/// take the default target.
|
/// take the default target.
|
||||||
pub advanced_collapsed: bool,
|
pub advanced_collapsed: bool,
|
||||||
/// ACP-backed structured transcript. 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
|
/// Enclosing repository of the chosen target — from the project scan for
|
||||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||||
pub base_repo: Option<PathBuf>,
|
pub base_repo: Option<PathBuf>,
|
||||||
@@ -303,7 +291,7 @@ impl Default for DraftPane {
|
|||||||
// while its data loads, and stay usable when the daemon reports none.
|
// while its data loads, and stay usable when the daemon reports none.
|
||||||
let mut presets = builtin_presets();
|
let mut presets = builtin_presets();
|
||||||
presets.push(custom_preset());
|
presets.push(custom_preset());
|
||||||
let mut draft = Self {
|
Self {
|
||||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
@@ -323,8 +311,6 @@ impl Default for DraftPane {
|
|||||||
name_touched: false,
|
name_touched: false,
|
||||||
worktree_enabled: true,
|
worktree_enabled: true,
|
||||||
advanced_collapsed: true,
|
advanced_collapsed: true,
|
||||||
structured_view: false,
|
|
||||||
structured_view_touched: false,
|
|
||||||
base_repo: None,
|
base_repo: None,
|
||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
@@ -343,11 +329,7 @@ impl Default for DraftPane {
|
|||||||
// straight away, and Tab from here reaches the command tiles.
|
// straight away, and Tab from here reaches the command tiles.
|
||||||
focused_field: DraftField::DirPicker,
|
focused_field: DraftField::DirPicker,
|
||||||
home_dir: String::new(),
|
home_dir: String::new(),
|
||||||
};
|
}
|
||||||
// The built-in tile the form opens on decides whether structured view
|
|
||||||
// starts ticked, exactly as a later preset list would.
|
|
||||||
draft.sync_structured_view();
|
|
||||||
draft
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +348,6 @@ impl DraftPane {
|
|||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.apply_seed_command();
|
self.apply_seed_command();
|
||||||
self.sync_structured_view();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||||
@@ -682,28 +663,6 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supports_structured_view(&self) -> bool {
|
|
||||||
supports_structured_view(self.command())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-resolve the toggle after the command changed: an unsupported
|
|
||||||
/// command forces it off, a supported one turns it back on unless the
|
|
||||||
/// operator has already made the choice themselves.
|
|
||||||
fn sync_structured_view(&mut self) {
|
|
||||||
if !self.supports_structured_view() {
|
|
||||||
self.structured_view = false;
|
|
||||||
} else if !self.structured_view_touched {
|
|
||||||
self.structured_view = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle_structured_view(&mut self) {
|
|
||||||
if self.supports_structured_view() {
|
|
||||||
self.structured_view = !self.structured_view;
|
|
||||||
self.structured_view_touched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The chosen working directory: a picked project row, else the raw path
|
/// The chosen working directory: a picked project row, else the raw path
|
||||||
/// input when it holds something.
|
/// input when it holds something.
|
||||||
pub fn cwd(&self) -> Option<PathBuf> {
|
pub fn cwd(&self) -> Option<PathBuf> {
|
||||||
@@ -734,22 +693,12 @@ impl DraftPane {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let worktree = self.build_worktree()?;
|
let worktree = self.build_worktree()?;
|
||||||
let auto_approve = self
|
|
||||||
.presets
|
|
||||||
.get(self.selected_preset)
|
|
||||||
.is_some_and(|p| p.auto_approve);
|
|
||||||
Ok(CreateSessionRequest {
|
Ok(CreateSessionRequest {
|
||||||
view: if self.structured_view && self.supports_structured_view() {
|
|
||||||
SessionView::Structured
|
|
||||||
} else {
|
|
||||||
SessionView::Terminal
|
|
||||||
},
|
|
||||||
cwd,
|
cwd,
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
auto_approve,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,9 +753,6 @@ impl DraftPane {
|
|||||||
order.push(DraftField::Name);
|
order.push(DraftField::Name);
|
||||||
order.push(DraftField::Advanced);
|
order.push(DraftField::Advanced);
|
||||||
if !self.advanced_collapsed {
|
if !self.advanced_collapsed {
|
||||||
if self.supports_structured_view() {
|
|
||||||
order.push(DraftField::StructuredToggle);
|
|
||||||
}
|
|
||||||
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||||
order.push(DraftField::WorktreeChoice);
|
order.push(DraftField::WorktreeChoice);
|
||||||
}
|
}
|
||||||
@@ -848,7 +794,6 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
self.selected_preset =
|
self.selected_preset =
|
||||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||||
self.sync_structured_view();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||||
@@ -1055,9 +1000,9 @@ impl DraftPane {
|
|||||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||||
// highlighted row isn't the picked one until a first Enter chooses
|
// highlighted row isn't the picked one until a first Enter chooses
|
||||||
// it (the next Enter, now that it's active, falls through to
|
// it (the next Enter, now that it's active, falls through to
|
||||||
// submit); and the disclosure/checkbox fields, which activate on
|
// submit); and the disclosure/toggle pair (`Advanced`,
|
||||||
// Enter like buttons rather than submitting, matching the web
|
// `WorktreeToggle`), which activate on Enter like a button rather
|
||||||
// form controls.
|
// than submitting, same as the web form's own `<button>` controls.
|
||||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||||
let choose_only =
|
let choose_only =
|
||||||
@@ -1077,10 +1022,6 @@ impl DraftPane {
|
|||||||
self.toggle_worktree();
|
self.toggle_worktree();
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
}
|
}
|
||||||
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
|
|
||||||
self.toggle_structured_view();
|
|
||||||
DraftInputResult::Consumed
|
|
||||||
}
|
|
||||||
InputKey::Enter => DraftInputResult::Submit,
|
InputKey::Enter => DraftInputResult::Submit,
|
||||||
InputKey::Char {
|
InputKey::Char {
|
||||||
c: ' ',
|
c: ' ',
|
||||||
@@ -1098,14 +1039,6 @@ impl DraftPane {
|
|||||||
self.toggle_worktree();
|
self.toggle_worktree();
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
}
|
}
|
||||||
InputKey::Char {
|
|
||||||
c: ' ',
|
|
||||||
ctrl: false,
|
|
||||||
..
|
|
||||||
} if self.focused_field == DraftField::StructuredToggle => {
|
|
||||||
self.toggle_structured_view();
|
|
||||||
DraftInputResult::Consumed
|
|
||||||
}
|
|
||||||
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
||||||
self.move_selection(-1);
|
self.move_selection(-1);
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
@@ -1149,10 +1082,7 @@ impl DraftPane {
|
|||||||
other => {
|
other => {
|
||||||
match self.focused_field {
|
match self.focused_field {
|
||||||
DraftField::Preset | DraftField::Submit => {}
|
DraftField::Preset | DraftField::Submit => {}
|
||||||
DraftField::CustomCommand => {
|
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
||||||
self.custom_command.handle_key(other);
|
|
||||||
self.sync_structured_view();
|
|
||||||
}
|
|
||||||
// Typing filters the list; the highlight resets to the top
|
// Typing filters the list; the highlight resets to the top
|
||||||
// so a narrowed search always starts at its best match.
|
// so a narrowed search always starts at its best match.
|
||||||
DraftField::DirPicker => {
|
DraftField::DirPicker => {
|
||||||
@@ -1190,9 +1120,9 @@ impl DraftPane {
|
|||||||
|
|
||||||
let show_custom = self.is_custom();
|
let show_custom = self.is_custom();
|
||||||
let show_raw = self.cwd_raw_visible;
|
let show_raw = self.cwd_raw_visible;
|
||||||
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
|
// The worktree choice step, the worktree toggle and its body all stay
|
||||||
// Optional view and worktree controls stay behind the "Advanced
|
// behind the "Advanced options" disclosure (#93): collapsed hides
|
||||||
// options" disclosure (#93), regardless of what they'd otherwise show.
|
// them, regardless of what they'd otherwise show.
|
||||||
let show_wt_toggle = !self.advanced_collapsed;
|
let show_wt_toggle = !self.advanced_collapsed;
|
||||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||||
let show_wt_choice =
|
let show_wt_choice =
|
||||||
@@ -1211,7 +1141,6 @@ impl DraftPane {
|
|||||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||||
+ u16::from(show_custom)
|
+ u16::from(show_custom)
|
||||||
+ u16::from(show_raw)
|
+ u16::from(show_raw)
|
||||||
+ u16::from(show_structured)
|
|
||||||
+ u16::from(show_wt_toggle)
|
+ u16::from(show_wt_toggle)
|
||||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||||
@@ -1231,9 +1160,6 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
constraints.push(Constraint::Length(1)); // name
|
constraints.push(Constraint::Length(1)); // name
|
||||||
constraints.push(Constraint::Length(1)); // advanced header
|
constraints.push(Constraint::Length(1)); // advanced header
|
||||||
if show_structured {
|
|
||||||
constraints.push(Constraint::Length(1));
|
|
||||||
}
|
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||||
}
|
}
|
||||||
@@ -1432,24 +1358,6 @@ impl DraftPane {
|
|||||||
advanced_row,
|
advanced_row,
|
||||||
);
|
);
|
||||||
|
|
||||||
if show_structured {
|
|
||||||
let row = next_row();
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(Line::from(vec![
|
|
||||||
Span::raw(format!("{:<GUTTER$}", "view:")),
|
|
||||||
Span::styled(
|
|
||||||
if self.structured_view {
|
|
||||||
"[x] structured view"
|
|
||||||
} else {
|
|
||||||
"[ ] structured view"
|
|
||||||
},
|
|
||||||
field_style(DraftField::StructuredToggle),
|
|
||||||
),
|
|
||||||
])),
|
|
||||||
row,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||||
// worktrees newest-first.
|
// worktrees newest-first.
|
||||||
@@ -1797,7 +1705,6 @@ mod tests {
|
|||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
auto_approve: false,
|
|
||||||
}]);
|
}]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.command(),
|
draft.command(),
|
||||||
@@ -1820,13 +1727,11 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
auto_approve: false,
|
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
auto_approve: false,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
@@ -1846,7 +1751,6 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
auto_approve: false,
|
|
||||||
}]);
|
}]);
|
||||||
assert!(draft.is_custom());
|
assert!(draft.is_custom());
|
||||||
assert_eq!(draft.command(), "bash -l");
|
assert_eq!(draft.command(), "bash -l");
|
||||||
@@ -1912,280 +1816,6 @@ mod tests {
|
|||||||
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
advanced_collapsed: false,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
draft.set_presets(vec![
|
|
||||||
SpawnPreset {
|
|
||||||
id: "claude".into(),
|
|
||||||
label: "Claude".into(),
|
|
||||||
command: "claude".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
SpawnPreset {
|
|
||||||
id: "codex".into(),
|
|
||||||
label: "Codex".into(),
|
|
||||||
command: "codex".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
SpawnPreset {
|
|
||||||
id: "opencode".into(),
|
|
||||||
label: "OpenCode".into(),
|
|
||||||
command: "opencode".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert!(draft.structured_view, "on by default for a supported agent");
|
|
||||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
|
||||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
||||||
assert!(rendered.contains("[x] structured view"), "{rendered}");
|
|
||||||
|
|
||||||
draft.cycle_preset(1);
|
|
||||||
assert_eq!(draft.command(), "codex");
|
|
||||||
assert!(
|
|
||||||
draft.structured_view,
|
|
||||||
"Codex keeps the structured selection"
|
|
||||||
);
|
|
||||||
draft.cycle_preset(1);
|
|
||||||
assert_eq!(draft.command(), "opencode");
|
|
||||||
assert!(
|
|
||||||
!draft.structured_view,
|
|
||||||
"a hidden value must not stay checked"
|
|
||||||
);
|
|
||||||
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
|
||||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
|
||||||
assert!(!rendered.contains("structured view"), "{rendered}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structured_view_request_is_guarded_against_a_stale_command() {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
|
||||||
worktree_enabled: false,
|
|
||||||
structured_view: true,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
|
||||||
|
|
||||||
draft.selected_preset = 1;
|
|
||||||
assert_eq!(draft.command(), "opencode");
|
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A custom command typed into the free-text field, run through the same
|
|
||||||
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
|
|
||||||
/// env prefix, or a wrapper still resolves to Claude Code or Codex, so
|
|
||||||
/// the toggle stays offered and the request carries `Structured`; an
|
|
||||||
/// unrelated program does not.
|
|
||||||
#[test]
|
|
||||||
fn structured_view_follows_command_resolution_for_custom_commands() {
|
|
||||||
let cases: &[(&str, bool)] = &[
|
|
||||||
("claude", true),
|
|
||||||
("claude --resume", true),
|
|
||||||
("CLAUDE", true),
|
|
||||||
("/usr/local/bin/claude -p hi", true),
|
|
||||||
("./bin/claude", true),
|
|
||||||
("FOO=1 BAR=2 claude", true),
|
|
||||||
("npx claude-code", true),
|
|
||||||
("npx --yes claude-code", true),
|
|
||||||
("sudo claude", true),
|
|
||||||
("sudo -u root claude", true),
|
|
||||||
("env -i claude", true),
|
|
||||||
("nohup claude", true),
|
|
||||||
("time claude", true),
|
|
||||||
("command claude", true),
|
|
||||||
("pnpm --silent dlx claude-code", true),
|
|
||||||
("yarn --silent dlx claude-code", true),
|
|
||||||
("bun x claude-code", true),
|
|
||||||
("\"/opt/my agent/claude\" --resume", true),
|
|
||||||
("codex", true),
|
|
||||||
("codex --full-auto", true),
|
|
||||||
("CODEX=1 codex", true),
|
|
||||||
("/usr/local/bin/codex exec", true),
|
|
||||||
("npx codex", true),
|
|
||||||
("sudo -u root codex", true),
|
|
||||||
("env -i codex", true),
|
|
||||||
("command codex", true),
|
|
||||||
("pnpm --silent dlx codex", true),
|
|
||||||
("\"/opt/my agent/codex\" exec", true),
|
|
||||||
("bash", false),
|
|
||||||
("opencode", false),
|
|
||||||
("/usr/local/bin/bash -l", false),
|
|
||||||
// A package-manager wrapper without `dlx`/`x` runs a
|
|
||||||
// `package.json` script, not the package (#199) — must not be
|
|
||||||
// mistaken for a Claude launch.
|
|
||||||
("pnpm claude", false),
|
|
||||||
("yarn claude", false),
|
|
||||||
// The daemon's structured-session child, never a valid
|
|
||||||
// user-facing launch form (#199).
|
|
||||||
("claude-code-acp", false),
|
|
||||||
];
|
|
||||||
for &(command, supported) in cases {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
|
||||||
worktree_enabled: false,
|
|
||||||
advanced_collapsed: false,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
draft.selected_preset = draft.presets.len() - 1;
|
|
||||||
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
|
||||||
draft.sync_structured_view();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
draft.field_order().contains(&DraftField::StructuredToggle),
|
|
||||||
supported,
|
|
||||||
"toggle visibility for {command:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
draft.structured_view, supported,
|
|
||||||
"default selection for {command:?}"
|
|
||||||
);
|
|
||||||
let view = draft.build_request().unwrap().view;
|
|
||||||
assert_eq!(
|
|
||||||
view,
|
|
||||||
if supported {
|
|
||||||
SessionView::Structured
|
|
||||||
} else {
|
|
||||||
SessionView::Terminal
|
|
||||||
},
|
|
||||||
"request view for {command:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert!(
|
|
||||||
!draft.structured_view,
|
|
||||||
"the toggle turns the default off for {command:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A configured preset (the spawn-form "alias" for a command) is gated
|
|
||||||
/// the same way as free text: a preset whose command resolves to Claude
|
|
||||||
/// Code or Codex through a wrapper still offers structured view.
|
|
||||||
#[test]
|
|
||||||
fn structured_view_follows_command_resolution_for_presets() {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
|
||||||
worktree_enabled: false,
|
|
||||||
advanced_collapsed: false,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
draft.set_presets(vec![
|
|
||||||
SpawnPreset {
|
|
||||||
id: "claude-wrapped".into(),
|
|
||||||
label: "Claude (npx)".into(),
|
|
||||||
command: "npx claude-code --resume".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
SpawnPreset {
|
|
||||||
id: "codex-wrapped".into(),
|
|
||||||
label: "Codex (npx)".into(),
|
|
||||||
command: "npx codex exec".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
SpawnPreset {
|
|
||||||
id: "aider".into(),
|
|
||||||
label: "Aider".into(),
|
|
||||||
command: "aider".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(draft.command(), "npx claude-code --resume");
|
|
||||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
|
||||||
assert!(draft.structured_view);
|
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
|
||||||
|
|
||||||
// Switching between supported presets preserves the selection.
|
|
||||||
draft.cycle_preset(1);
|
|
||||||
assert_eq!(draft.command(), "npx codex exec");
|
|
||||||
assert!(draft.structured_view);
|
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
|
||||||
|
|
||||||
// Switching to a preset that does not resolve to an ACP agent clears the
|
|
||||||
// stale selection instead of letting an invalid request through.
|
|
||||||
draft.cycle_preset(1);
|
|
||||||
assert_eq!(draft.command(), "aider");
|
|
||||||
assert!(!draft.structured_view, "stale selection must clear");
|
|
||||||
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The default only applies until the operator has an opinion: once they
|
|
||||||
/// untick the box, moving between supported commands leaves it unticked
|
|
||||||
/// instead of turning it back on under them.
|
|
||||||
#[test]
|
|
||||||
fn an_unticked_structured_view_survives_a_command_change() {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
|
||||||
worktree_enabled: false,
|
|
||||||
advanced_collapsed: false,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
draft.set_presets(vec![
|
|
||||||
SpawnPreset {
|
|
||||||
id: "claude".into(),
|
|
||||||
label: "Claude".into(),
|
|
||||||
command: "claude".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
SpawnPreset {
|
|
||||||
id: "codex".into(),
|
|
||||||
label: "Codex".into(),
|
|
||||||
command: "codex".into(),
|
|
||||||
auto_approve: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert!(draft.structured_view);
|
|
||||||
|
|
||||||
draft.toggle_structured_view();
|
|
||||||
assert!(!draft.structured_view);
|
|
||||||
|
|
||||||
draft.cycle_preset(1);
|
|
||||||
assert_eq!(draft.command(), "codex");
|
|
||||||
assert!(
|
|
||||||
!draft.structured_view,
|
|
||||||
"the operator's choice outranks the default"
|
|
||||||
);
|
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Editing the custom-command text away from a supported agent clears an armed
|
|
||||||
/// selection immediately, matching the preset-switch case above — an
|
|
||||||
/// invalid `Structured` request can never reach `build_request`.
|
|
||||||
#[test]
|
|
||||||
fn structured_view_clears_when_custom_command_edited_away_from_supported_agent() {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
|
||||||
worktree_enabled: false,
|
|
||||||
advanced_collapsed: false,
|
|
||||||
focused_field: DraftField::CustomCommand,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
draft.selected_preset = draft.presets.len() - 1;
|
|
||||||
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
|
||||||
draft.sync_structured_view();
|
|
||||||
assert!(draft.structured_view);
|
|
||||||
|
|
||||||
typed(&mut draft, " -p");
|
|
||||||
assert_eq!(draft.command(), "codex -p");
|
|
||||||
assert!(draft.structured_view, "still Codex, selection survives");
|
|
||||||
|
|
||||||
for _ in 0.."codex -p".chars().count() {
|
|
||||||
draft.on_input(&InputKey::Backspace);
|
|
||||||
}
|
|
||||||
typed(&mut draft, "bash");
|
|
||||||
assert_eq!(draft.command(), "bash");
|
|
||||||
assert!(
|
|
||||||
!draft.structured_view,
|
|
||||||
"editing the program away from Codex clears the stale selection"
|
|
||||||
);
|
|
||||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ impl FastForwardDefaults {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(CreateSessionRequest {
|
Some(CreateSessionRequest {
|
||||||
view: Default::default(),
|
|
||||||
cwd: project.path.clone(),
|
cwd: project.path.clone(),
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -121,7 +120,6 @@ impl FastForwardDefaults {
|
|||||||
branch: handle.to_string(),
|
branch: handle.to_string(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
auto_approve: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +183,6 @@ mod tests {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: id.into(),
|
label: id.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
auto_approve: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ pub enum MouseKind {
|
|||||||
Click,
|
Click,
|
||||||
DblClick,
|
DblClick,
|
||||||
Drag,
|
Drag,
|
||||||
Hover,
|
|
||||||
ScrollUp,
|
ScrollUp,
|
||||||
ScrollDown,
|
ScrollDown,
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,6 @@ impl InputKey {
|
|||||||
MouseKind::Click => "click",
|
MouseKind::Click => "click",
|
||||||
MouseKind::DblClick => "dblclick",
|
MouseKind::DblClick => "dblclick",
|
||||||
MouseKind::Drag => "drag",
|
MouseKind::Drag => "drag",
|
||||||
MouseKind::Hover => "hover",
|
|
||||||
MouseKind::ScrollUp => "scrollup",
|
MouseKind::ScrollUp => "scrollup",
|
||||||
MouseKind::ScrollDown => "scrolldown",
|
MouseKind::ScrollDown => "scrolldown",
|
||||||
};
|
};
|
||||||
@@ -180,7 +178,6 @@ impl InputKey {
|
|||||||
"click" => MouseKind::Click,
|
"click" => MouseKind::Click,
|
||||||
"dblclick" => MouseKind::DblClick,
|
"dblclick" => MouseKind::DblClick,
|
||||||
"drag" => MouseKind::Drag,
|
"drag" => MouseKind::Drag,
|
||||||
"hover" => MouseKind::Hover,
|
|
||||||
"scrollup" => MouseKind::ScrollUp,
|
"scrollup" => MouseKind::ScrollUp,
|
||||||
"scrolldown" => MouseKind::ScrollDown,
|
"scrolldown" => MouseKind::ScrollDown,
|
||||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||||
@@ -300,7 +297,6 @@ where
|
|||||||
let mk = match kind {
|
let mk = match kind {
|
||||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||||
MouseEventKind::Moved => MouseKind::Hover,
|
|
||||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -685,9 +681,6 @@ mod tests {
|
|||||||
let s = "mouse:dblclick(sidebar-row)";
|
let s = "mouse:dblclick(sidebar-row)";
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s);
|
assert_eq!(k.canonical(), s);
|
||||||
let s = "mouse:hover(pane)";
|
|
||||||
let k = InputKey::parse(s).unwrap();
|
|
||||||
assert_eq!(k.canonical(), s);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ pub mod render;
|
|||||||
pub mod session_task;
|
pub mod session_task;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod structured_pane;
|
|
||||||
pub mod structured_task;
|
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod text_input;
|
pub mod text_input;
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ impl Surface {
|
|||||||
pub fn activity(&self, state: ActivityState) -> Color {
|
pub fn activity(&self, state: ActivityState) -> Color {
|
||||||
match state {
|
match state {
|
||||||
ActivityState::Active => self.active,
|
ActivityState::Active => self.active,
|
||||||
ActivityState::Idle | ActivityState::Starting => self.idle,
|
ActivityState::Idle => self.idle,
|
||||||
ActivityState::AwaitingInput => self.awaiting,
|
ActivityState::AwaitingInput => self.awaiting,
|
||||||
ActivityState::Error => self.error,
|
ActivityState::Error => self.error,
|
||||||
}
|
}
|
||||||
@@ -928,10 +928,9 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const STATES: [ActivityState; 5] = [
|
const STATES: [ActivityState; 4] = [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
ActivityState::Starting,
|
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ use crate::palette;
|
|||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
use crate::structured_pane::StructuredPane;
|
|
||||||
|
|
||||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
/// coordinate to one of these via [`RegionMap::region_at`].
|
||||||
@@ -81,7 +80,6 @@ pub struct RenderInputs<'a> {
|
|||||||
pub home_dir: &'a str,
|
pub home_dir: &'a str,
|
||||||
pub pane_title: &'a str,
|
pub pane_title: &'a str,
|
||||||
pub pane: Option<&'a Pane>,
|
pub pane: Option<&'a Pane>,
|
||||||
pub structured_pane: Option<&'a StructuredPane>,
|
|
||||||
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
||||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
@@ -149,22 +147,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||||
if let Some(p) = inputs.pane {
|
if let Some(p) = inputs.pane {
|
||||||
p.render(f, pane_area);
|
p.render(f, pane_area);
|
||||||
} else if let Some(p) = inputs.structured_pane {
|
|
||||||
p.render(f, pane_area);
|
|
||||||
}
|
}
|
||||||
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||||
// chrome; every remaining cell belongs to the active session pane.
|
// chrome; every remaining cell belongs to the emulator.
|
||||||
map.pane = Some(pane_area);
|
map.pane = Some(pane_area);
|
||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
// always see the current mode + key hints.
|
// always see the current mode + key hints.
|
||||||
render_status_bar(
|
render_status_bar(f, status_area, inputs.state, inputs.link);
|
||||||
f,
|
|
||||||
status_area,
|
|
||||||
inputs.state,
|
|
||||||
inputs.link,
|
|
||||||
inputs.structured_pane.is_some(),
|
|
||||||
);
|
|
||||||
|
|
||||||
match inputs.state.overlay() {
|
match inputs.state.overlay() {
|
||||||
Some(OverlayKind::Spawn) => {
|
Some(OverlayKind::Spawn) => {
|
||||||
@@ -263,13 +253,7 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
|||||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||||
/// mode + focused panel + the most useful key hints so the user is never
|
/// mode + focused panel + the most useful key hints so the user is never
|
||||||
/// guessing what state they're in.
|
/// guessing what state they're in.
|
||||||
fn render_status_bar(
|
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) {
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
state: &AppState,
|
|
||||||
link: &LinkStatus,
|
|
||||||
structured: bool,
|
|
||||||
) {
|
|
||||||
// The daemon indicator is pinned right and sized to its own text, so a
|
// The daemon indicator is pinned right and sized to its own text, so a
|
||||||
// narrow terminal truncates the key hints rather than the release.
|
// narrow terminal truncates the key hints rather than the release.
|
||||||
let label = link_label(link);
|
let label = link_label(link);
|
||||||
@@ -287,13 +271,9 @@ fn render_status_bar(
|
|||||||
Focus::Sidebar => "sidebar",
|
Focus::Sidebar => "sidebar",
|
||||||
Focus::Pane => "pane",
|
Focus::Pane => "pane",
|
||||||
};
|
};
|
||||||
let hint = match (structured && state.focus == Focus::Pane, state.mode) {
|
let hint = match state.mode {
|
||||||
(true, Mode::Normal) => "j/k: rows • enter: expand • i: compose • esc: cancel",
|
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
||||||
(true, Mode::Insert) => "enter: send • esc: cancel • ^a: sidebar",
|
Mode::Insert => "^a: return to sidebar",
|
||||||
(false, Mode::Normal) => {
|
|
||||||
"j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit"
|
|
||||||
}
|
|
||||||
(false, Mode::Insert) => "^a: return to sidebar",
|
|
||||||
};
|
};
|
||||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||||
let line = ratatui::text::Line::from(vec![
|
let line = ratatui::text::Line::from(vec![
|
||||||
@@ -455,7 +435,6 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
structured_pane: None,
|
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -553,7 +532,6 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
structured_pane: None,
|
|
||||||
draft: Some(&draft),
|
draft: Some(&draft),
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -609,7 +587,6 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
structured_pane: None,
|
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
@@ -652,7 +629,6 @@ mod tests {
|
|||||||
home_dir: "",
|
home_dir: "",
|
||||||
pane_title: "session",
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
structured_pane: None,
|
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ impl SessionTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn jitter(base: Duration) -> Duration {
|
fn jitter(base: Duration) -> Duration {
|
||||||
let ms = base.as_millis() as u64;
|
let ms = base.as_millis() as u64;
|
||||||
let now_nanos = std::time::SystemTime::now()
|
let now_nanos = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -484,7 +484,6 @@ mod tests {
|
|||||||
let mut metadata = HashMap::new();
|
let mut metadata = HashMap::new();
|
||||||
metadata.insert("name".into(), serde_json::Value::String(name.into()));
|
metadata.insert("name".into(), serde_json::Value::String(name.into()));
|
||||||
Session {
|
Session {
|
||||||
view: Default::default(),
|
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
@@ -501,7 +500,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,7 +977,6 @@ mod tests {
|
|||||||
for state in [
|
for state in [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
ActivityState::Starting,
|
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
@@ -1010,7 +1007,6 @@ mod tests {
|
|||||||
for state in [
|
for state in [
|
||||||
ActivityState::Active,
|
ActivityState::Active,
|
||||||
ActivityState::Idle,
|
ActivityState::Idle,
|
||||||
ActivityState::Starting,
|
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
@@ -1254,7 +1250,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_command_when_name_metadata_is_absent() {
|
fn falls_back_to_command_when_name_metadata_is_absent() {
|
||||||
let s = Session {
|
let s = Session {
|
||||||
view: Default::default(),
|
|
||||||
id: Uuid::from_u128(1),
|
id: Uuid::from_u128(1),
|
||||||
command: "vim".into(),
|
command: "vim".into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
@@ -1271,7 +1266,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,773 +0,0 @@
|
|||||||
//! Live projection state for one structured session.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use maestro_protocol::{
|
|
||||||
ReducedState, StructuredApprovalDecision, StructuredEvent, TranscriptDelta, TranscriptRow,
|
|
||||||
TranscriptRowKind,
|
|
||||||
};
|
|
||||||
use tokio::sync::watch;
|
|
||||||
use tokio_tungstenite::tungstenite::{Error as WsError, Message};
|
|
||||||
|
|
||||||
use crate::input::InputKey;
|
|
||||||
use crate::session_task::SessionState;
|
|
||||||
use crate::text_input::TextInput;
|
|
||||||
|
|
||||||
/// Renderer and input state built directly from the daemon's two projections.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct StructuredState {
|
|
||||||
pub rows: Vec<TranscriptRow>,
|
|
||||||
pub reduced: ReducedState,
|
|
||||||
pub selected: Option<String>,
|
|
||||||
pub expanded: HashSet<String>,
|
|
||||||
pub scroll: usize,
|
|
||||||
/// Row id per rendered line from the last render pass, parallel to that
|
|
||||||
/// pass's `lines`. Lets the next render translate `scroll` — a raw index
|
|
||||||
/// into that now-stale layout — back into a row identity before mapping
|
|
||||||
/// it onto the freshly rebuilt layout, so growth/shrinkage above the
|
|
||||||
/// viewport shifts indices without shifting what's on screen.
|
|
||||||
pub(crate) line_rows: Vec<Option<String>>,
|
|
||||||
pub follow_selection: bool,
|
|
||||||
pub composer: TextInput,
|
|
||||||
pub error: Option<String>,
|
|
||||||
submitting: bool,
|
|
||||||
submission_generation: u64,
|
|
||||||
reconcile_submission_on_snapshot: Option<u64>,
|
|
||||||
approval_submitting: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StructuredState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
rows: Vec::new(),
|
|
||||||
reduced: ReducedState::default(),
|
|
||||||
selected: None,
|
|
||||||
expanded: HashSet::new(),
|
|
||||||
scroll: 0,
|
|
||||||
line_rows: Vec::new(),
|
|
||||||
follow_selection: true,
|
|
||||||
composer: TextInput::default(),
|
|
||||||
error: None,
|
|
||||||
submitting: false,
|
|
||||||
submission_generation: 0,
|
|
||||||
reconcile_submission_on_snapshot: None,
|
|
||||||
approval_submitting: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StructuredState {
|
|
||||||
pub fn apply(&mut self, event: StructuredEvent) {
|
|
||||||
match event {
|
|
||||||
StructuredEvent::TranscriptSnapshot { snapshot } => {
|
|
||||||
let selected = self.selected.clone();
|
|
||||||
self.rows = snapshot.rows;
|
|
||||||
self.selected = selected
|
|
||||||
.filter(|id| self.rows.iter().any(|row| &row.id == id))
|
|
||||||
.or_else(|| {
|
|
||||||
self.rows
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|row| row.kind != TranscriptRowKind::Plan)
|
|
||||||
.map(|row| row.id.clone())
|
|
||||||
});
|
|
||||||
self.expanded
|
|
||||||
.retain(|id| self.rows.iter().any(|row| &row.id == id));
|
|
||||||
}
|
|
||||||
StructuredEvent::ReducedState { state } => {
|
|
||||||
let reconcile_submission = self
|
|
||||||
.reconcile_submission_on_snapshot
|
|
||||||
.take()
|
|
||||||
.is_some_and(|generation| {
|
|
||||||
self.submitting && generation == self.submission_generation
|
|
||||||
});
|
|
||||||
self.apply_reduced(state, reconcile_submission);
|
|
||||||
}
|
|
||||||
StructuredEvent::ProjectionUpdate { transcript, state } => {
|
|
||||||
if let Some(delta) = transcript {
|
|
||||||
self.apply_delta(delta);
|
|
||||||
}
|
|
||||||
self.apply_reduced(state, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_delta(&mut self, delta: TranscriptDelta) {
|
|
||||||
match delta {
|
|
||||||
TranscriptDelta::Append { row } => {
|
|
||||||
let followed_tail = self.follow_selection
|
|
||||||
&& self.selected.as_ref().is_some_and(|id| {
|
|
||||||
self.rows
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|old| old.kind != TranscriptRowKind::Plan)
|
|
||||||
.is_some_and(|old| &old.id == id)
|
|
||||||
});
|
|
||||||
if let Some(index) = self.rows.iter().position(|old| old.id == row.id) {
|
|
||||||
self.rows[index] = row.clone();
|
|
||||||
} else {
|
|
||||||
self.rows.push(row.clone());
|
|
||||||
}
|
|
||||||
if row.kind != TranscriptRowKind::Plan && (self.selected.is_none() || followed_tail)
|
|
||||||
{
|
|
||||||
self.selected = Some(row.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TranscriptDelta::Patch { row } => {
|
|
||||||
if let Some(index) = self.rows.iter().position(|old| old.id == row.id) {
|
|
||||||
self.rows[index] = row.clone();
|
|
||||||
} else {
|
|
||||||
self.rows.push(row.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TranscriptDelta::Remove { id } => {
|
|
||||||
let removed = self.rows.iter().position(|row| row.id == id);
|
|
||||||
self.rows.retain(|row| row.id != id);
|
|
||||||
self.expanded.remove(&id);
|
|
||||||
if self.selected.as_deref() == Some(&id) {
|
|
||||||
self.selected = removed.and_then(|index| {
|
|
||||||
let split = index.min(self.rows.len());
|
|
||||||
self.rows[split..]
|
|
||||||
.iter()
|
|
||||||
.find(|row| row.kind != TranscriptRowKind::Plan)
|
|
||||||
.or_else(|| {
|
|
||||||
self.rows[..split]
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|row| row.kind != TranscriptRowKind::Plan)
|
|
||||||
})
|
|
||||||
.map(|row| row.id.clone())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_reduced(&mut self, state: ReducedState, reconcile_submission: bool) {
|
|
||||||
let previous_nonce = self.pending_nonce().map(str::to_owned);
|
|
||||||
if reconcile_submission || state.turn.responding || state.turn.thinking {
|
|
||||||
self.submitting = false;
|
|
||||||
}
|
|
||||||
self.reduced = state;
|
|
||||||
let next_nonce = self.pending_nonce().map(str::to_owned);
|
|
||||||
if next_nonce != previous_nonce {
|
|
||||||
self.approval_submitting = false;
|
|
||||||
if next_nonce.is_some() {
|
|
||||||
self.selected = self
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|row| row.kind == TranscriptRowKind::Approval)
|
|
||||||
.map(|row| row.id.clone());
|
|
||||||
self.follow_selection = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_snapshot_reconciliation(&mut self) {
|
|
||||||
self.reconcile_submission_on_snapshot =
|
|
||||||
self.submitting.then_some(self.submission_generation);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select(&mut self, delta: isize) {
|
|
||||||
let selectable: Vec<&TranscriptRow> = self
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.filter(|row| row.kind != TranscriptRowKind::Plan)
|
|
||||||
.collect();
|
|
||||||
if selectable.is_empty() {
|
|
||||||
self.selected = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current = self
|
|
||||||
.selected
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|id| selectable.iter().position(|row| &row.id == id))
|
|
||||||
.unwrap_or(selectable.len() - 1);
|
|
||||||
let next = if delta >= 0 {
|
|
||||||
current
|
|
||||||
.saturating_add(delta as usize)
|
|
||||||
.min(selectable.len() - 1)
|
|
||||||
} else {
|
|
||||||
current.saturating_sub(delta.unsigned_abs())
|
|
||||||
};
|
|
||||||
self.selected = Some(selectable[next].id.clone());
|
|
||||||
self.follow_selection = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
};
|
|
||||||
let expandable = self.rows.iter().any(|row| {
|
|
||||||
row.id == id
|
|
||||||
&& matches!(
|
|
||||||
row.kind,
|
|
||||||
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff
|
|
||||||
)
|
|
||||||
});
|
|
||||||
if !expandable {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if !self.expanded.insert(id.clone()) {
|
|
||||||
self.expanded.remove(&id);
|
|
||||||
}
|
|
||||||
self.follow_selection = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn scroll_by(&mut self, lines: isize) {
|
|
||||||
self.scroll = if lines >= 0 {
|
|
||||||
self.scroll.saturating_sub(lines as usize)
|
|
||||||
} else {
|
|
||||||
self.scroll.saturating_add(lines.unsigned_abs())
|
|
||||||
};
|
|
||||||
self.follow_selection = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn edit_composer(&mut self, key: &InputKey) {
|
|
||||||
if !self.turn_active() {
|
|
||||||
self.composer.handle_key(key);
|
|
||||||
self.error = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn begin_prompt(&mut self) -> Option<String> {
|
|
||||||
if self.turn_active() || self.composer.value().trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let prompt = self.composer.value().to_string();
|
|
||||||
self.composer = TextInput::default();
|
|
||||||
self.submission_generation = self.submission_generation.wrapping_add(1);
|
|
||||||
self.submitting = true;
|
|
||||||
self.error = None;
|
|
||||||
Some(prompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prompt_failed(&mut self, prompt: String, error: String) {
|
|
||||||
if self.composer.value().is_empty() {
|
|
||||||
self.composer = TextInput::with_value(prompt);
|
|
||||||
}
|
|
||||||
self.submitting = false;
|
|
||||||
self.error = Some(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn turn_active(&self) -> bool {
|
|
||||||
self.submitting || self.reduced.turn.responding || self.reduced.turn.thinking
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pending_nonce(&self) -> Option<&str> {
|
|
||||||
self.reduced
|
|
||||||
.pending_approval
|
|
||||||
.as_ref()?
|
|
||||||
.get("nonce")?
|
|
||||||
.as_str()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn approval_decision(&mut self, key: char) -> Option<(String, StructuredApprovalDecision)> {
|
|
||||||
if self.approval_submitting {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let selected = self.selected.as_deref()?;
|
|
||||||
let pending_row = self
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|row| row.kind == TranscriptRowKind::Approval)?;
|
|
||||||
if pending_row.id != selected {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let decision = match key {
|
|
||||||
'a' => StructuredApprovalDecision::AllowOnce,
|
|
||||||
'A' => StructuredApprovalDecision::AllowForSession,
|
|
||||||
'd' => StructuredApprovalDecision::Deny,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let offered = offered_decisions(&pending_row.data);
|
|
||||||
if !offered.contains(&decision) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let nonce = self.pending_nonce()?.to_string();
|
|
||||||
self.approval_submitting = true;
|
|
||||||
self.error = None;
|
|
||||||
Some((nonce, decision))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn approval_failed(&mut self, nonce: &str, error: String) {
|
|
||||||
if self.pending_nonce() != Some(nonce) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.approval_submitting = false;
|
|
||||||
self.error = Some(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn offered_decisions(data: &serde_json::Value) -> Vec<StructuredApprovalDecision> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for kind in data
|
|
||||||
.get("options")
|
|
||||||
.and_then(serde_json::Value::as_array)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.filter_map(|option| option.get("kind").and_then(serde_json::Value::as_str))
|
|
||||||
{
|
|
||||||
match kind {
|
|
||||||
"allow_once" if !out.contains(&StructuredApprovalDecision::AllowOnce) => {
|
|
||||||
out.push(StructuredApprovalDecision::AllowOnce)
|
|
||||||
}
|
|
||||||
"allow_always" if !out.contains(&StructuredApprovalDecision::AllowForSession) => {
|
|
||||||
out.push(StructuredApprovalDecision::AllowForSession)
|
|
||||||
}
|
|
||||||
"reject_once" | "reject_always" if !out.contains(&StructuredApprovalDecision::Deny) => {
|
|
||||||
out.push(StructuredApprovalDecision::Deny)
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cheap shared handle for a structured projection connection.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct StructuredHandle {
|
|
||||||
pub state: Arc<Mutex<StructuredState>>,
|
|
||||||
connection: Arc<AtomicU8>,
|
|
||||||
revision: Arc<AtomicU64>,
|
|
||||||
stop: watch::Sender<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for StructuredHandle {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("StructuredHandle")
|
|
||||||
.field("connection", &self.connection())
|
|
||||||
.field("revision", &self.revision())
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StructuredHandle {
|
|
||||||
pub fn connection(&self) -> SessionState {
|
|
||||||
match self.connection.load(Ordering::Acquire) {
|
|
||||||
0 => SessionState::Connecting,
|
|
||||||
1 => SessionState::Live,
|
|
||||||
2 => SessionState::Reconnecting,
|
|
||||||
_ => SessionState::Ended,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn revision(&self) -> u64 {
|
|
||||||
self.revision.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn end(&self) {
|
|
||||||
self.connection
|
|
||||||
.store(SessionState::Ended as u8, Ordering::Release);
|
|
||||||
let _ = self.stop.send(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn test_handle(state: StructuredState) -> Self {
|
|
||||||
let (stop, _) = watch::channel(false);
|
|
||||||
Self {
|
|
||||||
state: Arc::new(Mutex::new(state)),
|
|
||||||
connection: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
|
||||||
revision: Arc::new(AtomicU64::new(0)),
|
|
||||||
stop,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct StructuredTask;
|
|
||||||
|
|
||||||
impl StructuredTask {
|
|
||||||
pub fn spawn(ws_url: String) -> StructuredHandle {
|
|
||||||
let state = Arc::new(Mutex::new(StructuredState::default()));
|
|
||||||
let connection = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
|
||||||
let revision = Arc::new(AtomicU64::new(0));
|
|
||||||
let (stop, mut stopped) = watch::channel(false);
|
|
||||||
let handle = StructuredHandle {
|
|
||||||
state: state.clone(),
|
|
||||||
connection: connection.clone(),
|
|
||||||
revision: revision.clone(),
|
|
||||||
stop,
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut backoff = Duration::from_secs(1);
|
|
||||||
loop {
|
|
||||||
if *stopped.borrow() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state
|
|
||||||
.lock()
|
|
||||||
.expect("structured state mutex")
|
|
||||||
.prepare_snapshot_reconciliation();
|
|
||||||
connection.store(SessionState::Connecting as u8, Ordering::Release);
|
|
||||||
let connected = tokio::select! {
|
|
||||||
result = tokio_tungstenite::connect_async(&ws_url) => result,
|
|
||||||
_ = stopped.changed() => return,
|
|
||||||
};
|
|
||||||
let (mut ws, _) = match connected {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(error) => {
|
|
||||||
if terminal_handshake_error(&error) {
|
|
||||||
state.lock().expect("structured state mutex").error =
|
|
||||||
Some(format!("cannot open structured session: {error}"));
|
|
||||||
connection.store(SessionState::Ended as u8, Ordering::Release);
|
|
||||||
revision.fetch_add(1, Ordering::Release);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
connection.store(SessionState::Reconnecting as u8, Ordering::Release);
|
|
||||||
revision.fetch_add(1, Ordering::Release);
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(super::session_task::jitter(backoff)) => {},
|
|
||||||
_ = stopped.changed() => return,
|
|
||||||
}
|
|
||||||
backoff = backoff.saturating_mul(2).min(Duration::from_secs(30));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
backoff = Duration::from_secs(1);
|
|
||||||
connection.store(SessionState::Live as u8, Ordering::Release);
|
|
||||||
revision.fetch_add(1, Ordering::Release);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let frame = tokio::select! {
|
|
||||||
frame = ws.next() => frame,
|
|
||||||
_ = stopped.changed() => return,
|
|
||||||
};
|
|
||||||
match frame {
|
|
||||||
Some(Ok(Message::Text(text))) => {
|
|
||||||
if let Ok(event) = serde_json::from_str::<StructuredEvent>(&text) {
|
|
||||||
state.lock().expect("structured state mutex").apply(event);
|
|
||||||
revision.fetch_add(1, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connection.store(SessionState::Reconnecting as u8, Ordering::Release);
|
|
||||||
revision.fetch_add(1, Ordering::Release);
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(super::session_task::jitter(backoff)) => {},
|
|
||||||
_ = stopped.changed() => return,
|
|
||||||
}
|
|
||||||
backoff = backoff.saturating_mul(2).min(Duration::from_secs(30));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
handle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn terminal_handshake_error(error: &WsError) -> bool {
|
|
||||||
matches!(error, WsError::Http(response) if response.status().is_client_error())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use maestro_protocol::{TranscriptSnapshot, TurnState};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
fn row(id: &str, kind: TranscriptRowKind, data: serde_json::Value) -> TranscriptRow {
|
|
||||||
TranscriptRow {
|
|
||||||
id: id.into(),
|
|
||||||
kind,
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn snapshots_replace_and_deltas_address_rows_by_id() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
|
||||||
snapshot: TranscriptSnapshot {
|
|
||||||
cursor: 2,
|
|
||||||
rows: vec![row("a", TranscriptRowKind::AgentText, json!({}))],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.apply(StructuredEvent::ProjectionUpdate {
|
|
||||||
transcript: Some(TranscriptDelta::Patch {
|
|
||||||
row: row("a", TranscriptRowKind::AgentText, json!({"n": 2})),
|
|
||||||
}),
|
|
||||||
state: ReducedState::default(),
|
|
||||||
});
|
|
||||||
state.apply(StructuredEvent::ProjectionUpdate {
|
|
||||||
transcript: Some(TranscriptDelta::Append {
|
|
||||||
row: row("b", TranscriptRowKind::Notice, json!({})),
|
|
||||||
}),
|
|
||||||
state: ReducedState::default(),
|
|
||||||
});
|
|
||||||
assert_eq!(state.rows.len(), 2);
|
|
||||||
assert_eq!(state.rows[0].data["n"], 2);
|
|
||||||
|
|
||||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
|
||||||
snapshot: TranscriptSnapshot {
|
|
||||||
cursor: 5,
|
|
||||||
rows: vec![row("b", TranscriptRowKind::Notice, json!({"fresh": true}))],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert_eq!(state.rows.len(), 1);
|
|
||||||
assert_eq!(state.rows[0].data["fresh"], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selection_survives_patch_and_moves_after_remove() {
|
|
||||||
let mut state = StructuredState {
|
|
||||||
rows: vec![
|
|
||||||
row("a", TranscriptRowKind::Notice, json!({})),
|
|
||||||
row("b", TranscriptRowKind::ToolCall, json!({})),
|
|
||||||
row("plan", TranscriptRowKind::Plan, json!({})),
|
|
||||||
],
|
|
||||||
selected: Some("a".into()),
|
|
||||||
..StructuredState::default()
|
|
||||||
};
|
|
||||||
state.select(1);
|
|
||||||
assert_eq!(state.selected.as_deref(), Some("b"));
|
|
||||||
state.toggle_selected();
|
|
||||||
assert!(state.expanded.contains("b"));
|
|
||||||
state.apply_delta(TranscriptDelta::Remove { id: "b".into() });
|
|
||||||
assert_eq!(state.selected.as_deref(), Some("a"));
|
|
||||||
|
|
||||||
state.apply_delta(TranscriptDelta::Remove { id: "a".into() });
|
|
||||||
assert_eq!(
|
|
||||||
state.selected, None,
|
|
||||||
"collapsed plan rows are not selectable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn approval_keys_only_offer_server_backed_decisions() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.rows.push(row(
|
|
||||||
"approval:1",
|
|
||||||
TranscriptRowKind::Approval,
|
|
||||||
json!({"options":[
|
|
||||||
{"kind":"allow_once"}, {"kind":"reject_always"}
|
|
||||||
]}),
|
|
||||||
));
|
|
||||||
state.selected = Some("approval:1".into());
|
|
||||||
state.reduced.pending_approval = Some(json!({"nonce":"secret"}));
|
|
||||||
assert_eq!(
|
|
||||||
state.approval_decision('A'),
|
|
||||||
None,
|
|
||||||
"allow-for-session was not offered"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
state.approval_decision('a'),
|
|
||||||
Some(("secret".into(), StructuredApprovalDecision::AllowOnce))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolved_approval_ignores_its_late_failure() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.rows.push(row(
|
|
||||||
"approval:1",
|
|
||||||
TranscriptRowKind::Approval,
|
|
||||||
json!({"options":[{"kind":"allow_once"}]}),
|
|
||||||
));
|
|
||||||
state.selected = Some("approval:1".into());
|
|
||||||
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
|
||||||
assert!(state.approval_decision('a').is_some());
|
|
||||||
|
|
||||||
state.apply(StructuredEvent::ProjectionUpdate {
|
|
||||||
transcript: None,
|
|
||||||
state: ReducedState::default(),
|
|
||||||
});
|
|
||||||
state.approval_failed("old", "response lost".into());
|
|
||||||
|
|
||||||
assert_eq!(state.error, None);
|
|
||||||
assert!(!state.approval_submitting);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn superseded_approval_ignores_its_late_failure() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.rows.push(row(
|
|
||||||
"approval:1",
|
|
||||||
TranscriptRowKind::Approval,
|
|
||||||
json!({"options":[{"kind":"allow_once"}]}),
|
|
||||||
));
|
|
||||||
state.selected = Some("approval:1".into());
|
|
||||||
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
|
||||||
assert!(state.approval_decision('a').is_some());
|
|
||||||
|
|
||||||
state.rows.push(row(
|
|
||||||
"approval:2",
|
|
||||||
TranscriptRowKind::Approval,
|
|
||||||
json!({"options":[{"kind":"allow_once"}]}),
|
|
||||||
));
|
|
||||||
state.apply(StructuredEvent::ProjectionUpdate {
|
|
||||||
transcript: None,
|
|
||||||
state: ReducedState {
|
|
||||||
pending_approval: Some(json!({"nonce":"new"})),
|
|
||||||
..ReducedState::default()
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
state.approval_decision('a'),
|
|
||||||
Some(("new".into(), StructuredApprovalDecision::AllowOnce))
|
|
||||||
);
|
|
||||||
|
|
||||||
state.approval_failed("old", "response lost".into());
|
|
||||||
|
|
||||||
assert_eq!(state.error, None);
|
|
||||||
assert!(state.approval_submitting);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matching_approval_surfaces_its_failure() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.rows.push(row(
|
|
||||||
"approval:1",
|
|
||||||
TranscriptRowKind::Approval,
|
|
||||||
json!({"options":[{"kind":"allow_once"}]}),
|
|
||||||
));
|
|
||||||
state.selected = Some("approval:1".into());
|
|
||||||
state.reduced.pending_approval = Some(json!({"nonce":"current"}));
|
|
||||||
assert!(state.approval_decision('a').is_some());
|
|
||||||
|
|
||||||
state.approval_failed("current", "request failed".into());
|
|
||||||
|
|
||||||
assert_eq!(state.error.as_deref(), Some("request failed"));
|
|
||||||
assert!(!state.approval_submitting);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.edit_composer(&InputKey::char('h'));
|
|
||||||
state.edit_composer(&InputKey::char('i'));
|
|
||||||
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
|
||||||
assert!(state.turn_active());
|
|
||||||
state.prompt_failed("hi".into(), "nope".into());
|
|
||||||
assert_eq!(state.composer.value(), "hi");
|
|
||||||
|
|
||||||
state.reduced.turn = TurnState {
|
|
||||||
responding: true,
|
|
||||||
..TurnState::default()
|
|
||||||
};
|
|
||||||
state.edit_composer(&InputKey::char('x'));
|
|
||||||
assert_eq!(state.composer.value(), "hi");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn 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();
|
|
||||||
state.edit_composer(&InputKey::char('h'));
|
|
||||||
state.edit_composer(&InputKey::char('i'));
|
|
||||||
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
|
||||||
|
|
||||||
state.apply(StructuredEvent::ProjectionUpdate {
|
|
||||||
transcript: None,
|
|
||||||
state: ReducedState::default(),
|
|
||||||
});
|
|
||||||
assert!(
|
|
||||||
state.turn_active(),
|
|
||||||
"an ordinary idle update must not reopen the composer before turn begin"
|
|
||||||
);
|
|
||||||
|
|
||||||
state.prepare_snapshot_reconciliation();
|
|
||||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
|
||||||
snapshot: TranscriptSnapshot {
|
|
||||||
cursor: 0,
|
|
||||||
rows: Vec::new(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.apply(StructuredEvent::ReducedState {
|
|
||||||
state: ReducedState::default(),
|
|
||||||
});
|
|
||||||
assert!(
|
|
||||||
!state.turn_active(),
|
|
||||||
"a reconnect snapshot must settle a submission whose whole turn was missed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_handshake_state_does_not_settle_a_new_submission() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.prepare_snapshot_reconciliation();
|
|
||||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
|
||||||
snapshot: TranscriptSnapshot {
|
|
||||||
cursor: 0,
|
|
||||||
rows: Vec::new(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
state.edit_composer(&InputKey::char('h'));
|
|
||||||
state.edit_composer(&InputKey::char('i'));
|
|
||||||
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
|
||||||
state.apply(StructuredEvent::ReducedState {
|
|
||||||
state: ReducedState::default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
state.turn_active(),
|
|
||||||
"state captured before this submission must not reopen the composer"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failed_submission_reconciliation_does_not_settle_its_successor() {
|
|
||||||
let mut state = StructuredState::default();
|
|
||||||
state.edit_composer(&InputKey::char('a'));
|
|
||||||
assert_eq!(state.begin_prompt().as_deref(), Some("a"));
|
|
||||||
state.prepare_snapshot_reconciliation();
|
|
||||||
|
|
||||||
state.prompt_failed("a".into(), "nope".into());
|
|
||||||
state.composer = TextInput::with_value("b");
|
|
||||||
assert_eq!(state.begin_prompt().as_deref(), Some("b"));
|
|
||||||
|
|
||||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
|
||||||
snapshot: TranscriptSnapshot {
|
|
||||||
cursor: 0,
|
|
||||||
rows: Vec::new(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
state.apply(StructuredEvent::ReducedState {
|
|
||||||
state: ReducedState::default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
state.turn_active(),
|
|
||||||
"a reconnect for the failed submission must not settle its successor"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
//! The TUI's structured actions use the shared typed daemon client; assert the
|
|
||||||
//! exact route and bare approval decision body at the HTTP boundary.
|
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::routing::post;
|
|
||||||
use axum::{Json, Router};
|
|
||||||
use maestro_protocol::{DaemonClient, StructuredApprovalDecision, StructuredPromptRequest};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct Seen {
|
|
||||||
prompt: Option<String>,
|
|
||||||
cancelled: bool,
|
|
||||||
approval: Option<(String, StructuredApprovalDecision)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Shared = Arc<Mutex<Seen>>;
|
|
||||||
|
|
||||||
async fn prompt(
|
|
||||||
State(seen): State<Shared>,
|
|
||||||
Path(_id): Path<Uuid>,
|
|
||||||
Json(body): Json<StructuredPromptRequest>,
|
|
||||||
) {
|
|
||||||
seen.lock().unwrap().prompt = Some(body.prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cancel(State(seen): State<Shared>, Path(_id): Path<Uuid>) {
|
|
||||||
seen.lock().unwrap().cancelled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn approval(
|
|
||||||
State(seen): State<Shared>,
|
|
||||||
Path((_id, nonce)): Path<(Uuid, String)>,
|
|
||||||
Json(decision): Json<StructuredApprovalDecision>,
|
|
||||||
) {
|
|
||||||
seen.lock().unwrap().approval = Some((nonce, decision));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn prompt_cancel_and_approval_use_structured_api_contract() {
|
|
||||||
let seen = Shared::default();
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/api/sessions/:id/structured/prompt", post(prompt))
|
|
||||||
.route("/api/sessions/:id/structured/cancel", post(cancel))
|
|
||||||
.route(
|
|
||||||
"/api/sessions/:id/structured/approvals/:nonce",
|
|
||||||
post(approval),
|
|
||||||
)
|
|
||||||
.with_state(seen.clone());
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
||||||
|
|
||||||
let client = DaemonClient::new(format!("http://{addr}"));
|
|
||||||
let id = Uuid::from_u128(141);
|
|
||||||
client.send_structured_prompt(id, "ship it").await.unwrap();
|
|
||||||
client.cancel_structured_turn(id).await.unwrap();
|
|
||||||
client
|
|
||||||
.resolve_structured_approval(
|
|
||||||
id,
|
|
||||||
"one-use-nonce",
|
|
||||||
StructuredApprovalDecision::AllowForSession,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let seen = seen.lock().unwrap();
|
|
||||||
assert_eq!(seen.prompt.as_deref(), Some("ship it"));
|
|
||||||
assert!(seen.cancelled);
|
|
||||||
assert_eq!(
|
|
||||||
seen.approval,
|
|
||||||
Some((
|
|
||||||
"one-use-nonce".into(),
|
|
||||||
StructuredApprovalDecision::AllowForSession
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
//! Projection socket coverage: snapshots replace state on reconnect and live
|
|
||||||
//! deltas patch rows without client-side event folding.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use axum::extract::ws::{Message, WebSocketUpgrade};
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::response::Response;
|
|
||||||
use axum::routing::any;
|
|
||||||
use axum::Router;
|
|
||||||
use maestro_protocol::{
|
|
||||||
ReducedState, StructuredEvent, TranscriptDelta, TranscriptRow, TranscriptRowKind,
|
|
||||||
TranscriptSnapshot,
|
|
||||||
};
|
|
||||||
use maestro_tui::session_task::SessionState;
|
|
||||||
use maestro_tui::structured_task::StructuredTask;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
async fn structured_handler(
|
|
||||||
State(connects): State<Arc<AtomicUsize>>,
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
) -> Response {
|
|
||||||
ws.on_upgrade(move |mut socket| async move {
|
|
||||||
let attempt = connects.fetch_add(1, Ordering::SeqCst);
|
|
||||||
let snapshot = if attempt == 0 {
|
|
||||||
TranscriptSnapshot {
|
|
||||||
cursor: 1,
|
|
||||||
rows: vec![row("old", json!({"content":{"type":"text","text":"old"}}))],
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TranscriptSnapshot {
|
|
||||||
cursor: 3,
|
|
||||||
rows: vec![row("live", json!({"content":{"type":"text","text":"one"}}))],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
send(
|
|
||||||
&mut socket,
|
|
||||||
StructuredEvent::TranscriptSnapshot { snapshot },
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if attempt == 0 {
|
|
||||||
let _ = socket.close().await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
send(
|
|
||||||
&mut socket,
|
|
||||||
StructuredEvent::ProjectionUpdate {
|
|
||||||
transcript: Some(TranscriptDelta::Patch {
|
|
||||||
row: row("live", json!({"content":{"type":"text","text":"two"}})),
|
|
||||||
}),
|
|
||||||
state: ReducedState {
|
|
||||||
model: Some("test-model".into()),
|
|
||||||
..ReducedState::default()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row(id: &str, data: serde_json::Value) -> TranscriptRow {
|
|
||||||
TranscriptRow {
|
|
||||||
id: id.into(),
|
|
||||||
kind: TranscriptRowKind::AgentText,
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(socket: &mut axum::extract::ws::WebSocket, event: StructuredEvent) {
|
|
||||||
socket
|
|
||||||
.send(Message::Text(serde_json::to_string(&event).unwrap()))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn reconnect_snapshot_replaces_then_delta_patches_projection() {
|
|
||||||
let connects = Arc::new(AtomicUsize::new(0));
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/api/structured/x", any(structured_handler))
|
|
||||||
.with_state(connects.clone());
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
||||||
|
|
||||||
let handle = StructuredTask::spawn(format!("ws://{addr}/api/structured/x"));
|
|
||||||
for _ in 0..250 {
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
let state = handle.state.lock().unwrap().clone();
|
|
||||||
if state.reduced.model.as_deref() == Some("test-model") {
|
|
||||||
assert_eq!(state.rows.len(), 1);
|
|
||||||
assert_eq!(state.rows[0].id, "live");
|
|
||||||
assert_eq!(state.rows[0].data["content"]["text"], "two");
|
|
||||||
assert!(connects.load(Ordering::SeqCst) >= 2);
|
|
||||||
handle.end();
|
|
||||||
assert_eq!(handle.connection(), SessionState::Ended);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
panic!(
|
|
||||||
"structured projection never reached reconnect delta; connection={:?}",
|
|
||||||
handle.connection()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,6 +29,3 @@ tracing-subscriber.workspace = true
|
|||||||
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
|
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
|
||||||
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
|
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
|
||||||
maestro-tui = { path = "../maestro-tui", optional = true }
|
maestro-tui = { path = "../maestro-tui", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile.workspace = true
|
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
+4
-42
@@ -18,14 +18,10 @@ wizard, and cleans up — only the unified `maestro` binary ends up persisted in
|
|||||||
|
|
||||||
**Updating is the same command.** Re-running `./install.sh` detects an existing
|
**Updating is the same command.** Re-running `./install.sh` detects an existing
|
||||||
systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead
|
systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead
|
||||||
of `start`. No `--update` flag needed. **The restart keeps your terminal sessions:** the unit sets
|
of `start`. No `--update` flag needed. **The restart keeps your sessions:** the unit sets
|
||||||
`KillMode=process`, so systemd signals only the daemon — the tmux server (and
|
`KillMode=process`, so systemd signals only the daemon — the tmux server (and
|
||||||
every tmux-backed session) survives, and the new daemon re-adopts them on
|
every session) survives, and the new daemon re-adopts them on start. A unit
|
||||||
start. Structured sessions (an agent run as a daemon child over ACP) are not
|
written before this default lacks the line; re-run `./install.sh` to refresh it.
|
||||||
tmux panes, so shutdown ends their active turn; the transcript survives and
|
|
||||||
the next daemon attempts one resume (`docs/RESTART.md#structured-sessions`). A
|
|
||||||
unit written before this default lacks the line; re-run `./install.sh` to
|
|
||||||
refresh it.
|
|
||||||
|
|
||||||
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
|
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
|
||||||
uses your already-configured git credentials for this host (HTTPS helper or
|
uses your already-configured git credentials for this host (HTTPS helper or
|
||||||
@@ -98,10 +94,7 @@ running a local daemon, for a host that only talks to another machine's.
|
|||||||
|
|
||||||
The daemon unit carries the same `KillMode=process` as the wizard-written one,
|
The daemon unit carries the same `KillMode=process` as the wizard-written one,
|
||||||
so `systemctl --user restart maestro-daemon` leaves the tmux server and every
|
so `systemctl --user restart maestro-daemon` leaves the tmux server and every
|
||||||
terminal session alive. Structured sessions end their active turn on
|
session alive.
|
||||||
shutdown and resume once on the next daemon: ACP state comes back via
|
|
||||||
`session/load`, while the event log only replays Maestro's own transcript
|
|
||||||
projection (`docs/RESTART.md#structured-sessions`).
|
|
||||||
|
|
||||||
## Thin builds for single-role hosts
|
## Thin builds for single-role hosts
|
||||||
|
|
||||||
@@ -195,37 +188,6 @@ An unknown name is rejected as an `[tui].theme` configuration error.
|
|||||||
The full action vocabulary is the `Action` enum in
|
The full action vocabulary is the `Action` enum in
|
||||||
`crates/maestro-tui/src/action.rs`.
|
`crates/maestro-tui/src/action.rs`.
|
||||||
|
|
||||||
## Structured sessions: ACP adapters
|
|
||||||
|
|
||||||
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
|
|
||||||
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` 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
|
|
||||||
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 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
|
|
||||||
starts a new adapter, resumes it with `session/load`, and replays the
|
|
||||||
transcript. Terminal sessions keep surviving restarts through tmux. See
|
|
||||||
`docs/STRUCTURED-VIEW.md` for the full contract — row kinds, projections, the
|
|
||||||
handshake, and the approval and recovery rules — and `docs/RESTART.md` for
|
|
||||||
what a client observes across the restart.
|
|
||||||
|
|
||||||
## The browser UI
|
## The browser UI
|
||||||
|
|
||||||
The daemon serves the SPA off its own listener. Open
|
The daemon serves the SPA off its own listener. Open
|
||||||
|
|||||||
+4
-39
@@ -2,16 +2,10 @@
|
|||||||
|
|
||||||
Maestro is upgraded in place: the operator reinstalls the binary and systemd
|
Maestro is upgraded in place: the operator reinstalls the binary and systemd
|
||||||
restarts the daemon under every browser tab and TUI that is attached to it.
|
restarts the daemon under every browser tab and TUI that is attached to it.
|
||||||
tmux keeps **terminal** sessions alive across that (see `docs/INSTALL.md`),
|
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
|
||||||
but clients lose their sockets either way. This document is the contract for
|
lose their sockets. This document is the contract for what a client does about
|
||||||
what a client does about it — what the daemon publishes, how a client tells a
|
it — what the daemon publishes, how a client tells a restart from a blip, and
|
||||||
restart from a blip, and what the user sees while it happens.
|
what the user sees while it happens.
|
||||||
|
|
||||||
A **structured** session (`docs/STRUCTURED-VIEW.md`) has no tmux pane behind
|
|
||||||
it to survive on: its agent is a child of the daemon process itself, so a
|
|
||||||
restart takes it down with the daemon. See
|
|
||||||
[Structured sessions](#structured-sessions) below for exactly what that
|
|
||||||
costs and what it doesn't.
|
|
||||||
|
|
||||||
## What the daemon publishes
|
## What the daemon publishes
|
||||||
|
|
||||||
@@ -74,35 +68,6 @@ resolves, so a blocked client is never a trapped one. It clears itself the
|
|||||||
moment a probe succeeds — there is no "retry" button, because the client is
|
moment a probe succeeds — there is no "retry" button, because the client is
|
||||||
already retrying.
|
already retrying.
|
||||||
|
|
||||||
## Structured sessions
|
|
||||||
|
|
||||||
Everything above describes a **client's** view of a restart, and it holds for
|
|
||||||
a structured session exactly as written — the same three verdicts, the same
|
|
||||||
status indicator, the same overlay. What differs is what survives on the
|
|
||||||
**daemon** side, because a structured session's agent is a daemon child, not
|
|
||||||
a tmux pane:
|
|
||||||
|
|
||||||
- A restart **ends the active turn**. There is no adapter process left to
|
|
||||||
finish it, and no tmux pane keeping it alive underneath.
|
|
||||||
- The **transcript survives**, in the per-session event log
|
|
||||||
(`docs/STRUCTURED-VIEW.md#persistence-recovery-and-dead-sessions`) — a
|
|
||||||
graceful shutdown marks the session for one resume attempt. Recovery is
|
|
||||||
two separate operations, not one: the next daemon calls `session/load` so
|
|
||||||
the adapter restores its own ACP session, but whatever history the
|
|
||||||
adapter emits during that call is discarded, not replayed into anything —
|
|
||||||
the daemon already has that history on disk. Separately, the daemon
|
|
||||||
rebuilds its own projection by folding the persisted event log back
|
|
||||||
through it. An ungraceful death (a crash, `kill -9`) leaves no resume
|
|
||||||
marker, and the session stays dead permanently.
|
|
||||||
- **Pending approvals and their nonces do not survive.** Both live only in
|
|
||||||
the old daemon's memory; any ACP permission request still mid-flight when
|
|
||||||
the daemon exits is answered `Cancelled` on the way down, and a resumed
|
|
||||||
session starts with no pending approval at all.
|
|
||||||
|
|
||||||
`docs/STRUCTURED-VIEW.md` is the full contract for all of this — the event
|
|
||||||
log's shape, the recovery marker, and exactly which kind of session death is
|
|
||||||
resumable.
|
|
||||||
|
|
||||||
## Timing
|
## Timing
|
||||||
|
|
||||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||||
|
|||||||
+8
-27
@@ -160,7 +160,6 @@ The state word is never spelled out — the colour carries it.
|
|||||||
| ---------------- | --------- | ---------------- |
|
| ---------------- | --------- | ---------------- |
|
||||||
| `Active` | `--live` | `active` |
|
| `Active` | `--live` | `active` |
|
||||||
| `Idle` | `--muted` | `idle` |
|
| `Idle` | `--muted` | `idle` |
|
||||||
| `Starting` | `--muted` | `idle` |
|
|
||||||
| `AwaitingInput` | `--warn` | `awaiting` |
|
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||||
| `Error` | `--danger`| `error` |
|
| `Error` | `--danger`| `error` |
|
||||||
|
|
||||||
@@ -232,24 +231,7 @@ cutover points land within one tick.
|
|||||||
|
|
||||||
## Precedence
|
## Precedence
|
||||||
|
|
||||||
### Activity source by session kind
|
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
|
||||||
|
|
||||||
The daemon supplies one `activity` field to both clients, but its source depends
|
|
||||||
on the session kind:
|
|
||||||
|
|
||||||
| Session kind | Source |
|
|
||||||
|---|---|
|
|
||||||
| Terminal | The existing agent-report override and five-minute TTL, falling back to PTY quiescence, foreground process and `/proc` input-block inference. |
|
|
||||||
| Structured | ACP state directly: a pending approval is `AwaitingInput`; otherwise an in-flight turn is `Active`; no turn is `Idle`. A failed turn or dead structured session is `Error`, and the next turn clears a failed-turn error. |
|
|
||||||
|
|
||||||
Structured sessions never enter the terminal inference path and ignore activity
|
|
||||||
reports inherited through agent hooks. They have no PTY whose output or blocked
|
|
||||||
read could describe their state. This is a second source, not a replacement for
|
|
||||||
the terminal source.
|
|
||||||
|
|
||||||
### Terminal report override
|
|
||||||
|
|
||||||
For terminal sessions, an agent-reported `ActivityState` (#105) overrides the daemon's inference for
|
|
||||||
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
|
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
|
||||||
When the TTL expires, inference resumes and the session's reported detail —
|
When the TTL expires, inference resumes and the session's reported detail —
|
||||||
if any — clears with the report. Clients never blend the two: whichever source
|
if any — clears with the report. Clients never blend the two: whichever source
|
||||||
@@ -299,10 +281,10 @@ session is genuinely `Active` again — new work, not merely a refocus. Only
|
|||||||
after that fresh `Active` cycle does the *next* `AwaitingInput` glow again.
|
after that fresh `Active` cycle does the *next* `AwaitingInput` glow again.
|
||||||
Unfocusing and refocusing alone never re-arms it.
|
Unfocusing and refocusing alone never re-arms it.
|
||||||
|
|
||||||
This applies uniformly to `AwaitingInput` from terminal inference, an agent
|
This applies uniformly to `AwaitingInput` from either source — the daemon's
|
||||||
report (#105), or a structured session's pending approval, since the rail draws
|
own inference or an agent's report (#105) — since the rail draws them
|
||||||
them identically. It does not touch `Error`; terminal reports keep their TTL,
|
identically. It does not touch `Error`; that state's own TTL (below) is
|
||||||
while a structured failed-turn error clears when the next turn starts.
|
unrelated and unaffected by focus.
|
||||||
|
|
||||||
Each client reports its own pane's focus with `POST /api/sessions/:id/focus`
|
Each client reports its own pane's focus with `POST /api/sessions/:id/focus`
|
||||||
(`{"focused": bool}`) whenever the pane it is showing changes — on attach, on
|
(`{"focused": bool}`) whenever the pane it is showing changes — on attach, on
|
||||||
@@ -325,10 +307,9 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
|
|||||||
reachable by clicking a folder header — still steps to its neighbour rather
|
reachable by clicking a folder header — still steps to its neighbour rather
|
||||||
than jumping to the end of the list.
|
than jumping to the end of the list.
|
||||||
|
|
||||||
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
|
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
||||||
by default — and the same chord back): an attached terminal owns every plain
|
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
||||||
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
|
`ctrl-w h`.
|
||||||
uses `ctrl-w h`.
|
|
||||||
|
|
||||||
Everything else is the same key on both clients:
|
Everything else is the same key on both clients:
|
||||||
|
|
||||||
|
|||||||
@@ -1,459 +0,0 @@
|
|||||||
# STRUCTURED-VIEW.md — the structured session contract
|
|
||||||
|
|
||||||
The contract for a structured session (`view: Structured`, #133): an agent run
|
|
||||||
as a daemon child over the Agent Client Protocol (ACP) instead of in a tmux
|
|
||||||
pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
|
|
||||||
read this before changing anything that renders a structured session — both
|
|
||||||
the web and TUI renderers are written against it.
|
|
||||||
|
|
||||||
It covers the eight transcript row kinds, what the daemon decides versus what
|
|
||||||
a client decides, the two projections and their delta/cursor semantics, the
|
|
||||||
ACP handshake and the capabilities it declines, the adapter's install
|
|
||||||
command, and the approval nonce rules. **Non-goal: the detached runner.** It
|
|
||||||
does not exist yet, and nothing here describes it.
|
|
||||||
|
|
||||||
## What a structured session is
|
|
||||||
|
|
||||||
A terminal session's agent runs inside a tmux pane; a structured session's
|
|
||||||
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
|
|
||||||
daemon is the ACP **client**; the 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.
|
|
||||||
|
|
||||||
The child is a real child of the daemon process: same process group, and on
|
|
||||||
Linux marked with `PR_SET_PDEATHSIG` so it dies with the daemon even if the
|
|
||||||
daemon exits without cleaning up; other platforms have no such signal and
|
|
||||||
depend on an orderly shutdown/drop to terminate it. The ordinary path on
|
|
||||||
every platform is `AcpSession::terminate` (`session/cancel`, a short grace,
|
|
||||||
`SIGTERM`, then `SIGKILL`), called from both `DELETE /api/sessions/:id` and
|
|
||||||
daemon shutdown.
|
|
||||||
`docs/WORKTREES.md`'s kill-guard rule — an unforced kill refused while the
|
|
||||||
session's worktree holds unsaved work — applies to a structured session
|
|
||||||
exactly as it does to a terminal one; only what gets terminated differs (an
|
|
||||||
ACP handle instead of a tmux pane).
|
|
||||||
|
|
||||||
## Choosing the view
|
|
||||||
|
|
||||||
`view` is fixed at spawn and never changes. Every client picks it the same
|
|
||||||
way, from the resolved command alone
|
|
||||||
(`maestro_protocol::display::supports_structured_view`): a command that
|
|
||||||
resolves to Claude Code or Codex spawns **Structured** by default, anything
|
|
||||||
else spawns **Terminal**. The operator can override the default — the web and
|
|
||||||
TUI spawn forms show the checkbox ticked and let it be unticked, and
|
|
||||||
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
|
|
||||||
even for a command the CLI does not recognise and lets the daemon refuse it).
|
|
||||||
Once the operator works the toggle, a later command change never re-ticks it
|
|
||||||
under them; it can still be forced off when the new command has no adapter.
|
|
||||||
|
|
||||||
The daemon is the last word: a `Structured` request for a command with no
|
|
||||||
adapter is refused there, whichever client sent it.
|
|
||||||
|
|
||||||
## The adapter
|
|
||||||
|
|
||||||
The 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.
|
|
||||||
|
|
||||||
| 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. |
|
|
||||||
|
|
||||||
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 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
|
|
||||||
|
|
||||||
`initialize` is where the daemon states what kind of client it is. It
|
|
||||||
declares, deliberately, almost nothing:
|
|
||||||
|
|
||||||
| Capability | Declared | Why |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `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 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.
|
|
||||||
Before #139 it was a stub that declined every request; that stub is gone now
|
|
||||||
that a live implementation exists.
|
|
||||||
|
|
||||||
`session/new` opens a fresh ACP session on every ordinary spawn. A respawn
|
|
||||||
after a daemon restart instead sets `SpawnConfig::load_session(id)` and runs
|
|
||||||
`session/load` with the ACP session id the original handshake returned
|
|
||||||
(`Session::acp_session_id` on the wire) — the adapter must advertise
|
|
||||||
`agent_capabilities.load_session`, or the handshake fails outright naming
|
|
||||||
that. Whatever history the adapter streams back while `session/load` is in
|
|
||||||
flight is dropped on arrival, deliberately: the daemon's own event log
|
|
||||||
already holds that history, and forwarding it too would duplicate every row
|
|
||||||
in the projections below.
|
|
||||||
|
|
||||||
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
|
|
||||||
transcript is written in. Every row has a stable `id` (so later deltas can
|
|
||||||
address it), a `kind`, and a `data: Value` that preserves the underlying ACP
|
|
||||||
payload — clients depend on ACP's *shape* without depending on ACP as a
|
|
||||||
crate.
|
|
||||||
|
|
||||||
| Kind | Carries | Row identity |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `User` | 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. 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
|
|
||||||
currently folds `available_commands_update` and `session_info_update`
|
|
||||||
there) but the projection's `match` doesn't fold into one of the other
|
|
||||||
seven kinds. It is not a wire-level forward-compatibility seam:
|
|
||||||
`SessionUpdate` is an internally-tagged enum with no unknown-variant
|
|
||||||
fallback, so a `session/update` whose `sessionUpdate` tag this build's
|
|
||||||
dependency has never heard of — including a variant gated behind an
|
|
||||||
unstable ACP feature this build doesn't enable, which is compiled out of
|
|
||||||
the enum entirely — fails to deserialize before the projection ever runs,
|
|
||||||
rather than becoming a `Notice`. A genuinely new ACP variant needs the
|
|
||||||
`agent-client-protocol` dependency upgraded (and, typically, the projection's
|
|
||||||
`match` taught about it) before it can appear on this transcript at all — see
|
|
||||||
`projection.rs`'s test asserting that an unrecognised `sessionUpdate` tag is a
|
|
||||||
deserialization error, not a `Notice`. A client that does not know what to do
|
|
||||||
with a `Notice` can still render it as an opaque, collapsed line — that
|
|
||||||
remains a reasonable floor, not a client bug.
|
|
||||||
|
|
||||||
Rendering is entirely a client concern. The projection's job stops at
|
|
||||||
folding ACP into these eight shapes; markdown formatting, diff rendering,
|
|
||||||
tool-card treatment, and how a `Notice` is presented are all renderer
|
|
||||||
choices, not daemon ones.
|
|
||||||
|
|
||||||
## Daemon decisions vs. client decisions
|
|
||||||
|
|
||||||
**The daemon decides:**
|
|
||||||
|
|
||||||
- Which ACP `session/update` becomes which row kind, and under what id — the
|
|
||||||
fold in `projection.rs` is the one place this logic lives, so the web and
|
|
||||||
TUI renderers can never disagree about it.
|
|
||||||
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
|
|
||||||
or a failure ends it (`TurnState.responding` / `.thinking` /
|
|
||||||
`.active_tool_calls` / `.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.
|
|
||||||
- What happens to an ACP update variant the dependency decodes but nobody has
|
|
||||||
taught the projection to special-case (falls back to `Notice` rather than
|
|
||||||
erroring). A variant the dependency itself can't decode is a deserialization
|
|
||||||
error, not a `Notice` — see [The eight transcript row
|
|
||||||
kinds](#the-eight-transcript-row-kinds).
|
|
||||||
- Persistence and replay: what is written to the event log, in what order,
|
|
||||||
and how a restart resumes it.
|
|
||||||
|
|
||||||
**A client decides:**
|
|
||||||
|
|
||||||
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
|
||||||
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
|
|
||||||
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
|
||||||
from the transcript; the TUI may show the current plan in its status line,
|
|
||||||
while the web client deliberately has no stats or plan rail.
|
|
||||||
- The web client groups two or more tool rows after the same `User` row into
|
|
||||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
|
||||||
appear *between* tool rows in the same turn are absorbed into the group
|
|
||||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
|
||||||
outside as the prominent final answer. While a turn is still active
|
|
||||||
(responding or thinking), the most recent intermediate `AgentText` or
|
|
||||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
|
||||||
by the next text row when one arrives, and absorbed into the group when the
|
|
||||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
|
||||||
Opening the group reveals tools and intermediate text in chronological
|
|
||||||
order, with the text rendered in a subdued style.
|
|
||||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
|
||||||
source for composer `/` autocomplete at any whitespace-delimited token under
|
|
||||||
the caret. Completing a command replaces only that token. The client omits
|
|
||||||
those state-only notices, along with `session_info_update`, from the visible
|
|
||||||
transcript.
|
|
||||||
- How live `responding`, `thinking`, tool activity, elapsed time, and context
|
|
||||||
usage are drawn. The web client keeps these in a compact strip above the
|
|
||||||
composer and renders each durable turn footer inline with the transcript.
|
|
||||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
|
||||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
|
||||||
never the UI that solicits it.
|
|
||||||
- Scrolling, collapsing, and every other presentation-only behaviour.
|
|
||||||
|
|
||||||
## The two projections
|
|
||||||
|
|
||||||
`Projection` (daemon-only) is folded once from the durable ACP stream and
|
|
||||||
exposes two independent views, both defined in `maestro-protocol`:
|
|
||||||
|
|
||||||
- **`TranscriptSnapshot { cursor, rows }`** — the complete ordered list of
|
|
||||||
transcript rows built so far, plus the log position (`cursor`) it reflects.
|
|
||||||
It is intentionally always the *whole* transcript, not a windowed one: a
|
|
||||||
tool row patched much later must still find its original entry.
|
|
||||||
- **`ReducedState`** — one whole-session snapshot folded over every event
|
|
||||||
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
|
|
||||||
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
|
|
||||||
always full-history, never a delta — a client that only wants "is a turn
|
|
||||||
active right now" never has to replay anything to answer that.
|
|
||||||
|
|
||||||
Changes to the transcript travel as `TranscriptDelta`, tagged `type`:
|
|
||||||
`Append { row }` (a new row), `Patch { row }` (an existing row, addressed by
|
|
||||||
`id`, replaced wholesale), and `Remove { id }`. `Remove` is defined on the
|
|
||||||
wire for forward compatibility; nothing in the current projection emits it —
|
|
||||||
every row, once appended, is only ever patched or left alone.
|
|
||||||
|
|
||||||
### Cursor and replay semantics
|
|
||||||
|
|
||||||
The event log's own line position is the cursor: 1-based, shared by ACP
|
|
||||||
notifications and daemon-generated rows alike (`sidecar.rs`'s
|
|
||||||
`PersistedEvent` envelope is what lets `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
|
|
||||||
a `ReducedState` covering the whole log as it stands, then streams
|
|
||||||
`ProjectionUpdate { transcript, state }` for everything that happens next.
|
|
||||||
There is no client-supplied resume cursor on this socket: every connection
|
|
||||||
gets the full snapshot, not a gap-filled continuation, so a client never
|
|
||||||
needs the replay endpoint just to obtain the current transcript. Use
|
|
||||||
replay instead for cursor-bounded pagination through the durable log —
|
|
||||||
history from before this connection, fetched a page at a time.
|
|
||||||
- **`GET /api/sessions/:id/structured/replay`** — cursor-bounded pagination
|
|
||||||
over the durable log, returning one `StructuredReplayEvent` (`cursor`,
|
|
||||||
`transcript`, `state`) per matching line. `?since=<n>` returns the first
|
|
||||||
page after `n` (`since=0` selects from the beginning, i.e. the first
|
|
||||||
page); a client pages forward from there using the cursor of the last
|
|
||||||
event returned. `?before=<n>` returns the page immediately before `n`,
|
|
||||||
still in ascending order — passing both is a `400`. `?limit` defaults to
|
|
||||||
100 and is clamped to 500. Each call re-folds
|
|
||||||
the entire log from scratch to compute the state at every returned cursor,
|
|
||||||
so a page reflects exactly the *durable* state at its cursor, not an
|
|
||||||
approximation of it — at the cost of redoing the whole fold per request.
|
|
||||||
That durable state is not always identical to what a live client saw at
|
|
||||||
that moment, though: turn lifecycle transitions are broadcast live without
|
|
||||||
a durable event backing them (see [Prompt and cancel](#prompt-and-cancel-bounded-liveness)),
|
|
||||||
and a restored `Approval` row carries no pending nonce, because the nonce
|
|
||||||
never reached disk (see [Approvals](#approvals)) — replay is exact and
|
|
||||||
reconstructible, not a perfect record of everything the live view showed.
|
|
||||||
A session that is not a live structured session (terminal, or a dead
|
|
||||||
adapter) answers `409 not_structured` from either route rather than a
|
|
||||||
coherent-looking but stale reply.
|
|
||||||
|
|
||||||
## Approvals
|
|
||||||
|
|
||||||
An ACP `session/request_permission` callback becomes an `Approval` row plus a
|
|
||||||
pending decision, entirely on the daemon side:
|
|
||||||
|
|
||||||
- **Generation.** The daemon mints the nonce itself — a fresh
|
|
||||||
`Uuid::new_v4()` string per request — rather than using anything ACP's own
|
|
||||||
request carries. ACP permission callbacks have no request id of their own
|
|
||||||
that would double as one.
|
|
||||||
- **Live-only placement.** The nonce lives in exactly one place: the
|
|
||||||
in-memory `pending_approvals` map keyed by nonce, and its mirror in
|
|
||||||
`ReducedState.pending_approval.nonce` on the live wire. It is never part of
|
|
||||||
the `Approval` row's `data` — the row is `RequestPermissionRequest`
|
|
||||||
serialized *without* the nonce.
|
|
||||||
- **Secrecy from the agent and from durable history.** The adapter itself
|
|
||||||
never learns the nonce — it is a daemon-to-client secret, never sent back
|
|
||||||
over the ACP connection. It is likewise never written to the event log:
|
|
||||||
restoring an `Approval` row from disk (`Projection::restore_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
|
|
||||||
[Persistence](#persistence-recovery-and-dead-sessions).
|
|
||||||
- **One live approval at a time.** A new permission request mints its own
|
|
||||||
nonce, then cancels whatever was already pending (`cancel_pending_approvals`)
|
|
||||||
before installing itself as the new pending approval — ACP serializes
|
|
||||||
requests through one connection, so this only ever fires if an adapter
|
|
||||||
nonetheless overlaps two. Either way, the prior pending request is
|
|
||||||
cancelled/cleared before the new one is installed, so the newest request
|
|
||||||
wins.
|
|
||||||
- **Single-use and duplicate resolution.**
|
|
||||||
`POST /api/sessions/:id/structured/approvals/:nonce` **always answers
|
|
||||||
`204`** for a known, live structured session — whether the nonce is the
|
|
||||||
one currently pending, one already consumed by an earlier answer, or one
|
|
||||||
that never existed. Only an unknown or non-structured *session* answers
|
|
||||||
otherwise (`404` / `409`). This is deliberate: two clients racing to answer the same
|
|
||||||
approval must not turn a harmless duplicate click into a client-visible
|
|
||||||
error. Whichever `POST` reaches the daemon first removes the nonce from
|
|
||||||
`pending_approvals` and is the one whose decision actually reaches the
|
|
||||||
adapter; every later `POST` for that nonce is a no-op `204`.
|
|
||||||
- **Cancellation vs. denial.** A turn cancel or a session shutdown resolves
|
|
||||||
any pending approval with ACP's `Cancelled` outcome, not a `Deny` decision
|
|
||||||
— the adapter should read those as "the operation was withdrawn," not "the
|
|
||||||
operator refused it."
|
|
||||||
- **Decision mapping.** `AllowOnce` and `AllowForSession` select the ACP
|
|
||||||
option of kind `AllowOnce` / `AllowAlways` from the request's own option
|
|
||||||
list. `Deny` prefers a one-shot refusal (`RejectOnce`) and falls back to a
|
|
||||||
permanent one (`RejectAlways`) only when that is the sole rejection option
|
|
||||||
offered; if neither is present, it answers `Cancelled`.
|
|
||||||
|
|
||||||
## Prompt and cancel: bounded liveness
|
|
||||||
|
|
||||||
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
|
|
||||||
per-request id — the daemon's own notion of "the active turn" is the only
|
|
||||||
thing a callback can be attributed to, so at most one turn may ever be
|
|
||||||
in flight, and a cancelled turn must fully settle before the next prompt is
|
|
||||||
allowed to claim the daemon's idea of "the active turn."
|
|
||||||
|
|
||||||
- Web clients may collect a visible FIFO of operator messages while a turn is
|
|
||||||
active. They submit each queued message only after the projected turn becomes
|
|
||||||
idle; the queue is client-local and unsent messages do not survive a reload.
|
|
||||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
|
||||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
|
||||||
operator's input. If that append fails, the prompt is not dispatched. While a
|
|
||||||
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
|
||||||
queuing in the daemon — one active turn per session, never a hidden
|
|
||||||
server-side queue of prompts.
|
|
||||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
|
||||||
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
|
||||||
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
|
||||||
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
|
|
||||||
prompt claiming "the active turn" while the old one is still outstanding
|
|
||||||
would make the next permission callback ambiguous between the two.
|
|
||||||
- So a cancelled-but-still-dispatched turn must **settle within
|
|
||||||
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
|
|
||||||
resolves) or that grace period runs out. If it settles in time, the next
|
|
||||||
prompt proceeds normally. If it does not, the daemon does not guess: it
|
|
||||||
marks the whole structured session dead (`fail_cancelled_acp_turn`) rather
|
|
||||||
than risk a callback resolving against the wrong turn. This is the bounded
|
|
||||||
half of "bounded liveness" — a structured session's prompt/cancel dance
|
|
||||||
either resolves promptly or the session is declared unusable, never left
|
|
||||||
in an indefinite ambiguous state.
|
|
||||||
|
|
||||||
## Persistence, recovery, and dead sessions
|
|
||||||
|
|
||||||
A structured session's durable state is three files in the sidecar
|
|
||||||
directory: the sidecar itself (`<id>.json`), plus two next to it:
|
|
||||||
|
|
||||||
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
|
|
||||||
ACP `SessionNotification` (kept in its original JSON so old logs stay
|
|
||||||
readable) or a `{"maestroEvent": "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
|
|
||||||
`write_all` that errors after only a prefix reached disk, or a `flush` that
|
|
||||||
errors after a complete write — is rolled back to the exact byte length the
|
|
||||||
file was at before the call, so `last_seq` and the file's line count never
|
|
||||||
disagree and the same log handle can be appended to again immediately (#184).
|
|
||||||
If that rollback itself cannot restore the prior length, the append is
|
|
||||||
fatal: the log's consistency can no longer be promised, so the caller kills
|
|
||||||
the session instead of appending to it again.
|
|
||||||
- **`<id>.recover`** — an empty marker file. Its only meaning is "the daemon
|
|
||||||
that wrote this shut down gracefully while this session was running, and
|
|
||||||
the next daemon may attempt to resume it." It is consumed (deleted) the
|
|
||||||
moment a recovery attempt reads it — one recovery attempt per graceful
|
|
||||||
shutdown, never a standing flag.
|
|
||||||
|
|
||||||
Three ways a structured session ends up `Dead`, each with different
|
|
||||||
persistence consequences:
|
|
||||||
|
|
||||||
1. **The adapter exits, or a notification append to the event log fails,
|
|
||||||
while the daemon keeps running.** (An approval-row append failure is
|
|
||||||
narrower: as long as the log rolled back cleanly, it only cancels the
|
|
||||||
pending approval and rolls the unpersisted `Approval` row back out of the
|
|
||||||
live transcript, without marking the session dead — a snapshot taken after
|
|
||||||
the failure must never show a row durable replay could not reproduce
|
|
||||||
(#184). If the rollback itself did not restore the log's prior length,
|
|
||||||
this narrower path does not apply either: the session is marked dead the
|
|
||||||
same as any other append failure, since the log can no longer promise
|
|
||||||
`last_seq` matches its file.)
|
|
||||||
`mark_session_dead` applies the same natural-exit worktree rule
|
|
||||||
as a terminal session's own pane exiting (`docs/WORKTREES.md`): a pristine
|
|
||||||
worktree is removed, one holding unsaved work is left on disk. No recovery
|
|
||||||
marker is written. This death is permanent — a later daemon restart's
|
|
||||||
`recover()` finds the sidecar, sees no marker, and leaves the session
|
|
||||||
`Dead` without attempting to resume it.
|
|
||||||
2. **A cancelled turn fails to settle within `CANCEL_GRACE`.** Same
|
|
||||||
consequence as (1): the session is marked dead through the same path, no
|
|
||||||
marker, no future resumption.
|
|
||||||
3. **Graceful daemon shutdown**, while the session was still `Running`.
|
|
||||||
`mark_session_dead_after_shutdown` marks the session dead **without**
|
|
||||||
applying the worktree exit rule — the tree is kept exactly as it is,
|
|
||||||
pristine or not, because there is nothing "natural" about this exit — and
|
|
||||||
writes the `.recover` marker. The sidecar on disk therefore says `Dead`,
|
|
||||||
but is eligible for resumption.
|
|
||||||
|
|
||||||
Recovery, on the next daemon's `recover()`: for every `Structured` sidecar
|
|
||||||
not already adopted, the marker is consumed. When one was present and the
|
|
||||||
sidecar carries a stored `acp_session_id`, the daemon spawns a fresh adapter
|
|
||||||
with `SpawnConfig::load_session(acp_session_id)`, replays the
|
|
||||||
entire durable event log into a new `Projection` (rebuilding the transcript
|
|
||||||
and reduced state exactly as they were, with any restored `Approval` row
|
|
||||||
landing as history — never as a re-armed pending approval, since its nonce
|
|
||||||
never reached disk), and only then reopens the log for further appends. What
|
|
||||||
the adapter streams back while its own `session/load` is in flight is
|
|
||||||
discarded rather than appended, so the replayed history is not duplicated
|
|
||||||
(see [The ACP handshake](#the-acp-handshake)). Success flips the session back
|
|
||||||
to `Running`/`Idle`; failure (no stored `acp_session_id`, or the handshake
|
|
||||||
itself failing) leaves it `Dead` with the failure reason as
|
|
||||||
`activity_detail` — the same "tell the operator what to fix" pattern as a
|
|
||||||
missing adapter binary.
|
|
||||||
|
|
||||||
Pending approvals and their nonces never survive any of this. They live only
|
|
||||||
in the in-memory `pending_approvals` map and the live `ReducedState`, both
|
|
||||||
gone the instant the daemon that held them exits. Whatever ACP permission
|
|
||||||
request was mid-flight when the old daemon died was already answered
|
|
||||||
`Cancelled` on that daemon's own shutdown path, so a resumed session always
|
|
||||||
starts with no pending approval.
|
|
||||||
|
|
||||||
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
|
|
||||||
now that structured sessions exist alongside terminal ones.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
{
|
|
||||||
"_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; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
|
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1
-14
@@ -11,8 +11,7 @@
|
|||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0"
|
||||||
"marked": "^18.0.11"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"happy-dom": "^20.11.6",
|
"happy-dom": "^20.11.6",
|
||||||
@@ -1223,18 +1222,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
+3
-14
@@ -6,7 +6,7 @@
|
|||||||
"packageManager": "pnpm@9.15.4",
|
"packageManager": "pnpm@9.15.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b tsconfig.build.json && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -16,23 +16,12 @@
|
|||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-webgl": "^0.19.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": {
|
"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",
|
"happy-dom": "^20.11.6",
|
||||||
"tailwindcss": "^4.3.3",
|
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vitest": "^2.1.9",
|
"vitest": "^2.1.9"
|
||||||
"zustand": "^5.0.15"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+11
-994
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<div id="react-root" />
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+1
-68
@@ -11,9 +11,6 @@ import type {
|
|||||||
Projects,
|
Projects,
|
||||||
RecentDirs,
|
RecentDirs,
|
||||||
Session,
|
Session,
|
||||||
StructuredApprovalDecision,
|
|
||||||
StructuredConfigRequest,
|
|
||||||
StructuredPromptRequest,
|
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
@@ -94,21 +91,6 @@ 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
|
/** 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
|
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||||
@@ -170,53 +152,4 @@ export async function fetchBranches(path: string): Promise<Branches> {
|
|||||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a prompt to a structured session's ACP turn (#138). `409
|
export { eventsWsUrl } from "./routes.js";
|
||||||
* turn_in_flight` when one is already active — the caller reflects that from
|
|
||||||
* `ReducedState.turn` instead of racing this call. */
|
|
||||||
export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<void> {
|
|
||||||
await jsonOrError(
|
|
||||||
await fetch(routes.structuredPrompt(id), {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ prompt } satisfies StructuredPromptRequest),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
await fetch(routes.structuredCancel(id), { method: "POST", credentials: "same-origin" }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer a pending ACP permission request (#139). Always resolves once the
|
|
||||||
* session itself is a live structured session — a nonce that is unknown or
|
|
||||||
* already consumed still answers `204` (docs/STRUCTURED-VIEW.md), so two
|
|
||||||
* clients racing the same approval never see this reject. */
|
|
||||||
export async function resolveStructuredApproval(
|
|
||||||
id: Uuid,
|
|
||||||
nonce: string,
|
|
||||||
decision: StructuredApprovalDecision,
|
|
||||||
): Promise<void> {
|
|
||||||
await jsonOrError(
|
|
||||||
await fetch(routes.structuredApproval(id, nonce), {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify(decision),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
|
||||||
|
|||||||
+65
-35
@@ -4,15 +4,24 @@ import { Terminal } from "@xterm/xterm";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { attachWsUrl } from "./routes.js";
|
import { attachWsUrl } from "./routes.js";
|
||||||
|
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
||||||
|
import { CommandSet, defineCommands } from "./commands.js";
|
||||||
|
import { escapeHtml } from "./html.js";
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||||
import { requestToggleDrawer } from "./drawer.js";
|
import { requestToggleDrawer } from "./drawer.js";
|
||||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||||
import type { PaneConnection } from "./pane.js";
|
|
||||||
import type { ClientControl, Uuid } from "./types.js";
|
import type { ClientControl, Uuid } from "./types.js";
|
||||||
import { WebglPolicy } from "./webglRenderer.js";
|
import { WebglPolicy } from "./webglRenderer.js";
|
||||||
|
|
||||||
export type { AttachInfo } from "./paneTitle.js";
|
// The pane's half of the command registry (#148): plain Escape hands the
|
||||||
|
// keyboard back to the session list, the pair to the list's Tab.
|
||||||
|
const paneKeys = new CommandSet(
|
||||||
|
defineCommands({
|
||||||
|
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
|
||||||
|
}),
|
||||||
|
{ preventDefault: false },
|
||||||
|
);
|
||||||
|
|
||||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||||
* rationed rather than handed to every connection. */
|
* rationed rather than handed to every connection. */
|
||||||
@@ -29,7 +38,25 @@ const webglPolicy = new WebglPolicy();
|
|||||||
* session ending) tears the socket down — so switching away and back is
|
* session ending) tears the socket down — so switching away and back is
|
||||||
* instant, with scrollback already in place.
|
* instant, with scrollback already in place.
|
||||||
*/
|
*/
|
||||||
export interface AttachConnection extends PaneConnection {}
|
export interface AttachConnection {
|
||||||
|
readonly sessionId: Uuid;
|
||||||
|
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
||||||
|
readonly el: HTMLElement;
|
||||||
|
/** Becomes the visible view: fit to the pane, focus, sync size.
|
||||||
|
* `focus: false` mounts without taking the keyboard — what a sidebar
|
||||||
|
* preview needs, so j/k keeps working while the pane is on screen. */
|
||||||
|
onMount(opts?: { focus?: boolean }): void;
|
||||||
|
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
||||||
|
onUnmount(): void;
|
||||||
|
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||||
|
focusTerminal(): void;
|
||||||
|
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
|
||||||
|
* handling — the leader's literal Ctrl-K escape hatch
|
||||||
|
* (docs/COMMAND-BAR.md). */
|
||||||
|
sendInput(data: string): void;
|
||||||
|
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AttachOptions {
|
export interface AttachOptions {
|
||||||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
||||||
@@ -43,6 +70,23 @@ export interface AttachOptions {
|
|||||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the header shows about the session (name / command). Optional — falls
|
||||||
|
* back to a short session id when the session isn't known yet. */
|
||||||
|
export interface AttachInfo {
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header echoes the sidebar: the program becomes its tool icon when we have a
|
||||||
|
* mark for it and stays as its bare name when we do not. Arguments are
|
||||||
|
* dropped, as on the card. */
|
||||||
|
function commandFragment(command: string): string {
|
||||||
|
const { tool, program } = splitCommand(command);
|
||||||
|
const icon = tool ? toolIconSvg(tool) : null;
|
||||||
|
const text = icon ? "" : program;
|
||||||
|
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createAttachConnection(
|
export function createAttachConnection(
|
||||||
sessionId: Uuid,
|
sessionId: Uuid,
|
||||||
opts: AttachOptions = {},
|
opts: AttachOptions = {},
|
||||||
@@ -52,19 +96,21 @@ export function createAttachConnection(
|
|||||||
el.className = "attach";
|
el.className = "attach";
|
||||||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
// Useful identity over a bare UUID: name · command. The full id stays in the
|
||||||
// tooltip for when you really need it.
|
// tooltip for when you really need it.
|
||||||
const titleHtml = paneTitleHtml(sessionId, info);
|
const titleHtml = info
|
||||||
const titleAttr = paneTitleAttr(sessionId, info);
|
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||||||
|
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||||||
|
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||||||
|
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<header class="attach-header">
|
<header class="attach-header">
|
||||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||||
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
|
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
||||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="attach-body"></div>
|
<div class="attach-body"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
|
|
||||||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
||||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||||
@@ -175,6 +221,15 @@ export function createAttachConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plain Escape hands the keyboard back to the session list instead of the
|
||||||
|
// shell/app. `false` tells xterm to skip its own handling of this event —
|
||||||
|
// which is also why the set doesn't `preventDefault`: swallowing here is
|
||||||
|
// xterm's return value, not the browser's.
|
||||||
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
|
if (e.type !== "keydown") return true;
|
||||||
|
return !paneKeys.handle(e);
|
||||||
|
});
|
||||||
|
|
||||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
// 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:
|
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||||
// • normal buffer → term.scrollLines walks the scrollback.
|
// • normal buffer → term.scrollLines walks the scrollback.
|
||||||
@@ -324,9 +379,6 @@ export function createAttachConnection(
|
|||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
let reconnectTimer: number | null = null;
|
let reconnectTimer: number | null = null;
|
||||||
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
|
|
||||||
// Follow it once per socket; later scrolling remains under the operator's control.
|
|
||||||
let awaitingReplay = true;
|
|
||||||
|
|
||||||
const sendControl = (msg: ClientControl) => {
|
const sendControl = (msg: ClientControl) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
@@ -346,7 +398,6 @@ export function createAttachConnection(
|
|||||||
};
|
};
|
||||||
|
|
||||||
function connect(): void {
|
function connect(): void {
|
||||||
awaitingReplay = true;
|
|
||||||
ws = new WebSocket(attachWsUrl(sessionId));
|
ws = new WebSocket(attachWsUrl(sessionId));
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
@@ -367,20 +418,10 @@ export function createAttachConnection(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
const replay = awaitingReplay;
|
term.write(new Uint8Array(ev.data));
|
||||||
awaitingReplay = false;
|
|
||||||
term.write(new Uint8Array(ev.data), () => {
|
|
||||||
if (replay) term.scrollToBottom();
|
|
||||||
});
|
|
||||||
} else if (ev.data instanceof Blob) {
|
} else if (ev.data instanceof Blob) {
|
||||||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
||||||
void ev.data.arrayBuffer().then((b) => {
|
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
|
||||||
const replay = awaitingReplay;
|
|
||||||
awaitingReplay = false;
|
|
||||||
term.write(new Uint8Array(b), () => {
|
|
||||||
if (replay) term.scrollToBottom();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -429,12 +470,6 @@ export function createAttachConnection(
|
|||||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||||
sendControl({ type: "Resize", 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);
|
window.addEventListener("resize", onResize);
|
||||||
const visual = window.visualViewport ?? null;
|
const visual = window.visualViewport ?? null;
|
||||||
@@ -478,10 +513,6 @@ export function createAttachConnection(
|
|||||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||||
// bar already take.
|
// bar already take.
|
||||||
sendInput,
|
sendInput,
|
||||||
updateInfo(next: AttachInfo) {
|
|
||||||
titleEl.innerHTML = paneTitleHtml(sessionId, next);
|
|
||||||
titleEl.title = paneTitleAttr(sessionId, next);
|
|
||||||
},
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
@@ -494,7 +525,6 @@ export function createAttachConnection(
|
|||||||
visual?.removeEventListener("resize", onResize);
|
visual?.removeEventListener("resize", onResize);
|
||||||
dataDisp.dispose();
|
dataDisp.dispose();
|
||||||
resizeDisp.dispose();
|
resizeDisp.dispose();
|
||||||
selectionDisp.dispose();
|
|
||||||
releaseWebgl();
|
releaseWebgl();
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
+7
-4
@@ -53,6 +53,10 @@ export function requestOpenCommandBar(): void {
|
|||||||
* connection pool. */
|
* connection pool. */
|
||||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||||
|
|
||||||
|
/** Ask the shell to hand the keyboard back to the session list — what Escape
|
||||||
|
* does when the pane's terminal is focused. Handled in main.ts. */
|
||||||
|
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
||||||
|
|
||||||
/** Ask the shell to open the spawn modal seeded from an existing session
|
/** Ask the shell to open the spawn modal seeded from an existing session
|
||||||
* ("Spawn similar"). Detail is the session being cloned. */
|
* ("Spawn similar"). Detail is the session being cloned. */
|
||||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||||
@@ -663,9 +667,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pointer hover previews only while Ctrl is held — plain hover was too
|
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
||||||
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
// out: there is no hover there, and a tap already opens the session.
|
||||||
// unconditionally via applyNav.
|
|
||||||
let hovered: string | null = null;
|
let hovered: string | null = null;
|
||||||
const setHovered = (id: string | null) => {
|
const setHovered = (id: string | null) => {
|
||||||
if (hovered === id) return;
|
if (hovered === id) return;
|
||||||
@@ -673,7 +676,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
opts.onPreview?.(id);
|
opts.onPreview?.(id);
|
||||||
};
|
};
|
||||||
treeHost.addEventListener("pointerover", (e) => {
|
treeHost.addEventListener("pointerover", (e) => {
|
||||||
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
if (e.pointerType === "touch") return;
|
||||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||||
setHovered(row?.dataset.sessionId ?? null);
|
setHovered(row?.dataset.sessionId ?? null);
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-12
@@ -133,28 +133,31 @@ describe("CommandSet", () => {
|
|||||||
expect(run).toHaveBeenCalledTimes(2);
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
|
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
|
||||||
const plain = vi.fn();
|
const plain = vi.fn();
|
||||||
const chorded = vi.fn();
|
const chorded = vi.fn();
|
||||||
const set = new CommandSet(
|
const set = new CommandSet(
|
||||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
|
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
||||||
{ leader: DEFAULT_LEADER },
|
|
||||||
);
|
);
|
||||||
const input = el("<input>");
|
const input = el("<input>");
|
||||||
expect(pressOn(set, input, "n")).toBe(false);
|
expect(pressOn(set, input, "n")).toBe(false);
|
||||||
expect(plain).not.toHaveBeenCalled();
|
expect(plain).not.toHaveBeenCalled();
|
||||||
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
|
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
|
||||||
// left for the pane's shell/app instead.
|
|
||||||
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
|
|
||||||
pressOn(set, terminal, "a", { ctrlKey: true });
|
|
||||||
expect(pressOn(set, terminal, "h")).toBe(true);
|
|
||||||
expect(chorded).toHaveBeenCalledTimes(1);
|
expect(chorded).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a plain Escape for the pane instead of binding it", () => {
|
it("keeps pane commands live inside the terminal's textarea", () => {
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
const run = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
||||||
|
preventDefault: false,
|
||||||
|
});
|
||||||
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
expect(pressOn(set, textarea, "Escape")).toBe(false);
|
const e = key("Escape");
|
||||||
|
Object.defineProperty(e, "target", { value: textarea });
|
||||||
|
expect(set.handle(e)).toBe(true);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
// xterm is told to swallow by the return value, not by the browser default.
|
||||||
|
expect(e.defaultPrevented).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +173,6 @@ describe("the leader", () => {
|
|||||||
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
||||||
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
||||||
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
||||||
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
|
|
||||||
// Commands that do not hang off the leader are untouched.
|
// Commands that do not hang off the leader are untouched.
|
||||||
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -93,8 +93,7 @@ const SPECS: readonly SpecSource[] = [
|
|||||||
id: "focus.toggle",
|
id: "focus.toggle",
|
||||||
label: "Move the keyboard between the session list and the pane",
|
label: "Move the keyboard between the session list and the pane",
|
||||||
group: "Session list",
|
group: "Session list",
|
||||||
keys: [`${DEFAULT_LEADER} h`],
|
keys: ["ctrl-shift-h"],
|
||||||
leaderKeys: (leader) => [`${leader} h`],
|
|
||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,6 +145,13 @@ const SPECS: readonly SpecSource[] = [
|
|||||||
keys: ["tab"],
|
keys: ["tab"],
|
||||||
scope: "list",
|
scope: "list",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "pane.focusList",
|
||||||
|
label: "Hand the keyboard back to the session list, from the pane",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["esc"],
|
||||||
|
scope: "pane",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "help.toggle",
|
id: "help.toggle",
|
||||||
label: "Show or hide this help",
|
label: "Show or hide this help",
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
// React port of multiview.ts's tab + split tree (issue #249). Preview mode
|
|
||||||
// (docs/PREVIEW.md) is issue #251; 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 { useEffect, useRef } from "react";
|
|
||||||
import { useStore } from "zustand";
|
|
||||||
|
|
||||||
import { activeTab, type Dir, type Node } from "../../layout.js";
|
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
|
||||||
import type { Session, Uuid } from "../../types.js";
|
|
||||||
import { PaneContainer } from "./PaneContainer.js";
|
|
||||||
import { PaneTitle } from "./PaneTitle.js";
|
|
||||||
import { TabBar } from "./TabBar.js";
|
|
||||||
|
|
||||||
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 function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
|
|
||||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
|
||||||
const tab = activeTab(layout);
|
|
||||||
|
|
||||||
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 min-h-0 flex-1">
|
|
||||||
{tab.root ? (
|
|
||||||
<SplitNode node={tab.root} tab={tab} store={store} sessions={sessions} onDisconnect={onDisconnect} />
|
|
||||||
) : (
|
|
||||||
(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>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeProps {
|
|
||||||
tab: { id: string; focusedLeafId: string | null };
|
|
||||||
store: LayoutStoreApi;
|
|
||||||
sessions?: ReadonlyMap<Uuid, Session>;
|
|
||||||
onDisconnect?: (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,
|
|
||||||
store,
|
|
||||||
sessions,
|
|
||||||
onDisconnect,
|
|
||||||
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
|
||||||
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
|
||||||
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 border " +
|
|
||||||
(isFocused ? "border-accent" : "border-border-strong")
|
|
||||||
}
|
|
||||||
data-leaf-id={leaf.id}
|
|
||||||
data-session-id={leaf.sessionId ?? undefined}
|
|
||||||
onPointerDownCapture={() => store.getState().focusLeaf(leaf.id)}
|
|
||||||
>
|
|
||||||
<div className="mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong bg-surface 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
|
|
||||||
sessionId={leaf.sessionId}
|
|
||||||
session={session}
|
|
||||||
focus={isFocused}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
// @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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// 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} />
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// @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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// 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 ?? "at-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 ?? "at-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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { createLayoutStore } from "../../stores/layoutStore.js";
|
|
||||||
import { TabBar } from "./TabBar.js";
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function store() {
|
|
||||||
return createLayoutStore({
|
|
||||||
load: () => null,
|
|
||||||
save: vi.fn(),
|
|
||||||
scheduler: { setTimer: () => 1, clearTimer: () => {} },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function dataTransfer() {
|
|
||||||
const data = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
setData: (type: string, value: string) => data.set(type, value),
|
|
||||||
getData: (type: string) => data.get(type) ?? "",
|
|
||||||
effectAllowed: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TabBar", () => {
|
|
||||||
it("renders one tab per layout tab, marking the active one", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
const tabs = screen.getAllByRole("tab");
|
|
||||||
expect(tabs).toHaveLength(2);
|
|
||||||
expect(tabs[1].getAttribute("aria-selected")).toBe("true");
|
|
||||||
expect(tabs[0].getAttribute("aria-selected")).toBe("false");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the open-session count next to a tab's title", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
expect(screen.getByText(/· 1/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clicking a tab selects it", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
const [first] = s.getState().layout.tabs;
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
fireEvent.click(screen.getAllByRole("tab")[0]);
|
|
||||||
expect(s.getState().layout.activeTabId).toBe(first.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the + button adds a tab", () => {
|
|
||||||
const s = store();
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
fireEvent.click(screen.getByTitle("New tab"));
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the close control on the last remaining tab", () => {
|
|
||||||
const s = store();
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
expect(screen.queryByTitle("Close tab")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closing a tab removes it", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
fireEvent.click(screen.getAllByTitle("Close tab")[0]);
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dragging a tab onto another reorders them", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
s.getState().addTab();
|
|
||||||
const [a, b, c] = s.getState().layout.tabs;
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
const tabs = screen.getAllByRole("tab");
|
|
||||||
const dt = dataTransfer();
|
|
||||||
fireEvent.dragStart(tabs[0], { dataTransfer: dt });
|
|
||||||
fireEvent.dragOver(tabs[2], { dataTransfer: dt });
|
|
||||||
fireEvent.drop(tabs[2], { dataTransfer: dt });
|
|
||||||
expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([b.id, c.id, a.id]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// The tab strip above the canvas (issue #249): click to switch, drag to
|
|
||||||
// reorder, a close ✕ per tab (hidden on the last one), a trailing + to add.
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useStore } from "zustand";
|
|
||||||
|
|
||||||
import { leaves } from "../../layout.js";
|
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
|
||||||
|
|
||||||
export interface TabBarProps {
|
|
||||||
store: LayoutStoreApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabBar({ store }: TabBarProps) {
|
|
||||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mv-tabs flex items-center gap-sp-1 overflow-x-auto border-b border-border-strong bg-surface px-sp-2 text-fs-12" role="tablist">
|
|
||||||
{layout.tabs.map((tab) => {
|
|
||||||
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
|
||||||
const isActive = tab.id === layout.activeTabId;
|
|
||||||
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
draggable
|
|
||||||
data-tab-id={tab.id}
|
|
||||||
className={
|
|
||||||
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
|
||||||
(isActive ? "bg-surface-2 text-ink" : "text-faint hover:text-ink") +
|
|
||||||
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
|
||||||
}
|
|
||||||
onDragStart={(e) => {
|
|
||||||
setDraggingId(tab.id);
|
|
||||||
e.dataTransfer.setData("text/plain", tab.id);
|
|
||||||
e.dataTransfer.effectAllowed = "move";
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (!draggingId || draggingId === tab.id) return;
|
|
||||||
e.preventDefault();
|
|
||||||
setOverId(tab.id);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setOverId((current) => (current === tab.id ? null : current))}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fromId = e.dataTransfer.getData("text/plain") || draggingId;
|
|
||||||
setDraggingId(null);
|
|
||||||
setOverId(null);
|
|
||||||
if (fromId && fromId !== tab.id) store.getState().reorderTab(fromId, tab.id);
|
|
||||||
}}
|
|
||||||
onDragEnd={() => {
|
|
||||||
setDraggingId(null);
|
|
||||||
setOverId(null);
|
|
||||||
}}
|
|
||||||
onClick={() => store.getState().selectTab(tab.id)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{tab.title}
|
|
||||||
{count ? ` · ${count}` : ""}
|
|
||||||
</span>
|
|
||||||
{layout.tabs.length > 1 && (
|
|
||||||
<span
|
|
||||||
className="mv-tab-close text-faint hover:text-ink"
|
|
||||||
title="Close tab"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
store.getState().closeTab(tab.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mv-tab-add px-sp-2 py-sp-1 text-muted hover:text-ink"
|
|
||||||
title="New tab"
|
|
||||||
onClick={() => store.getState().addTab()}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
|
|
||||||
// corners, full card height. It paints for every card, Idle included, and it
|
|
||||||
// carries the accessible name of the state so status is never colour-only.
|
|
||||||
|
|
||||||
import { activityColorSlug } from "../../activity.js";
|
|
||||||
import type { ActivityState } from "../../types.js";
|
|
||||||
|
|
||||||
export const STATE_WORDS: Record<string, string> = {
|
|
||||||
active: "Active",
|
|
||||||
idle: "Idle",
|
|
||||||
"awaiting-input": "Awaiting input",
|
|
||||||
error: "Error",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Rail fill per state, and the motion the state earns. */
|
|
||||||
const RAIL_CLASS: Record<string, string> = {
|
|
||||||
active: "bg-live",
|
|
||||||
idle: "bg-border-strong",
|
|
||||||
"awaiting-input": "bg-warn rail-blink",
|
|
||||||
error: "bg-danger rail-flash",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Duration text inherits the rail's colour (docs/SIDEBAR.md). */
|
|
||||||
export const DURATION_CLASS: Record<string, string> = {
|
|
||||||
active: "text-live",
|
|
||||||
idle: "text-muted",
|
|
||||||
"awaiting-input": "text-warn",
|
|
||||||
error: "text-danger",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function stateWordFor(activity: ActivityState | string | undefined): string {
|
|
||||||
return STATE_WORDS[activityColorSlug(activity ?? "Idle")] ?? STATE_WORDS.idle;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivityRail({ activity }: { activity: ActivityState | string | undefined }) {
|
|
||||||
const slug = activityColorSlug(activity ?? "Idle");
|
|
||||||
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
|
|
||||||
data-activity={slug}
|
|
||||||
role="img"
|
|
||||||
title={word}
|
|
||||||
aria-label={word}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
|
|
||||||
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
|
||||||
label: string;
|
|
||||||
danger?: boolean;
|
|
||||||
onSelect(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMenuProps {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
items: ContextMenuItem[];
|
|
||||||
onClose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
const onDown = (e: Event) => {
|
|
||||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
document.addEventListener("pointerdown", onDown, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
document.removeEventListener("pointerdown", onDown, true);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="menu"
|
|
||||||
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
|
|
||||||
style={{ left: x, top: y }}
|
|
||||||
>
|
|
||||||
{items.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.label}
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
|
|
||||||
item.danger ? "text-danger" : "text-ink"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
item.onSelect();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// A collapsible directory in the sidebar tree. Only a *collapsed* header is a
|
|
||||||
// keyboard-walk stop (docs/SIDEBAR.md); an expanded one is decoration in front
|
|
||||||
// of the cards it holds, so it carries no highlight of its own.
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { splitFolderLabel } from "./model.js";
|
|
||||||
|
|
||||||
export interface FolderNodeProps {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
depth: number;
|
|
||||||
collapsed: boolean;
|
|
||||||
highlighted: boolean;
|
|
||||||
onToggle(): void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FolderNode(props: FolderNodeProps) {
|
|
||||||
const { stem, last } = splitFolderLabel(props.name);
|
|
||||||
return (
|
|
||||||
<div className="tree-folder">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`tree-folder-header flex w-full items-center gap-sp-1 py-[2px] text-fs-11 text-muted ${
|
|
||||||
props.highlighted ? "ring-1 ring-accent-ring" : ""
|
|
||||||
}`}
|
|
||||||
style={{ paddingLeft: props.depth * 16 }}
|
|
||||||
aria-expanded={!props.collapsed}
|
|
||||||
data-nav-key={`folder:${props.path}`}
|
|
||||||
data-highlighted={props.highlighted ? "true" : undefined}
|
|
||||||
title={props.name}
|
|
||||||
onClick={props.onToggle}
|
|
||||||
>
|
|
||||||
{/* One glyph, rotated when collapsed, so the mark and the rotation
|
|
||||||
never disagree about which way the folder points. */}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`inline-block transition-transform ${props.collapsed ? "-rotate-90" : ""}`}
|
|
||||||
>
|
|
||||||
▾
|
|
||||||
</span>
|
|
||||||
<span className="truncate">
|
|
||||||
{stem && <span className="text-faint">{stem}/</span>}
|
|
||||||
{last}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{!props.collapsed && props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
|
|
||||||
// confirmation names what the card last saw in the way — the daemon refuses an
|
|
||||||
// unforced kill over such a tree, and saying so beforehand is cheaper than the
|
|
||||||
// refusal. Cancel holds the focus so a stray Enter kills nothing.
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
import type { InTheWay } from "../../types.js";
|
|
||||||
import { formatInTheWay } from "../../types.js";
|
|
||||||
|
|
||||||
export interface KillConfirmProps {
|
|
||||||
name: string;
|
|
||||||
blockers: InTheWay | null;
|
|
||||||
/** Set when the daemon already refused an unforced kill: this pass forces. */
|
|
||||||
force?: boolean;
|
|
||||||
onConfirm(): void;
|
|
||||||
onCancel(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KillConfirm(props: KillConfirmProps) {
|
|
||||||
const cancel = useRef<HTMLButtonElement>(null);
|
|
||||||
useEffect(() => cancel.current?.focus(), []);
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={props.force ? "Force kill session" : "Kill session"}
|
|
||||||
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") props.onCancel();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
|
|
||||||
<p className="text-fs-13 text-ink">
|
|
||||||
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
|
|
||||||
</p>
|
|
||||||
{props.blockers && (
|
|
||||||
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-sp-3 flex justify-end gap-sp-2">
|
|
||||||
<button
|
|
||||||
ref={cancel}
|
|
||||||
type="button"
|
|
||||||
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
|
|
||||||
onClick={props.onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
|
|
||||||
onClick={props.onConfirm}
|
|
||||||
>
|
|
||||||
{props.force ? "Force kill" : "Kill"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
// docs/SIDEBAR.md: a session card is always two lines — the name, then the
|
|
||||||
// adaptive meta line — with the activity rail in the gutter beside them.
|
|
||||||
|
|
||||||
import { useEffect, useRef, type MouseEvent } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
activityDurationLabel,
|
|
||||||
activityHeldMs,
|
|
||||||
META_LINE_MIN_HOLD_MS,
|
|
||||||
} from "../../activity.js";
|
|
||||||
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
|
|
||||||
import type { InTheWay, Session } from "../../types.js";
|
|
||||||
import {
|
|
||||||
blockerStats,
|
|
||||||
customSessionName,
|
|
||||||
formatInTheWay,
|
|
||||||
isProvisionalName,
|
|
||||||
MAX_SESSION_NAME_LEN,
|
|
||||||
normalizeSessionName,
|
|
||||||
sessionBlockers,
|
|
||||||
sessionName,
|
|
||||||
} from "../../types.js";
|
|
||||||
import { ActivityRail, DURATION_CLASS, stateWordFor } from "./ActivityRail.js";
|
|
||||||
import { activityColorSlug } from "../../activity.js";
|
|
||||||
|
|
||||||
export interface SessionCardProps {
|
|
||||||
session: Session;
|
|
||||||
/** Nesting depth; the row's padding (and so the rail) shifts outward with it. */
|
|
||||||
depth: number;
|
|
||||||
/** Clock the duration is measured against — the sidebar ticks it. */
|
|
||||||
now: number;
|
|
||||||
highlighted: boolean;
|
|
||||||
/** Showing in the focused pane. */
|
|
||||||
active: boolean;
|
|
||||||
/** Open in some pane, not the focused one. */
|
|
||||||
open: boolean;
|
|
||||||
previewing: boolean;
|
|
||||||
renaming: boolean;
|
|
||||||
onSelect(): void;
|
|
||||||
onOpenMenu(x: number, y: number): void;
|
|
||||||
onSpawnSimilar(): void;
|
|
||||||
onKill(): void;
|
|
||||||
onRenameCommit(name: string): void;
|
|
||||||
onRenameCancel(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
|
|
||||||
* wins. Written as one ordered run so the precedence is the order it reads in. */
|
|
||||||
function rowFill(p: SessionCardProps): string {
|
|
||||||
if (p.active) return "bg-accent-fill";
|
|
||||||
if (p.previewing) return "bg-preview-tint";
|
|
||||||
if (p.open) return "bg-accent/[0.06]";
|
|
||||||
if (p.highlighted) return "bg-preview-tint";
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionCard(props: SessionCardProps) {
|
|
||||||
const { session: s, depth, now, renaming } = props;
|
|
||||||
const name = sessionName(s);
|
|
||||||
const running = s.status === "Running";
|
|
||||||
const word = stateWordFor(s.activity);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
|
|
||||||
running ? "" : "opacity-60"
|
|
||||||
}`}
|
|
||||||
href={`#/attach/${encodeURIComponent(s.id)}`}
|
|
||||||
style={{ paddingLeft: depth * 16 + 8 }}
|
|
||||||
data-session-id={s.id}
|
|
||||||
data-nav-key={`session:${s.id}`}
|
|
||||||
data-status={running ? "running" : "dead"}
|
|
||||||
data-highlighted={props.highlighted ? "true" : undefined}
|
|
||||||
aria-label={`${name}, ${running ? "running" : "exited"}, ${word}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (renaming) return;
|
|
||||||
e.preventDefault();
|
|
||||||
props.onSelect();
|
|
||||||
}}
|
|
||||||
onContextMenu={(e: MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
props.onOpenMenu(e.clientX, e.clientY);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityRail activity={s.activity} />
|
|
||||||
<span className="flex min-w-0 items-center gap-sp-2">
|
|
||||||
{renaming ? (
|
|
||||||
<RenameField
|
|
||||||
session={s}
|
|
||||||
onCommit={props.onRenameCommit}
|
|
||||||
onCancel={props.onRenameCancel}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`min-w-0 flex-1 truncate text-fs-13 ${
|
|
||||||
isProvisionalName(s) ? "text-muted" : "text-ink"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex flex-none items-center gap-[2px]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="leaf-clone text-faint hover:text-ink"
|
|
||||||
title="Spawn similar"
|
|
||||||
aria-label={`Spawn similar to ${name}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
props.onSpawnSimilar();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⧉
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="leaf-kill text-faint hover:text-danger"
|
|
||||||
title="Kill session"
|
|
||||||
aria-label={`Kill ${name}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
props.onKill();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<MetaLine session={s} now={now} />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`.
|
|
||||||
* The line itself is unconditional; only its fragments are adaptive. */
|
|
||||||
export function MetaLine({ session: s, now }: { session: Session; now: number }) {
|
|
||||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
|
||||||
const held = activityHeldMs(s.activity_since, now);
|
|
||||||
const duration =
|
|
||||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
|
||||||
const blockers = sessionBlockers(s);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="card-line2 flex min-w-0 items-center gap-sp-2 text-fs-11"
|
|
||||||
data-activity={slug}
|
|
||||||
>
|
|
||||||
{duration && (
|
|
||||||
<span className={`activity-duration flex-none ${DURATION_CLASS[slug] ?? "text-muted"}`}>
|
|
||||||
{duration}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{s.activity_detail && (
|
|
||||||
<span className="activity-detail min-w-0 flex-1 truncate text-faint">
|
|
||||||
{s.activity_detail}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CommandFragment command={s.command} />
|
|
||||||
{blockers && <BlockedFragment blockers={blockers} />}
|
|
||||||
{s.status === "Dead" && <span className="flex-none text-faint">exited</span>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the card names the program only, as its tool mark when we
|
|
||||||
* have one. The full command line stays in the tooltip either way. */
|
|
||||||
function CommandFragment({ command }: { command: string }) {
|
|
||||||
const { tool, program } = splitCommand(command);
|
|
||||||
const icon = tool ? toolIconSvg(tool) : null;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="leaf-cmd flex min-w-0 flex-none items-center gap-[4px] truncate text-faint"
|
|
||||||
title={command}
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
<span className="contents" dangerouslySetInnerHTML={{ __html: icon }} />
|
|
||||||
) : (
|
|
||||||
<span className="truncate">{program}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Blocked work in diff notation — `+124 -18 ?3 ↑2`. Only `+` and `-` take
|
|
||||||
* colour; the spelled-out breakdown survives as the tooltip. */
|
|
||||||
function BlockedFragment({ blockers }: { blockers: InTheWay }) {
|
|
||||||
const stats = blockerStats(blockers);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="leaf-blocked flex flex-none items-center gap-[4px] text-faint"
|
|
||||||
title={`Kill will be refused unless forced — ${formatInTheWay(blockers)}`}
|
|
||||||
>
|
|
||||||
{stats.map((st) => (
|
|
||||||
<span
|
|
||||||
key={st.kind}
|
|
||||||
data-stat={st.kind}
|
|
||||||
className={
|
|
||||||
st.kind === "add" ? "text-live" : st.kind === "del" ? "text-danger" : "text-muted"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{st.text}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inline rename (#125): Enter commits, Escape and blur cancel, an emptied
|
|
||||||
* field clears the name so the command fallback resumes. It starts from the
|
|
||||||
* session's own name, never from the command it falls back to. */
|
|
||||||
function RenameField({
|
|
||||||
session: s,
|
|
||||||
onCommit,
|
|
||||||
onCancel,
|
|
||||||
}: {
|
|
||||||
session: Session;
|
|
||||||
onCommit(name: string): void;
|
|
||||||
onCancel(): void;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
|
||||||
const settled = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
ref.current?.focus();
|
|
||||||
ref.current?.select();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
type="text"
|
|
||||||
className="leaf-rename min-w-0 flex-1 bg-surface-2 text-fs-13 text-ink"
|
|
||||||
defaultValue={customSessionName(s) ?? ""}
|
|
||||||
maxLength={MAX_SESSION_NAME_LEN}
|
|
||||||
aria-label={`Rename ${sessionName(s)}`}
|
|
||||||
// The card is an <a> the tree also drags from: without these, clicking
|
|
||||||
// into the field navigates away or starts a drag.
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (settled.current) return;
|
|
||||||
settled.current = true;
|
|
||||||
onCommit(normalizeSessionName(e.currentTarget.value));
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (settled.current) return;
|
|
||||||
settled.current = true;
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
if (settled.current) return;
|
|
||||||
settled.current = true;
|
|
||||||
onCancel();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { Session } from "../../types.js";
|
|
||||||
import { Sidebar } from "./Sidebar.js";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
function session(id: string, over: Partial<Session> = {}): Session {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
cwd: "/home/n/alpha",
|
|
||||||
command: "hx src/",
|
|
||||||
env: {},
|
|
||||||
metadata: { name: id },
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-08-24T10:00:00Z",
|
|
||||||
...over,
|
|
||||||
} as Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The card element for a session, by the `data-session-id` the row carries. */
|
|
||||||
function card(id: string): HTMLElement {
|
|
||||||
const el = document.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
|
||||||
if (!el) throw new Error(`no card for ${id}`);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tree(): HTMLElement {
|
|
||||||
return document.querySelector<HTMLElement>(".tree")!;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SessionCard layout", () => {
|
|
||||||
it("renders two lines: the name, then a meta line naming the program", () => {
|
|
||||||
render(<Sidebar sessions={[session("alpha")]} />);
|
|
||||||
|
|
||||||
const row = card("alpha");
|
|
||||||
expect(row.textContent).toContain("alpha");
|
|
||||||
const meta = row.querySelector(".card-line2")!;
|
|
||||||
// The program only — never its arguments.
|
|
||||||
expect(meta.textContent).toContain("hx");
|
|
||||||
expect(meta.textContent).not.toContain("src/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows no duration under the 30s hold, and one above it", () => {
|
|
||||||
const now = Date.parse("2026-08-24T10:00:00Z");
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
|
|
||||||
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
|
|
||||||
|
|
||||||
render(<Sidebar sessions={[fresh, held]} />);
|
|
||||||
|
|
||||||
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
|
|
||||||
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
|
|
||||||
render(
|
|
||||||
<Sidebar
|
|
||||||
sessions={[
|
|
||||||
session("idle"),
|
|
||||||
session("await", { activity: "AwaitingInput" }),
|
|
||||||
session("err", { activity: "Error" }),
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
|
|
||||||
expect(rail("idle").dataset.activity).toBe("idle");
|
|
||||||
expect(rail("idle").className).not.toContain("rail-");
|
|
||||||
expect(rail("await").className).toContain("rail-blink");
|
|
||||||
expect(rail("err").className).toContain("rail-flash");
|
|
||||||
// Status never rests on colour alone.
|
|
||||||
expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
|
|
||||||
render(
|
|
||||||
<Sidebar
|
|
||||||
sessions={[
|
|
||||||
session("alpha", {
|
|
||||||
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
|
|
||||||
expect(blocked.textContent).toBe("+124-18?3");
|
|
||||||
expect(blocked.title).toContain("2 uncommitted files");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dims a provisional name and leaves a settled one alone", () => {
|
|
||||||
render(
|
|
||||||
<Sidebar
|
|
||||||
sessions={[
|
|
||||||
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
|
|
||||||
session("user", { metadata: { name: "user", name_source: "User" } }),
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
|
|
||||||
expect(card("user").textContent).toContain("user");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("keyboard walk", () => {
|
|
||||||
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
|
|
||||||
|
|
||||||
it("j and k step the highlight through the cards", () => {
|
|
||||||
render(<Sidebar sessions={two} />);
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
expect(card("a").dataset.highlighted).toBe("true");
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
expect(card("b").dataset.highlighted).toBe("true");
|
|
||||||
fireEvent.keyDown(tree(), { key: "k" });
|
|
||||||
expect(card("a").dataset.highlighted).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
|
|
||||||
render(<Sidebar sessions={two} />);
|
|
||||||
|
|
||||||
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
|
|
||||||
fireEvent.click(header);
|
|
||||||
expect(header.getAttribute("aria-expanded")).toBe("false");
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
expect(
|
|
||||||
document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!.dataset.highlighted,
|
|
||||||
).toBe("true");
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
|
||||||
expect(card("a").dataset.highlighted).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
const onFocusPane = vi.fn();
|
|
||||||
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
|
||||||
expect(onSelect).toHaveBeenCalledWith("a");
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "Tab" });
|
|
||||||
expect(onFocusPane).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("previews the session the walk lands on", () => {
|
|
||||||
const onPreview = vi.fn();
|
|
||||||
render(<Sidebar sessions={two} onPreview={onPreview} />);
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
expect(onPreview).toHaveBeenCalledWith("a");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("x asks to kill the highlighted session and r renames it", () => {
|
|
||||||
const onKill = vi.fn();
|
|
||||||
render(<Sidebar sessions={two} onKill={onKill} />);
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
fireEvent.keyDown(tree(), { key: "x" });
|
|
||||||
expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "r" });
|
|
||||||
expect(card("a").querySelector(".leaf-rename")).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("context menu", () => {
|
|
||||||
it("offers rename, spawn similar and kill", () => {
|
|
||||||
const onSpawnSimilar = vi.fn();
|
|
||||||
const onKill = vi.fn();
|
|
||||||
render(
|
|
||||||
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.contextMenu(card("alpha"));
|
|
||||||
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent);
|
|
||||||
expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Spawn similar"));
|
|
||||||
expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renames in place: Enter commits the field, Escape cancels", () => {
|
|
||||||
const onRename = vi.fn();
|
|
||||||
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
|
|
||||||
|
|
||||||
fireEvent.contextMenu(card("alpha"));
|
|
||||||
fireEvent.click(screen.getByText("Rename…"));
|
|
||||||
|
|
||||||
const input = card("alpha").querySelector<HTMLInputElement>(".leaf-rename")!;
|
|
||||||
expect(input.value).toBe("alpha");
|
|
||||||
fireEvent.change(input, { target: { value: " renamed " } });
|
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
|
||||||
expect(onRename).toHaveBeenCalledWith("alpha", "renamed");
|
|
||||||
expect(card("alpha").querySelector(".leaf-rename")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("search", () => {
|
|
||||||
it("filters the tree down to matching sessions", () => {
|
|
||||||
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
|
|
||||||
|
|
||||||
expect(document.querySelector('[data-session-id="alpha"]')).toBeNull();
|
|
||||||
expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty state", () => {
|
|
||||||
it("offers a spawn when there are no sessions at all", () => {
|
|
||||||
const onSpawn = vi.fn();
|
|
||||||
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
|
||||||
expect(onSpawn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
// React port of the sidebar tree (catalog.ts). The contract it renders is
|
|
||||||
// docs/SIDEBAR.md: two-line cards behind an activity rail, folder-grouped by
|
|
||||||
// repository, walked with j/k.
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useStore } from "zustand";
|
|
||||||
|
|
||||||
import { ApiRequestError, killSession, renameSession } from "../../api.js";
|
|
||||||
import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js";
|
|
||||||
import type { InTheWay, Session } from "../../types.js";
|
|
||||||
import { sessionBlockers, sessionName } from "../../types.js";
|
|
||||||
import { buildTree, type TreeNode } from "../../tree.js";
|
|
||||||
import { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
|
|
||||||
import { FolderNode } from "./FolderNode.js";
|
|
||||||
import { KillConfirm } from "./KillConfirm.js";
|
|
||||||
import { SessionCard } from "./SessionCard.js";
|
|
||||||
import { SidebarSearch } from "./SidebarSearch.js";
|
|
||||||
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
|
|
||||||
import { useNow } from "./useNow.js";
|
|
||||||
import "./sidebar.css";
|
|
||||||
|
|
||||||
export interface SidebarProps {
|
|
||||||
/** Live catalog. Either this or `sessions` supplies the tree. */
|
|
||||||
store?: CatalogStoreApi;
|
|
||||||
/** Static session list — what tests and the design kit render from. */
|
|
||||||
sessions?: Session[];
|
|
||||||
/** The daemon's home dir, so cwds abbreviate to `~/…`. */
|
|
||||||
homeDir?: string;
|
|
||||||
/** Showing in the focused pane. */
|
|
||||||
activeId?: string | null;
|
|
||||||
/** Open in some pane, focused one included. */
|
|
||||||
openIds?: ReadonlySet<string>;
|
|
||||||
/** Previewing (docs/PREVIEW.md) — a view, never durable state. */
|
|
||||||
previewId?: string | null;
|
|
||||||
onSelect?(id: string): void;
|
|
||||||
/** The highlight moved onto a session, or off every session. */
|
|
||||||
onPreview?(id: string | null): void;
|
|
||||||
onSpawnSimilar?(session: Session): void;
|
|
||||||
onSpawn?(): void;
|
|
||||||
/** Fast-forward spawn (`N`). */
|
|
||||||
onSpawnDefaults?(): void;
|
|
||||||
/** Tab: hand the keyboard back to the pane. */
|
|
||||||
onFocusPane?(): void;
|
|
||||||
onHelp?(): void;
|
|
||||||
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
|
|
||||||
onKill?(session: Session): void;
|
|
||||||
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
|
|
||||||
onRename?(id: string, name: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuState {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
session: Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KillState {
|
|
||||||
session: Session;
|
|
||||||
blockers: InTheWay | null;
|
|
||||||
force: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar(props: SidebarProps) {
|
|
||||||
const sessions = useSessions(props);
|
|
||||||
const now = useNow();
|
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());
|
|
||||||
const [navKey, setNavKey] = useState<string | null>(null);
|
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
||||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
|
||||||
const [kill, setKill] = useState<KillState | null>(null);
|
|
||||||
const treeRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const tree = useMemo(
|
|
||||||
() => buildTree(filterSessions(sessions, query), props.homeDir),
|
|
||||||
[sessions, query, props.homeDir],
|
|
||||||
);
|
|
||||||
const rows = useMemo(() => navRows(tree, collapsed), [tree, collapsed]);
|
|
||||||
|
|
||||||
// A row that went away — session killed, folder collapsed over it — drops
|
|
||||||
// the highlight rather than leaving it pointing at nothing.
|
|
||||||
useEffect(() => {
|
|
||||||
if (navKey !== null && !rows.some((r) => r.key === navKey)) setNavKey(null);
|
|
||||||
}, [rows, navKey]);
|
|
||||||
|
|
||||||
const preview = props.onPreview;
|
|
||||||
const previewOf = useCallback(
|
|
||||||
(key: string | null) => {
|
|
||||||
preview?.(key?.startsWith("session:") ? key.slice("session:".length) : null);
|
|
||||||
},
|
|
||||||
[preview],
|
|
||||||
);
|
|
||||||
|
|
||||||
const moveNav = useCallback(
|
|
||||||
(delta: number) => {
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
const cur = rows.findIndex((r) => r.key === navKey);
|
|
||||||
// Nothing highlighted: start one off the end being walked from, so the
|
|
||||||
// first step lands on the first (or last) row.
|
|
||||||
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
|
|
||||||
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
|
|
||||||
setNavKey(next.key);
|
|
||||||
previewOf(next.key);
|
|
||||||
},
|
|
||||||
[rows, navKey, previewOf],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleFolder = useCallback((path: string) => {
|
|
||||||
setCollapsed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(path)) next.delete(path);
|
|
||||||
else next.add(path);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/** Enter: open the highlighted session, or expand the highlighted folder and
|
|
||||||
* land on the first row it was hiding. */
|
|
||||||
const activateNav = useCallback(() => {
|
|
||||||
const row = rows.find((r) => r.key === navKey);
|
|
||||||
if (!row) return;
|
|
||||||
if (row.kind === "session") {
|
|
||||||
props.onSelect?.(row.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const expanded = new Set(collapsed);
|
|
||||||
expanded.delete(row.path);
|
|
||||||
setCollapsed(expanded);
|
|
||||||
const revealed = firstRowUnder(tree, expanded, row.path);
|
|
||||||
setNavKey(revealed?.key ?? null);
|
|
||||||
}, [rows, navKey, collapsed, tree, props]);
|
|
||||||
|
|
||||||
const beginKill = useCallback(
|
|
||||||
(s: Session) => {
|
|
||||||
if (props.onKill) {
|
|
||||||
props.onKill(s);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setKill({ session: s, blockers: sessionBlockers(s), force: false });
|
|
||||||
},
|
|
||||||
[props],
|
|
||||||
);
|
|
||||||
|
|
||||||
const commitRename = useCallback(
|
|
||||||
(id: string, name: string) => {
|
|
||||||
setRenamingId(null);
|
|
||||||
if (props.onRename) props.onRename(id, name);
|
|
||||||
else void renameSession(id, name).catch(() => {});
|
|
||||||
},
|
|
||||||
[props],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** The session behind the highlighted row, when the highlight is on a card. */
|
|
||||||
const highlightedSession = useCallback((): Session | undefined => {
|
|
||||||
const row = rows.find((r) => r.key === navKey);
|
|
||||||
if (row?.kind !== "session") return undefined;
|
|
||||||
return sessions.find((s) => s.id === row.id);
|
|
||||||
}, [rows, navKey, sessions]);
|
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
// The inline rename field owns its own keys; the walk stays out of it.
|
|
||||||
if (e.target instanceof HTMLInputElement) return;
|
|
||||||
switch (e.key) {
|
|
||||||
case "j":
|
|
||||||
case "ArrowDown":
|
|
||||||
e.preventDefault();
|
|
||||||
moveNav(1);
|
|
||||||
return;
|
|
||||||
case "k":
|
|
||||||
case "ArrowUp":
|
|
||||||
e.preventDefault();
|
|
||||||
moveNav(-1);
|
|
||||||
return;
|
|
||||||
case "Enter":
|
|
||||||
e.preventDefault();
|
|
||||||
activateNav();
|
|
||||||
return;
|
|
||||||
case "Tab":
|
|
||||||
e.preventDefault();
|
|
||||||
props.onFocusPane?.();
|
|
||||||
return;
|
|
||||||
case "c": {
|
|
||||||
const row = rows.find((r) => r.key === navKey);
|
|
||||||
if (row?.kind !== "folder") return;
|
|
||||||
e.preventDefault();
|
|
||||||
toggleFolder(row.path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "r": {
|
|
||||||
const s = highlightedSession();
|
|
||||||
if (!s) return;
|
|
||||||
e.preventDefault();
|
|
||||||
setRenamingId(s.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "x": {
|
|
||||||
const s = highlightedSession();
|
|
||||||
if (!s) return;
|
|
||||||
e.preventDefault();
|
|
||||||
beginKill(s);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "n":
|
|
||||||
e.preventDefault();
|
|
||||||
props.onSpawn?.();
|
|
||||||
return;
|
|
||||||
case "N":
|
|
||||||
e.preventDefault();
|
|
||||||
props.onSpawnDefaults?.();
|
|
||||||
return;
|
|
||||||
case "?":
|
|
||||||
e.preventDefault();
|
|
||||||
props.onHelp?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItems = (s: Session): ContextMenuItem[] => [
|
|
||||||
{ label: "Rename…", onSelect: () => setRenamingId(s.id) },
|
|
||||||
{ label: "Spawn similar", onSelect: () => props.onSpawnSimilar?.(s) },
|
|
||||||
{ label: "Kill session", danger: true, onSelect: () => beginKill(s) },
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderNode = (node: TreeNode, depth: number): React.ReactNode => {
|
|
||||||
if (node.kind === "leaf") {
|
|
||||||
const s = node.session;
|
|
||||||
return (
|
|
||||||
<SessionCard
|
|
||||||
key={s.id}
|
|
||||||
session={s}
|
|
||||||
depth={depth}
|
|
||||||
now={now}
|
|
||||||
highlighted={navKey === sessionKey(s.id)}
|
|
||||||
active={props.activeId === s.id}
|
|
||||||
open={(props.openIds?.has(s.id) ?? false) && props.activeId !== s.id}
|
|
||||||
previewing={props.previewId === s.id && props.activeId !== s.id}
|
|
||||||
renaming={renamingId === s.id}
|
|
||||||
onSelect={() => props.onSelect?.(s.id)}
|
|
||||||
onOpenMenu={(x, y) => setMenu({ x, y, session: s })}
|
|
||||||
onSpawnSimilar={() => props.onSpawnSimilar?.(s)}
|
|
||||||
onKill={() => beginKill(s)}
|
|
||||||
onRenameCommit={(name) => commitRename(s.id, name)}
|
|
||||||
onRenameCancel={() => setRenamingId(null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!node.name) {
|
|
||||||
return node.children.map((c) => renderNode(c, depth));
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<FolderNode
|
|
||||||
key={node.path}
|
|
||||||
name={node.name}
|
|
||||||
path={node.path}
|
|
||||||
depth={depth}
|
|
||||||
collapsed={collapsed.has(node.path)}
|
|
||||||
highlighted={navKey === `folder:${node.path}`}
|
|
||||||
onToggle={() => toggleFolder(node.path)}
|
|
||||||
>
|
|
||||||
{node.children.map((c) => renderNode(c, depth + 1))}
|
|
||||||
</FolderNode>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="sidebar flex h-full min-h-0 flex-col bg-surface">
|
|
||||||
<SidebarSearch
|
|
||||||
value={query}
|
|
||||||
onChange={setQuery}
|
|
||||||
onDismiss={() => treeRef.current?.focus()}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={treeRef}
|
|
||||||
className="tree min-h-0 flex-1 overflow-y-auto outline-none"
|
|
||||||
tabIndex={-1}
|
|
||||||
role="tree"
|
|
||||||
aria-label="Sessions"
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
// The keyboard walk's preview belongs to the list holding the
|
|
||||||
// keyboard: once it doesn't, the pane goes back to what was opened.
|
|
||||||
onBlur={() => preview?.(null)}
|
|
||||||
>
|
|
||||||
{sessions.length === 0 ? (
|
|
||||||
<EmptyState onSpawn={() => props.onSpawn?.()} />
|
|
||||||
) : (
|
|
||||||
renderNode(tree, 0)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{menu && (
|
|
||||||
<ContextMenu
|
|
||||||
x={menu.x}
|
|
||||||
y={menu.y}
|
|
||||||
items={menuItems(menu.session)}
|
|
||||||
onClose={() => setMenu(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{kill && (
|
|
||||||
<KillConfirm
|
|
||||||
name={sessionName(kill.session)}
|
|
||||||
blockers={kill.blockers}
|
|
||||||
force={kill.force}
|
|
||||||
onCancel={() => setKill(null)}
|
|
||||||
onConfirm={() => {
|
|
||||||
const { session: s, force } = kill;
|
|
||||||
setKill(null);
|
|
||||||
void killSession(s.id, force).catch((e: unknown) => {
|
|
||||||
// The daemon refuses an unforced kill over a worktree holding
|
|
||||||
// unsaved work; that refusal needs its own prompt
|
|
||||||
// (docs/WORKTREES.md).
|
|
||||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
|
||||||
setKill({
|
|
||||||
session: s,
|
|
||||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tree's one empty state: no sessions at all. */
|
|
||||||
function EmptyState({ onSpawn }: { onSpawn(): void }) {
|
|
||||||
return (
|
|
||||||
<div className="empty flex flex-col items-center gap-sp-2 p-sp-6 text-center">
|
|
||||||
<div aria-hidden="true" className="text-fs-20 text-faint">
|
|
||||||
+
|
|
||||||
</div>
|
|
||||||
<div className="text-fs-13 text-ink">No sessions yet</div>
|
|
||||||
<p className="text-fs-12 text-muted">Start your first session — it'll show up here.</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-r-1 bg-accent px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
|
|
||||||
onClick={onSpawn}
|
|
||||||
>
|
|
||||||
+ Spawn session
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sessions from the store when one is wired, else the static prop. */
|
|
||||||
function useSessions(props: SidebarProps): Session[] {
|
|
||||||
const fromStore = useStore(
|
|
||||||
// `useStore` needs a store every render; the no-store case is served by a
|
|
||||||
// stable empty stand-in whose result is thrown away below.
|
|
||||||
props.store ?? EMPTY_STORE,
|
|
||||||
(s: CatalogStore) => s.sessions,
|
|
||||||
);
|
|
||||||
return useMemo(
|
|
||||||
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
|
|
||||||
[props.store, props.sessions, fromStore],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One frozen state object, not a fresh one per call: `useStore` reads the
|
|
||||||
// snapshot on every render and a new Map each time would never settle.
|
|
||||||
const EMPTY_STATE = { sessions: new Map<string, Session>() };
|
|
||||||
const EMPTY_STORE = {
|
|
||||||
getState: () => EMPTY_STATE,
|
|
||||||
getInitialState: () => EMPTY_STATE,
|
|
||||||
setState: () => {},
|
|
||||||
subscribe: () => () => {},
|
|
||||||
} as unknown as CatalogStoreApi;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// The sidebar's search field: a substring filter over the tree (name, command
|
|
||||||
// and cwd). Escape clears it and hands the keyboard back to the list.
|
|
||||||
|
|
||||||
export interface SidebarSearchProps {
|
|
||||||
value: string;
|
|
||||||
onChange(value: string): void;
|
|
||||||
/** Escape on an already-empty field — the list wants the keyboard back. */
|
|
||||||
onDismiss?(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarSearch({ value, onChange, onDismiss }: SidebarSearchProps) {
|
|
||||||
return (
|
|
||||||
<header className="sidebar-search p-sp-2">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
className="search w-full rounded-r-1 border border-border bg-surface-2 px-sp-2 py-sp-1 text-fs-12 text-ink"
|
|
||||||
placeholder="Search sessions…"
|
|
||||||
aria-label="Search sessions"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="off"
|
|
||||||
spellCheck={false}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "Escape") return;
|
|
||||||
e.preventDefault();
|
|
||||||
if (value) onChange("");
|
|
||||||
else onDismiss?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { Sidebar, type SidebarProps } from "./Sidebar.js";
|
|
||||||
export { SessionCard, type SessionCardProps } from "./SessionCard.js";
|
|
||||||
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
|
|
||||||
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
|
|
||||||
export { ActivityRail } from "./ActivityRail.js";
|
|
||||||
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
|
|
||||||
export { KillConfirm } from "./KillConfirm.js";
|
|
||||||
export * from "./model.js";
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { buildTree } from "../../tree.js";
|
|
||||||
import type { Session } from "../../types.js";
|
|
||||||
import { filterSessions, firstRowUnder, navRows, splitFolderLabel } from "./model.js";
|
|
||||||
|
|
||||||
function session(id: string, cwd: string, command = "claude"): Session {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
cwd,
|
|
||||||
command,
|
|
||||||
env: {},
|
|
||||||
metadata: {},
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-08-24T10:00:00Z",
|
|
||||||
} as Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("navRows", () => {
|
|
||||||
const tree = buildTree([session("a", "/one"), session("b", "/two")], undefined);
|
|
||||||
|
|
||||||
it("stops on every card and skips expanded folder headers", () => {
|
|
||||||
expect(navRows(tree, new Set()).map((r) => r.key)).toEqual(["session:a", "session:b"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops on a collapsed folder header instead of the cards it hides", () => {
|
|
||||||
expect(navRows(tree, new Set(["one"])).map((r) => r.key)).toEqual([
|
|
||||||
"folder:one",
|
|
||||||
"session:b",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the first row an expanded folder reveals", () => {
|
|
||||||
expect(firstRowUnder(tree, new Set(), "one")?.key).toBe("session:a");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("splitFolderLabel", () => {
|
|
||||||
it("keeps a shallow path whole", () => {
|
|
||||||
expect(splitFolderLabel("~/maestro")).toEqual({ stem: null, last: "~/maestro" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initials the leading segments of a deep path", () => {
|
|
||||||
expect(splitFolderLabel("~/git.naps.pt/naps62/maestro")).toEqual({
|
|
||||||
stem: "~/g/n",
|
|
||||||
last: "maestro",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("filterSessions", () => {
|
|
||||||
const all = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
|
|
||||||
|
|
||||||
it("returns everything for a blank query", () => {
|
|
||||||
expect(filterSessions(all, " ")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the cwd and the command, case-insensitively", () => {
|
|
||||||
expect(filterSessions(all, "ALPHA").map((s) => s.id)).toEqual(["a"]);
|
|
||||||
expect(filterSessions(all, "bash").map((s) => s.id)).toEqual(["b"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
// Pure model helpers behind the React sidebar. Kept out of the components so
|
|
||||||
// the walk order and the folder label can be tested without a DOM.
|
|
||||||
|
|
||||||
import type { Session } from "../../types.js";
|
|
||||||
import { sessionCwd, sessionName } from "../../types.js";
|
|
||||||
import type { TreeNode } from "../../tree.js";
|
|
||||||
|
|
||||||
/** A row the keyboard walk can land on (docs/SIDEBAR.md "Keyboard walk"):
|
|
||||||
* every session card, and a folder header only while it is collapsed. */
|
|
||||||
export type NavRow =
|
|
||||||
| { key: string; kind: "folder"; path: string }
|
|
||||||
| { key: string; kind: "session"; id: string };
|
|
||||||
|
|
||||||
export function folderKey(path: string): string {
|
|
||||||
return `folder:${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sessionKey(id: string): string {
|
|
||||||
return `session:${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The walkable rows in visual order. An expanded folder contributes nothing
|
|
||||||
* itself — its cards are one row away — while a collapsed one is the only row
|
|
||||||
* it has, so skipping it would leave the folder unreachable. */
|
|
||||||
export function navRows(root: TreeNode, collapsed: ReadonlySet<string>): NavRow[] {
|
|
||||||
const out: NavRow[] = [];
|
|
||||||
const walk = (node: TreeNode): void => {
|
|
||||||
if (node.kind === "leaf") {
|
|
||||||
out.push({ key: sessionKey(node.session.id), kind: "session", id: node.session.id });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (node.name && collapsed.has(node.path)) {
|
|
||||||
out.push({ key: folderKey(node.path), kind: "folder", path: node.path });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const child of node.children) walk(child);
|
|
||||||
};
|
|
||||||
for (const child of root.kind === "folder" ? root.children : [root]) walk(child);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The first walkable row inside `path`, i.e. where the highlight lands when
|
|
||||||
* Enter expands that folder. */
|
|
||||||
export function firstRowUnder(
|
|
||||||
root: TreeNode,
|
|
||||||
collapsed: ReadonlySet<string>,
|
|
||||||
path: string,
|
|
||||||
): NavRow | undefined {
|
|
||||||
const found = findFolder(root, path);
|
|
||||||
if (!found) return undefined;
|
|
||||||
return navRows({ ...found, name: "" }, collapsed)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFolder(node: TreeNode, path: string): Extract<TreeNode, { kind: "folder" }> | null {
|
|
||||||
if (node.kind === "leaf") return null;
|
|
||||||
if (node.path === path && node.name) return node;
|
|
||||||
for (const child of node.children) {
|
|
||||||
const hit = findFolder(child, path);
|
|
||||||
if (hit) return hit;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Structured form of `abbreviateFolderName`: a deep path shows its leading
|
|
||||||
* segments as initials so the tail stays readable in a narrow sidebar. */
|
|
||||||
export function splitFolderLabel(name: string): { stem: string | null; last: string } {
|
|
||||||
const parts = name.split("/");
|
|
||||||
if (parts.length <= 2) return { stem: null, last: name };
|
|
||||||
const last = parts[parts.length - 1];
|
|
||||||
const stem = parts
|
|
||||||
.slice(0, -1)
|
|
||||||
.map((p) => (p === "~" ? "~" : (Array.from(p)[0] ?? "")))
|
|
||||||
.join("/");
|
|
||||||
return { stem, last };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Case-insensitive substring filter over a session's name, command and cwd —
|
|
||||||
* what the sidebar's own search field narrows the tree with. */
|
|
||||||
export function filterSessions(sessions: Session[], query: string): Session[] {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return sessions;
|
|
||||||
return sessions.filter((s) => {
|
|
||||||
const cwd = sessionCwd(s) ?? "";
|
|
||||||
return (
|
|
||||||
sessionName(s).toLowerCase().includes(q) ||
|
|
||||||
s.command.toLowerCase().includes(q) ||
|
|
||||||
cwd.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/* Motion for the activity rail (docs/SIDEBAR.md): a hard blink while
|
|
||||||
AwaitingInput, a fast flash while Error. Active and Idle are solid and
|
|
||||||
still — colour alone is the signal. Kept as real CSS rather than Tailwind
|
|
||||||
utilities because both need @keyframes of their own. */
|
|
||||||
|
|
||||||
@keyframes maestro-rail-blink {
|
|
||||||
0%,
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50.01%,
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes maestro-rail-flash {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
15% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
45% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-blink {
|
|
||||||
animation: maestro-rail-blink 1.2s steps(1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-flash {
|
|
||||||
animation: maestro-rail-flash 1.4s ease-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.rail-blink,
|
|
||||||
.rail-flash {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
/** A clock that ticks at least every `intervalMs` while the page is visible.
|
|
||||||
* docs/SIDEBAR.md asks for a re-render at least every 30s so the duration's
|
|
||||||
* cutover points land within one tick. */
|
|
||||||
export function useNow(intervalMs = 30_000): number {
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
setNow(Date.now());
|
|
||||||
}, intervalMs);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [intervalMs]);
|
|
||||||
return now;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { BranchInfo } from "../../types.js";
|
|
||||||
import { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function props(over: Partial<BranchPickerProps> = {}): BranchPickerProps {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
onToggleEnabled: vi.fn(),
|
|
||||||
hint: null,
|
|
||||||
baseRepo: "/repo",
|
|
||||||
mode: "new",
|
|
||||||
onModeChange: vi.fn(),
|
|
||||||
newBranch: "",
|
|
||||||
onNewBranchChange: vi.fn(),
|
|
||||||
branches: undefined,
|
|
||||||
existingBranch: "",
|
|
||||||
onExistingBranchChange: vi.fn(),
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("BranchPicker", () => {
|
|
||||||
it("shows the hint instead of the body while unresolved", () => {
|
|
||||||
render(<BranchPicker {...props({ hint: "Choose a working directory first." })} />);
|
|
||||||
expect(screen.getByText("Choose a working directory first.")).not.toBeNull();
|
|
||||||
expect(screen.queryByText("New branch name")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the new-branch field by default and the repo line once resolved", () => {
|
|
||||||
render(<BranchPicker {...props()} />);
|
|
||||||
expect(screen.getByText("repo: /repo")).not.toBeNull();
|
|
||||||
expect(screen.getByPlaceholderText("feat/foo")).not.toBeNull();
|
|
||||||
expect(screen.queryByText("Pick a branch")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to the existing-branch select and reports the mode change", () => {
|
|
||||||
const onModeChange = vi.fn();
|
|
||||||
render(<BranchPicker {...props({ onModeChange })} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText("Existing branch"));
|
|
||||||
|
|
||||||
expect(onModeChange).toHaveBeenCalledWith("existing");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables a branch already held by another worktree, and the main checkout's HEAD", () => {
|
|
||||||
const branches: BranchInfo[] = [
|
|
||||||
{ name: "main", is_head: true },
|
|
||||||
{ name: "feat/a", is_head: false, worktree: "/repos/a" },
|
|
||||||
{ name: "feat/b", is_head: false },
|
|
||||||
];
|
|
||||||
render(<BranchPicker {...props({ mode: "existing", branches })} />);
|
|
||||||
|
|
||||||
const main = screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement;
|
|
||||||
const a = screen.getByRole("option", { name: /feat\/a — held by \/repos\/a/ }) as HTMLOptionElement;
|
|
||||||
const b = screen.getByRole("option", { name: "feat/b" }) as HTMLOptionElement;
|
|
||||||
expect(main.disabled).toBe(true);
|
|
||||||
expect(a.disabled).toBe(true);
|
|
||||||
expect(b.disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a placeholder option when no local branches were found", () => {
|
|
||||||
render(<BranchPicker {...props({ mode: "existing", branches: [] })} />);
|
|
||||||
expect(screen.getByText("No local branches found.")).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggles the worktree checkbox", () => {
|
|
||||||
const onToggleEnabled = vi.fn();
|
|
||||||
render(<BranchPicker {...props({ onToggleEnabled })} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText("Create git worktree"));
|
|
||||||
|
|
||||||
expect(onToggleEnabled).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
// The spawn form's worktree section (#61): create-worktree toggle, then
|
|
||||||
// (once a base repo is known) new-branch-vs-existing-branch, mirroring
|
|
||||||
// spawnForm.ts's `.adv-group`/`.wt-body`/`.wt-mode` markup.
|
|
||||||
|
|
||||||
import type { BranchInfo } from "../../types.js";
|
|
||||||
import type { WorktreeBranchMode } from "../../spawnForm.js";
|
|
||||||
|
|
||||||
export interface BranchPickerProps {
|
|
||||||
enabled: boolean;
|
|
||||||
onToggleEnabled(enabled: boolean): void;
|
|
||||||
/** Status line shown in place of the body — "Checking…", "not a repo",
|
|
||||||
* "choose a directory first". `null` once the body itself can render. */
|
|
||||||
hint: string | null;
|
|
||||||
/** The repo a worktree would branch from, once resolved. */
|
|
||||||
baseRepo: string | null;
|
|
||||||
mode: WorktreeBranchMode;
|
|
||||||
onModeChange(mode: WorktreeBranchMode): void;
|
|
||||||
newBranch: string;
|
|
||||||
onNewBranchChange(value: string): void;
|
|
||||||
/** This cwd's local branches, or `undefined` while unknown/not yet loaded. */
|
|
||||||
branches: BranchInfo[] | undefined;
|
|
||||||
existingBranch: string;
|
|
||||||
onExistingBranchChange(value: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BranchPicker(props: BranchPickerProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex flex-col gap-sp-3 border-l-2 pl-sp-3 ${
|
|
||||||
props.enabled ? "border-accent" : "border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
data-role="toggle"
|
|
||||||
className="size-[18px] accent-accent"
|
|
||||||
checked={props.enabled}
|
|
||||||
onChange={(e) => props.onToggleEnabled(e.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
Create git worktree
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{props.hint && <p className="m-0 text-fs-12 text-muted">{props.hint}</p>}
|
|
||||||
|
|
||||||
{props.enabled && !props.hint && (
|
|
||||||
<div className="flex flex-col gap-sp-3">
|
|
||||||
{props.baseRepo && (
|
|
||||||
<p className="m-0 font-mono text-fs-12 text-muted">repo: {props.baseRepo}</p>
|
|
||||||
)}
|
|
||||||
<div role="radiogroup" aria-label="Branch source" className="flex gap-sp-4">
|
|
||||||
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="worktree_branch_mode"
|
|
||||||
checked={props.mode === "new"}
|
|
||||||
onChange={() => props.onModeChange("new")}
|
|
||||||
/>
|
|
||||||
New branch
|
|
||||||
</label>
|
|
||||||
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="worktree_branch_mode"
|
|
||||||
checked={props.mode === "existing"}
|
|
||||||
onChange={() => props.onModeChange("existing")}
|
|
||||||
/>
|
|
||||||
Existing branch
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{props.mode === "new" ? (
|
|
||||||
<label className="flex flex-col gap-1 text-fs-12 text-muted">
|
|
||||||
New branch name
|
|
||||||
<input
|
|
||||||
name="worktree_branch"
|
|
||||||
type="text"
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="feat/foo"
|
|
||||||
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2 font-mono text-fs-15 text-ink"
|
|
||||||
value={props.newBranch}
|
|
||||||
onChange={(e) => props.onNewBranchChange(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<label className="flex flex-col gap-1 text-fs-12 text-muted">
|
|
||||||
Pick a branch
|
|
||||||
<select
|
|
||||||
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2 font-mono text-fs-15 text-ink"
|
|
||||||
value={props.existingBranch}
|
|
||||||
onChange={(e) => props.onExistingBranchChange(e.currentTarget.value)}
|
|
||||||
>
|
|
||||||
{branchOptions(props.branches)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function branchOptions(branches: BranchInfo[] | undefined) {
|
|
||||||
if (!branches || branches.length === 0) {
|
|
||||||
return <option value="">No local branches found.</option>;
|
|
||||||
}
|
|
||||||
return branches.map((b) => {
|
|
||||||
// A branch already checked out anywhere stays visible but unpickable —
|
|
||||||
// git refuses a second checkout. That includes the main checkout's own
|
|
||||||
// HEAD, which is commonly `main` and would otherwise be the default pick,
|
|
||||||
// making every existing-branch spawn fail (#54 review).
|
|
||||||
const held = b.worktree ?? (b.is_head ? "the main checkout" : null);
|
|
||||||
return (
|
|
||||||
<option key={b.name} value={b.name} disabled={held !== null} title={held ?? ""}>
|
|
||||||
{held ? `${b.name} — held by ${held}` : b.name}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { DirAutocomplete } from "./DirAutocomplete.js";
|
|
||||||
|
|
||||||
vi.mock("../../api.js", () => ({
|
|
||||||
listDir: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { listDir } from "../../api.js";
|
|
||||||
|
|
||||||
const mockListDir = vi.mocked(listDir);
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
function Harness({ initial = "" }: { initial?: string }) {
|
|
||||||
const [value, setValue] = useState(initial);
|
|
||||||
return <DirAutocomplete value={value} onChange={setValue} placeholder="cwd" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `fireEvent.focus`/`.blur` dispatch the event without moving the browser's
|
|
||||||
// real focus, so `document.activeElement` (which the dropdown's fetch guards
|
|
||||||
// against) never changes — calling the DOM method directly does both.
|
|
||||||
function focus(el: HTMLElement): void {
|
|
||||||
act(() => el.focus());
|
|
||||||
}
|
|
||||||
function blur(el: HTMLElement): void {
|
|
||||||
act(() => el.blur());
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("DirAutocomplete", () => {
|
|
||||||
it("lists entries matching the trailing segment on focus", async () => {
|
|
||||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
|
|
||||||
render(<Harness initial="/home/n/al" />);
|
|
||||||
|
|
||||||
focus(screen.getByPlaceholderText("cwd"));
|
|
||||||
|
|
||||||
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
|
|
||||||
expect(screen.queryByRole("option", { name: "beta/" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Tab commits the highlighted entry and does not close the field", async () => {
|
|
||||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
|
||||||
render(<Harness initial="/home/n/a" />);
|
|
||||||
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
|
|
||||||
focus(input);
|
|
||||||
await screen.findByRole("option", { name: "alpha/" });
|
|
||||||
|
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
|
||||||
|
|
||||||
expect(input.value).toBe("/home/n/alpha/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("arrows move the highlight and Enter accepts it without submitting", async () => {
|
|
||||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
|
|
||||||
render(<Harness initial="/home/n/" />);
|
|
||||||
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
|
|
||||||
focus(input);
|
|
||||||
await screen.findByRole("option", { name: "alpha/" });
|
|
||||||
|
|
||||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
||||||
const notPrevented = fireEvent.keyDown(input, { key: "Enter" });
|
|
||||||
|
|
||||||
expect(input.value).toBe("/home/n/beta/");
|
|
||||||
expect(notPrevented).toBe(false); // preventDefault called -> event "processed"
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Escape closes the dropdown and stops the key from bubbling", async () => {
|
|
||||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
|
||||||
const onKeyDown = vi.fn();
|
|
||||||
render(
|
|
||||||
<div onKeyDown={onKeyDown}>
|
|
||||||
<Harness initial="/home/n/a" />
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
const input = screen.getByPlaceholderText("cwd");
|
|
||||||
focus(input);
|
|
||||||
await screen.findByRole("option", { name: "alpha/" });
|
|
||||||
|
|
||||||
fireEvent.keyDown(input, { key: "Escape" });
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
|
|
||||||
expect(onKeyDown).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not reopen on refocus until the operator types again", async () => {
|
|
||||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
|
||||||
render(<Harness initial="/home/n/a" />);
|
|
||||||
const input = screen.getByPlaceholderText("cwd");
|
|
||||||
focus(input);
|
|
||||||
await screen.findByRole("option", { name: "alpha/" });
|
|
||||||
fireEvent.keyDown(input, { key: "Escape" });
|
|
||||||
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
|
|
||||||
|
|
||||||
blur(input);
|
|
||||||
focus(input);
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
expect(screen.queryByRole("option")).toBeNull();
|
|
||||||
|
|
||||||
fireEvent.change(input, { target: { value: "/home/n/al" } });
|
|
||||||
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user