Compare commits
224
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f8f02e11 | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 | ||
|
|
3c10a3a08a | ||
|
|
fd79c8116a | ||
|
|
cb8b93f178 | ||
|
|
60e57fde31 | ||
|
|
8d35f7f8c8 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
88afd0df3f | ||
|
|
df51aa7be9 | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
43bfda5a60 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 | ||
|
|
0543f66469 | ||
|
|
4a2f7e98b4 | ||
|
|
8c151a5fb2 | ||
|
|
8d0886f9b7 | ||
|
|
9f82400f0a | ||
|
|
059316cbac | ||
|
|
0b1c9e15cf | ||
|
|
e30084ea24 | ||
|
|
bb898a75f3 | ||
|
|
c8e90af91c | ||
|
|
d0df60ac87 | ||
|
|
25244803a9 | ||
|
|
92b41a8742 | ||
|
|
508262bbac | ||
|
|
002f1b466b | ||
|
|
bc27bffbae | ||
|
|
bbf622ed9b | ||
|
|
0c46fb993a | ||
|
|
be554d0ede | ||
|
|
4fda393378 | ||
|
|
a1f3690aaf | ||
|
|
4ce432c227 | ||
|
|
ba7fd68004 | ||
|
|
c3d008d07f | ||
|
|
c355f67133 | ||
|
|
b5c9970e28 | ||
|
|
22d740a36b | ||
|
|
ccdfec8257 | ||
|
|
30f91176ae | ||
|
|
a771d29210 | ||
|
|
913421e244 | ||
|
|
2e7c5f7f58 | ||
|
|
dba3d8c249 | ||
|
|
4c8217385a | ||
|
|
758bacf3fc | ||
|
|
0af91296df | ||
|
|
370edfa763 | ||
|
|
e7086512e7 | ||
|
|
aca8d39886 | ||
|
|
6ae7c3eb97 | ||
|
|
a73eb6284f | ||
|
|
57efb615f6 | ||
|
|
fe26dab6a8 | ||
|
|
8eecd9961e | ||
|
|
ac2eb01bda | ||
|
|
1576380080 | ||
|
|
85feb433ae | ||
|
|
a11933b7bf | ||
|
|
fa40eb7378 | ||
|
|
b680266990 | ||
|
|
ded1f050b2 | ||
|
|
a58ebfed93 | ||
|
|
31b429a840 | ||
|
|
12bd68a2af | ||
|
|
f765d415d6 | ||
|
|
d3e3010245 | ||
|
|
7a0dbbf7f2 | ||
|
|
84fa4a3076 | ||
|
|
eafeac5c05 | ||
|
|
f96f94db8f | ||
|
|
efb54750ed | ||
|
|
9e59121eec | ||
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 | ||
|
|
25344abe4f | ||
|
|
b52a9085e9 | ||
|
|
8bd943a8ca | ||
|
|
b86335ddf1 | ||
|
|
c348381c5f | ||
|
|
dabdf9fcba | ||
|
|
b76dc06a09 | ||
|
|
23fbfdc5b8 | ||
|
|
ff34727e3c | ||
|
|
e9ede49256 | ||
|
|
8c97ef0616 | ||
|
|
9533672e09 | ||
|
|
b11784e8e3 | ||
|
|
42ffc1a352 | ||
|
|
a50f098c8a | ||
|
|
32d4058c96 | ||
|
|
74a423567f | ||
|
|
b225fbdd69 | ||
|
|
244e8d2927 | ||
|
|
4a1a10c49a | ||
|
|
d607740fea | ||
|
|
0b34d1608f | ||
|
|
075dea509a | ||
|
|
c067f48a24 | ||
|
|
51f96cfb98 | ||
|
|
8f5d96a95b | ||
|
|
3e98b3ab24 | ||
|
|
24ed1ac002 | ||
|
|
3529947497 | ||
|
|
4851ad9101 | ||
|
|
64741a027e | ||
|
|
cdd4213ff5 | ||
|
|
f8973b3802 | ||
|
|
1e2c0f0a38 | ||
|
|
6fa7dc63e4 | ||
|
|
5c0a4c4640 | ||
|
|
8773671afd | ||
|
|
d9815d86c6 | ||
|
|
13009d0f0e | ||
|
|
bbae09bc48 | ||
|
|
c7c294996e | ||
|
|
3c638b08de | ||
|
|
be23a2b539 | ||
|
|
b98f5cb3e5 | ||
|
|
0f5576c77a | ||
|
|
0292f373f7 | ||
|
|
eea337ce36 | ||
|
|
fd3ea64949 | ||
|
|
046c110460 | ||
|
|
6eb65f1057 | ||
|
|
3a68c1dd2f | ||
|
|
fde1954eac | ||
|
|
25435c34b2 | ||
|
|
4a956e9c38 | ||
|
|
2a49814572 | ||
|
|
d1aa56bab3 | ||
|
|
cf322746a7 | ||
|
|
b25e733539 | ||
|
|
5a861976d7 | ||
|
|
bfe4965081 | ||
|
|
9d7ed8c071 | ||
|
|
88a40c1ea7 | ||
|
|
7049b5f1c0 | ||
|
|
c277ad630c | ||
|
|
fc9c62d46b | ||
|
|
3fe560d67e | ||
|
|
b2b33535ae | ||
|
|
4ea6ddcaee | ||
|
|
e9142bb027 | ||
|
|
d11098a478 | ||
|
|
61776b4e0a | ||
|
|
eabd21bccf | ||
|
|
fd54d4f833 | ||
|
|
78b5abb0cf | ||
|
|
6d8b49b75f | ||
|
|
6c20a99d58 | ||
|
|
b1718559df | ||
|
|
540a3025b1 | ||
|
|
3cb4bebd1e | ||
|
|
c0da16658a | ||
|
|
eb4655a9d2 | ||
|
|
0e20baf7c2 | ||
|
|
c76059ec66 | ||
|
|
af8acbfbf2 | ||
|
|
f02168ffd5 | ||
|
|
1ad7845f51 | ||
|
|
f0c272e0b1 | ||
|
|
624f6f19c8 | ||
|
|
08161ae042 | ||
|
|
1e3c7b294d | ||
|
|
49ab7e9c81 | ||
|
|
0cc25d7e1c | ||
|
|
706942ddd7 | ||
|
|
52877abe29 | ||
|
|
43fc3a3735 | ||
|
|
ff1ffb377c | ||
|
|
ba74d3515b | ||
|
|
8325bc6d9a | ||
|
|
8f499dbee3 | ||
|
|
511fed408c | ||
|
|
ba02b11f13 | ||
|
|
59ede15d2b | ||
|
|
331039df11 | ||
|
|
151438094e | ||
|
|
c1c283c0f8 | ||
|
|
1b260b5c20 | ||
|
|
3eb1d7ddbe | ||
|
|
9d6b69cc49 | ||
|
|
b2cbb23fe2 | ||
|
|
9fe9fe4628 | ||
|
|
e6a254b734 | ||
|
|
dc783778c8 | ||
|
|
f8b434dd1d |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,12 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
|
||||
the two matchers must agree on, and the fixture table they are tested against.
|
||||
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
|
||||
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
|
||||
|
||||
Generated
+750
-57
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ repository = "https://git.naps.pt/naps62/maestro"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
@@ -17,6 +18,7 @@ crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
dirs = "5"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
libc = "0.2"
|
||||
maestro-acp = { path = "crates/maestro-acp" }
|
||||
maestro-config = { path = "crates/maestro-config" }
|
||||
# `default-features = false` so each consumer opts into `haiku-autoname`
|
||||
# through its own feature of the same name (#131); a workspace entry that
|
||||
@@ -38,9 +40,11 @@ tempfile = "3"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
||||
tokio-tungstenite = "0.21"
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
toml = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
unicode-width = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
ratatui = "0.28"
|
||||
vt100 = "0.15"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[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
Executable
+262
@@ -0,0 +1,262 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,505 @@
|
||||
//! `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,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
pub enum AttachError {
|
||||
#[error("ws: {0}")]
|
||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
#[error("daemon returned {status} ({code}): {message}")]
|
||||
Rejected {
|
||||
status: String,
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("termios: {0}")]
|
||||
@@ -32,6 +38,21 @@ pub enum AttachError {
|
||||
}
|
||||
|
||||
impl AttachError {
|
||||
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
|
||||
if let Some(body) = response.body().as_deref() {
|
||||
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
|
||||
return Self::Rejected {
|
||||
status: response.status().to_string(),
|
||||
code: error_body.error.code,
|
||||
message: error_body.error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::WebSocket(error)
|
||||
}
|
||||
|
||||
/// True when the failure was an inability to reach the WS endpoint at the
|
||||
/// transport level (connection refused), as opposed to an HTTP-level
|
||||
/// rejection from a daemon that did answer.
|
||||
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
||||
/// byte that exits cleanly without killing the session.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(url)
|
||||
.await
|
||||
.map_err(AttachError::from_websocket)?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
|
||||
let raw = RawTty::enable()?;
|
||||
@@ -233,4 +256,25 @@ mod tests {
|
||||
Err(AttachError::InvalidDetachKey(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_surfaces_structured_handshake_errors() {
|
||||
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
|
||||
"not_terminal",
|
||||
"session is not a terminal session",
|
||||
))
|
||||
.unwrap();
|
||||
let response = tokio_tungstenite::tungstenite::http::Response::builder()
|
||||
.status(409)
|
||||
.body(Some(body))
|
||||
.unwrap();
|
||||
|
||||
let error =
|
||||
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// Type text into a running session, as if it had been typed on an
|
||||
/// attached terminal.
|
||||
/// Send text to a running session. Terminal sessions receive raw
|
||||
/// keystrokes; structured sessions receive an ACP prompt.
|
||||
Send(SendArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
Hooks(HooksArgs),
|
||||
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
pub existing_branch: bool,
|
||||
/// Run a Claude Code or Codex session in a plain terminal instead of the
|
||||
/// structured (ACP) view it otherwise gets by default.
|
||||
#[arg(long, conflicts_with = "structured")]
|
||||
pub terminal: bool,
|
||||
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
|
||||
/// Codex commands have an adapter; the daemon refuses anything else.
|
||||
#[arg(long)]
|
||||
pub structured: bool,
|
||||
/// After spawning, attach immediately.
|
||||
#[arg(long, short = 'a')]
|
||||
pub attach: bool,
|
||||
|
||||
@@ -96,20 +96,32 @@ pub async fn dispatch(
|
||||
}
|
||||
|
||||
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||
/// user's data dir and register it in their Claude Code settings. Idempotent
|
||||
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
||||
/// user's data dir and register it in every agent's hook file. Idempotent and
|
||||
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
|
||||
/// agent failing is reported and does not stop the others.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn run_hooks_install() -> Result<(), CliError> {
|
||||
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||
let changed = maestro_hooks::install_for_home(&home)?;
|
||||
if changed {
|
||||
println!(
|
||||
"installed claude-code activity hook: {} → {}",
|
||||
maestro_hooks::script_path(&home).display(),
|
||||
maestro_hooks::settings_path(&home).display()
|
||||
);
|
||||
} else {
|
||||
println!("claude-code activity hook already up to date");
|
||||
let script = maestro_hooks::script_path(&home);
|
||||
for (target, result) in maestro_hooks::install_for_home(&home) {
|
||||
let agent = target.label();
|
||||
let settings = target.settings_path(&home);
|
||||
match result {
|
||||
Ok(true) => {
|
||||
println!(
|
||||
"installed {agent} activity hook: {} → {}",
|
||||
script.display(),
|
||||
settings.display()
|
||||
);
|
||||
if target == maestro_hooks::Target::Codex {
|
||||
// Codex will not run a hook it has not been told to
|
||||
// trust, and that approval is the operator's to give.
|
||||
println!(" codex will ask you to trust this hook the next time it starts");
|
||||
}
|
||||
}
|
||||
Ok(false) => println!("{agent} activity hook already up to date"),
|
||||
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||
Session, SessionStatus, WorktreeSpec,
|
||||
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -51,15 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
};
|
||||
|
||||
let body = CreateSessionRequest {
|
||||
view: spawn_view(&args),
|
||||
cwd: args.cwd.clone(),
|
||||
command: args.command.clone(),
|
||||
env,
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve: false,
|
||||
};
|
||||
client.create_session(&body).await
|
||||
}
|
||||
|
||||
/// The view a `maestro spawn` asks for: structured whenever the command
|
||||
/// resolves to an agent with an ACP adapter, since that is the richer session
|
||||
/// and the one both other clients now default to. `--terminal` opts out;
|
||||
/// `--structured` forces the request through even for a command this side
|
||||
/// does not recognise, leaving the refusal to the daemon.
|
||||
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
||||
if args.terminal {
|
||||
return SessionView::Terminal;
|
||||
}
|
||||
if args.structured || supports_structured_view(&args.command) {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
}
|
||||
}
|
||||
|
||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||
/// the work went.
|
||||
@@ -112,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
||||
/// `maestro send`. Answers with the session that was typed into, so the
|
||||
/// caller can name it once the daemon has accepted the text.
|
||||
///
|
||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||
/// the shape a heredoc or a pipe wants.
|
||||
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||
/// there — ACP prompts are always complete messages).
|
||||
///
|
||||
/// Text comes from the arguments, or from stdin when there is none — the
|
||||
/// shape a heredoc or a pipe wants.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_send(
|
||||
client: &DaemonClient,
|
||||
@@ -137,19 +158,28 @@ pub async fn run_send(
|
||||
return Err(CliError::NothingToSend);
|
||||
}
|
||||
|
||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
let sessions = client.list_sessions().await?;
|
||||
resolve_session(&sessions, &args.session)?
|
||||
}
|
||||
};
|
||||
// We always need the session list: even a bare UUID needs the view field
|
||||
// to decide which endpoint to hit.
|
||||
let sessions = client.list_sessions().await?;
|
||||
let id = resolve_session(&sessions, &args.session)?;
|
||||
let view = sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.view)
|
||||
.unwrap_or(SessionView::Terminal);
|
||||
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
match view {
|
||||
SessionView::Structured => {
|
||||
client.send_structured_prompt(id, &text).await?;
|
||||
}
|
||||
SessionView::Terminal => {
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -178,8 +208,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
)
|
||||
.ok();
|
||||
for s in sorted {
|
||||
@@ -189,11 +219,16 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
SessionStatus::Running => "running",
|
||||
SessionStatus::Dead => "dead",
|
||||
};
|
||||
let view = match s.view {
|
||||
SessionView::Terminal => "terminal",
|
||||
SessionView::Structured => "structured",
|
||||
};
|
||||
let activity = match s.activity {
|
||||
ActivityState::Active => "active",
|
||||
ActivityState::Idle => "idle",
|
||||
ActivityState::AwaitingInput => "awaiting",
|
||||
ActivityState::Error => "error",
|
||||
ActivityState::Starting => "starting",
|
||||
};
|
||||
// Show the live foreground command alongside the spawn command when
|
||||
// the daemon has observed one that differs (NAP-340).
|
||||
@@ -206,8 +241,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
let blocked = blocked_label(s);
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, activity, dir, command
|
||||
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, view, activity, dir, command
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
@@ -229,13 +264,70 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::DaemonInfo;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::cli::{Cli, Command};
|
||||
|
||||
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
||||
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
||||
argv.extend_from_slice(extra);
|
||||
let cli = Cli::try_parse_from(argv).expect("parses");
|
||||
let Command::Spawn(args) = cli.command else {
|
||||
panic!("expected spawn")
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
/// The CLI follows the two interactive clients: a command with an ACP
|
||||
/// adapter spawns structured unless `--terminal` says otherwise, and
|
||||
/// `--structured` still forces the request for a command this side does
|
||||
/// not recognise.
|
||||
#[test]
|
||||
fn spawn_defaults_to_structured_for_supported_agents() {
|
||||
use super::spawn_view;
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["claude"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["npx codex exec"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
||||
SessionView::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
||||
SessionView::Structured
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_refuses_both_view_flags_at_once() {
|
||||
let err = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"claude",
|
||||
"--structured",
|
||||
"--terminal",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("--terminal"), "{err}");
|
||||
}
|
||||
|
||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||
Session {
|
||||
view: Default::default(),
|
||||
id: Uuid::from_u128(id),
|
||||
cwd: PathBuf::from(cwd),
|
||||
command: command.into(),
|
||||
@@ -252,6 +344,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +376,22 @@ mod tests {
|
||||
assert!(s.contains("idle"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_exposes_terminal_and_structured_views() {
|
||||
let terminal = session("/tmp", "bash", 1);
|
||||
let mut structured = session("/tmp", "claude", 2);
|
||||
structured.view = SessionView::Structured;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
||||
|
||||
assert!(lines[header + 1].contains("terminal"), "{out}");
|
||||
assert!(lines[header + 2].contains("structured"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||
|
||||
use maestro_protocol::{DaemonClient, DaemonInfo};
|
||||
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
||||
use maestro_testing::{session, MockDaemon};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
@@ -91,11 +92,13 @@ async fn spawn_round_trips_through_the_daemon() {
|
||||
let created = mock
|
||||
.client()
|
||||
.create_session(&maestro_protocol::CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/home/mock".into(),
|
||||
command: "bash".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -199,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
||||
assert_eq!(sent, id);
|
||||
}
|
||||
|
||||
/// A structured session routes through the ACP prompt endpoint, not the
|
||||
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
|
||||
/// complete messages).
|
||||
#[tokio::test]
|
||||
async fn send_to_structured_session_uses_acp_prompt() {
|
||||
let mock = MockDaemon::start().await;
|
||||
let id = Uuid::from_u128(13);
|
||||
let mut s = session(id, "/tmp", "claude");
|
||||
s.view = SessionView::Structured;
|
||||
mock.add_session(s);
|
||||
|
||||
let args = maestro_cli::cli::SendArgs {
|
||||
session: id.to_string(),
|
||||
text: vec!["fix".into(), "the".into(), "tests".into()],
|
||||
no_enter: false,
|
||||
};
|
||||
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||
.await
|
||||
.expect("send");
|
||||
|
||||
assert_eq!(sent, id);
|
||||
// Terminal input must stay empty — the message went through ACP.
|
||||
assert!(mock.inputs().is_empty());
|
||||
let prompts = mock.structured_prompts();
|
||||
assert_eq!(prompts.len(), 1);
|
||||
assert_eq!(prompts[0].0, id);
|
||||
assert_eq!(prompts[0].1.prompt, "fix the tests");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||
let mock = MockDaemon::start().await;
|
||||
|
||||
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
|
||||
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
|
||||
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
||||
|
||||
/// Leader key both clients use when `[tui].leader` is unset (#159). Canonical
|
||||
/// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey`
|
||||
/// both produce.
|
||||
pub const DEFAULT_LEADER: &str = "ctrl-a";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("io: {0}")]
|
||||
@@ -152,6 +157,15 @@ pub struct TuiSection {
|
||||
pub keymap_insert: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl TuiSection {
|
||||
/// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for
|
||||
/// history, but read by every client: the daemon serves it on
|
||||
/// `GET /api/info` so the web follows the same knob (#159).
|
||||
pub fn leader(&self) -> &str {
|
||||
self.leader.as_deref().unwrap_or(DEFAULT_LEADER)
|
||||
}
|
||||
}
|
||||
|
||||
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TuiTheme {
|
||||
@@ -299,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "opencode".into(),
|
||||
command: "opencode".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "zsh".into(),
|
||||
label: "Shell".into(),
|
||||
command: "zsh".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -320,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
||||
id: CUSTOM_PRESET_ID.into(),
|
||||
label: "Custom…".into(),
|
||||
command: String::new(),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +519,7 @@ auto_install = false
|
||||
id: "fish".into(),
|
||||
label: "fish".into(),
|
||||
command: "fish".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -537,6 +556,7 @@ auto_install = false
|
||||
id: " ".into(),
|
||||
label: "x".into(),
|
||||
command: "x".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -547,6 +567,7 @@ auto_install = false
|
||||
id: "x".into(),
|
||||
label: "x".into(),
|
||||
command: "".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -564,11 +585,13 @@ auto_install = false
|
||||
id: "a".into(),
|
||||
label: "a".into(),
|
||||
command: "a".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "a".into(),
|
||||
label: "a2".into(),
|
||||
command: "a2".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
@@ -583,6 +606,7 @@ auto_install = false
|
||||
id: "custom".into(),
|
||||
label: "Custom".into(),
|
||||
command: "whatever".into(),
|
||||
auto_approve: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ dirs.workspace = true
|
||||
futures-util.workspace = true
|
||||
include_dir = { workspace = true, optional = true }
|
||||
libc.workspace = true
|
||||
maestro-acp.workspace = true
|
||||
mime_guess = { workspace = true, optional = true }
|
||||
maestro-config.workspace = true
|
||||
maestro-hooks = { workspace = true, default-features = false }
|
||||
@@ -42,6 +43,7 @@ tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
libc.workspace = true
|
||||
reqwest.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
urlencoding = "2"
|
||||
|
||||
@@ -66,6 +66,23 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a structured session's sidebar state from its ACP projection.
|
||||
pub fn decide_structured(
|
||||
turn_active: bool,
|
||||
pending_approval: bool,
|
||||
turn_failed: bool,
|
||||
) -> ActivityState {
|
||||
if turn_failed {
|
||||
ActivityState::Error
|
||||
} else if pending_approval {
|
||||
ActivityState::AwaitingInput
|
||||
} else if turn_active {
|
||||
ActivityState::Active
|
||||
} else {
|
||||
ActivityState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a session's pane must be continuously focused (a client's pane,
|
||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||
/// operator has looked, so the rail stops asking for attention.
|
||||
@@ -136,6 +153,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
||||
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
||||
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
||||
assert_eq!(
|
||||
decide_structured(true, true, false),
|
||||
ActivityState::AwaitingInput
|
||||
);
|
||||
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_is_in_force_until_exactly_its_expiry() {
|
||||
let ttl = Duration::from_secs(300);
|
||||
|
||||
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
|
||||
) -> Result<(), axum::Error> {
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
|
||||
// This client's claim on the shared window size. Registered up front and
|
||||
// released on every exit below, so a socket that dies mid-session stops
|
||||
// constraining the size for the clients still attached.
|
||||
let viewport = manager.attach_viewport();
|
||||
|
||||
// NAP-229: send buffered scrollback up-front so the client sees the last
|
||||
// screenful immediately instead of a blank terminal. The replay lock the
|
||||
// bridge held during snapshot+subscribe guarantees no byte appears both
|
||||
// here and on the live receiver, and none is dropped at the seam.
|
||||
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
|
||||
manager.release_viewport(id, viewport).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
|
||||
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
||||
match ctrl {
|
||||
ClientControl::Resize { cols, rows } => {
|
||||
let _ = manager.resize(id, cols, rows).await;
|
||||
let _ = manager.set_viewport(id, viewport, cols, rows).await;
|
||||
}
|
||||
ClientControl::Ping => {
|
||||
let pong = serde_json::to_string(&ClientControl::Pong)
|
||||
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
|
||||
}
|
||||
}
|
||||
|
||||
manager.release_viewport(id, viewport).await;
|
||||
|
||||
if session_ended {
|
||||
let _ = sink.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
@@ -51,17 +51,23 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||
// session spawned right away should already carry the hook.
|
||||
if config.hooks_auto_install {
|
||||
match crate::hooks::auto_install(&config.home_dir) {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!("installed claude-code activity hook (#106)");
|
||||
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
|
||||
let agent = target.label();
|
||||
match outcome {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!(agent, "installed activity hook (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Upgraded => {
|
||||
tracing::info!(agent, "updated activity hook to this build (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Failed => {
|
||||
tracing::warn!(
|
||||
agent,
|
||||
"hooks auto-install failed; run `maestro hooks install` manually"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Upgraded => {
|
||||
tracing::info!("updated claude-code activity hook to this build (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Failed => {
|
||||
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// No worktree dir to pre-create: without an override the worktrees live
|
||||
@@ -100,20 +106,34 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
projects.start_background_refresh();
|
||||
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager,
|
||||
manager.clone(),
|
||||
config.hostname.clone(),
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
projects,
|
||||
config.leader.clone(),
|
||||
));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
let bound = listener.local_addr()?;
|
||||
tracing::info!(addr = %bound, "API listening");
|
||||
|
||||
let shutdown = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("ctrl-c received — shutting down");
|
||||
// Ctrl-C at a terminal or SIGTERM from systemd: either one is a
|
||||
// shutdown, and both must reach the adapter children below (#134). The
|
||||
// unit's `KillMode=process` signals only the daemon, so nothing else
|
||||
// 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)
|
||||
|
||||
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
|
||||
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||
/// `true`.
|
||||
pub hooks_auto_install: bool,
|
||||
/// Canonical leader key advertised to clients in `GET /api/info` (#159):
|
||||
/// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon
|
||||
/// never binds it itself; it serves it so the web client and the TUI read
|
||||
/// one knob.
|
||||
pub leader: String,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
project_roots,
|
||||
project_scan_depth,
|
||||
hooks_auto_install: file.hooks.auto_install(),
|
||||
leader: file.tui.leader().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #159: the leader the operator set for the TUI is what the daemon
|
||||
/// advertises, so rebinding it once moves both clients' chord.
|
||||
#[test]
|
||||
fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.leader, "ctrl-a");
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[tui]
|
||||
leader = "ctrl-b"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.leader, "ctrl-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
//! First-start hook auto-install (#106).
|
||||
//!
|
||||
//! The daemon runs [`auto_install`] once at startup: unless the user's
|
||||
//! `~/.claude/settings.json` already carries exactly what this build ships
|
||||
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
|
||||
//! same installer `maestro hooks install` uses writes it. That covers
|
||||
//! upgrades as well as first starts: an install from a maestro that had fewer
|
||||
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
|
||||
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
|
||||
//! a failure is warned about and never blocks startup. The same global hook
|
||||
//! serves every Claude Code session on the machine, so nothing here targets
|
||||
//! repositories or worktrees.
|
||||
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
|
||||
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
|
||||
//! file already carries exactly what this build ships (and `[hooks]
|
||||
//! .auto_install` hasn't opted out — checked by the caller), the same
|
||||
//! installer `maestro hooks install` uses writes it. That covers upgrades as
|
||||
//! well as first starts: an install from a maestro that had fewer events —
|
||||
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
|
||||
//! settings — is rewritten rather than left half-wired. Best-effort: a
|
||||
//! failure is warned about and never blocks startup, and one target failing
|
||||
//! does not stop the others. The same global hook serves every session on the
|
||||
//! machine, so nothing here targets repositories or worktrees.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Install the Claude Code activity hook for `home_dir` unless the current
|
||||
/// one is already there or the home dir is unresolvable (`""`). Returns what
|
||||
/// happened; an older install being brought up to date is
|
||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
|
||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
||||
use maestro_hooks::Target;
|
||||
|
||||
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
|
||||
/// any whose install is already current. Returns what happened per target —
|
||||
/// an older install being brought up to date is
|
||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
|
||||
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
|
||||
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
|
||||
Target::ALL
|
||||
.iter()
|
||||
.map(|&target| (target, auto_install_target(target, home_dir)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
|
||||
if home_dir.is_empty() {
|
||||
return AutoInstallOutcome::Skipped;
|
||||
}
|
||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
||||
let script = maestro_hooks::script_path(Path::new(home_dir));
|
||||
if maestro_hooks::is_current(&settings, &script) {
|
||||
let home = Path::new(home_dir);
|
||||
let settings = target.settings_path(home);
|
||||
let script = maestro_hooks::script_path(home);
|
||||
if maestro_hooks::is_current(target, &settings, &script) {
|
||||
return AutoInstallOutcome::AlreadyPresent;
|
||||
}
|
||||
let stale = maestro_hooks::is_installed(&settings);
|
||||
match maestro_hooks::install_for_home(Path::new(home_dir)) {
|
||||
match maestro_hooks::install_target(target, home) {
|
||||
Ok(_) if stale => AutoInstallOutcome::Upgraded,
|
||||
Ok(_) => AutoInstallOutcome::Installed,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
|
||||
tracing::warn!(
|
||||
target = target.label(),
|
||||
error = %e,
|
||||
"hooks auto-install failed (non-fatal)"
|
||||
);
|
||||
AutoInstallOutcome::Failed
|
||||
}
|
||||
}
|
||||
@@ -52,15 +68,50 @@ pub enum AutoInstallOutcome {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The outcome recorded for one target.
|
||||
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
|
||||
outcomes
|
||||
.iter()
|
||||
.find(|(t, _)| *t == target)
|
||||
.map(|(_, o)| *o)
|
||||
.expect("every target is reported")
|
||||
}
|
||||
|
||||
/// Everything happens under a temp `$HOME`; the developer's own
|
||||
/// `~/.claude/settings.json` is never read or written.
|
||||
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
|
||||
/// written.
|
||||
#[test]
|
||||
fn installs_when_settings_carry_no_maestro_hook() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outcome = auto_install(dir.path().to_str().unwrap());
|
||||
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
assert!(maestro_hooks::is_installed(&settings));
|
||||
let outcomes = auto_install(dir.path().to_str().unwrap());
|
||||
for target in Target::ALL {
|
||||
assert_eq!(
|
||||
outcome(&outcomes, target),
|
||||
AutoInstallOutcome::Installed,
|
||||
"{}",
|
||||
target.label()
|
||||
);
|
||||
assert!(maestro_hooks::is_installed(
|
||||
&target.settings_path(dir.path())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Every agent maestro knows about is hooked, not just Claude Code: a
|
||||
/// codex session reports activity and its first prompt the same way.
|
||||
#[test]
|
||||
fn codex_gets_its_own_hook_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
auto_install(dir.path().to_str().unwrap());
|
||||
let hooks = dir.path().join(".codex/hooks.json");
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
|
||||
let events = root["hooks"].as_object().unwrap();
|
||||
assert!(events.contains_key("PermissionRequest"));
|
||||
assert!(
|
||||
!events.contains_key("Notification"),
|
||||
"codex has no Notification"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -69,14 +120,22 @@ mod tests {
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
// A current install is left exactly as it is.
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
let before = std::fs::read_to_string(&settings).unwrap();
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read_to_string(&settings).unwrap(),
|
||||
"already-installed settings must not be rewritten"
|
||||
);
|
||||
let before: Vec<String> = Target::ALL
|
||||
.iter()
|
||||
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
|
||||
.collect();
|
||||
let outcomes = auto_install(home);
|
||||
for (i, target) in Target::ALL.iter().enumerate() {
|
||||
assert_eq!(
|
||||
outcome(&outcomes, *target),
|
||||
AutoInstallOutcome::AlreadyPresent
|
||||
);
|
||||
assert_eq!(
|
||||
before[i],
|
||||
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
|
||||
"already-installed settings must not be rewritten"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An install from an older maestro — present, but missing an event this
|
||||
@@ -87,7 +146,7 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
let settings = Target::ClaudeCode.settings_path(dir.path());
|
||||
let script = maestro_hooks::script_path(dir.path());
|
||||
|
||||
let mut root: serde_json::Value =
|
||||
@@ -96,12 +155,23 @@ mod tests {
|
||||
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
|
||||
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
|
||||
assert!(maestro_hooks::is_current(&settings, &script));
|
||||
let outcomes = auto_install(home);
|
||||
assert_eq!(
|
||||
outcome(&outcomes, Target::ClaudeCode),
|
||||
AutoInstallOutcome::Upgraded
|
||||
);
|
||||
assert!(maestro_hooks::is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings,
|
||||
&script
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_home_is_skipped() {
|
||||
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
|
||||
let outcomes = auto_install("");
|
||||
for target in Target::ALL {
|
||||
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod hooks;
|
||||
pub mod local_api;
|
||||
pub mod panes;
|
||||
pub mod proc;
|
||||
pub mod projection;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod release;
|
||||
@@ -30,6 +31,7 @@ pub mod spawn_history;
|
||||
#[cfg(feature = "web")]
|
||||
pub mod static_bundle;
|
||||
pub mod tmux;
|
||||
pub mod viewport;
|
||||
pub mod worktree;
|
||||
pub mod worktree_probe;
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{any, delete, get, post};
|
||||
@@ -21,12 +22,14 @@ use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::SessionManager;
|
||||
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
|
||||
use crate::sidecar::EventCursor;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalApiState {
|
||||
@@ -39,6 +42,10 @@ pub struct LocalApiState {
|
||||
pub presets: Arc<Vec<SpawnPreset>>,
|
||||
/// Project discovery (#54) backing `GET /api/projects`.
|
||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||
/// Configured leader key (`[tui].leader`) advertised in `GET /api/info`
|
||||
/// (#159), so the web client binds the chord the operator configured
|
||||
/// rather than its own default.
|
||||
pub leader: Arc<String>,
|
||||
}
|
||||
|
||||
pub fn router(state: LocalApiState) -> Router {
|
||||
@@ -60,7 +67,20 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::NAMES, get(names))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.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")]
|
||||
{
|
||||
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
||||
@@ -160,12 +180,14 @@ fn dir_error_status(code: &str) -> StatusCode {
|
||||
fn session_error_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"not_found" => StatusCode::NOT_FOUND,
|
||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||
"kill_guarded" => StatusCode::CONFLICT,
|
||||
// The session outlived its pane: nothing to type into, and nothing
|
||||
// the daemon can do about it.
|
||||
"not_running" => StatusCode::CONFLICT,
|
||||
"not_terminal" => StatusCode::CONFLICT,
|
||||
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
@@ -219,6 +241,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
||||
presets: (*s.presets).clone(),
|
||||
build: crate::release::build_info(),
|
||||
instance: crate::release::instance_id().to_string(),
|
||||
leader: (*s.leader).clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,11 +315,10 @@ async fn rename_session(
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
||||
/// The report holds above the daemon's inference for a TTL, then inference
|
||||
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
||||
/// forever. Unknown session is 404; an unknown state value is rejected by the
|
||||
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
|
||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal
|
||||
/// session's state. Structured sessions acknowledge and ignore these reports
|
||||
/// because their activity comes from ACP. Unknown session is 404; an unknown
|
||||
/// state value is rejected by the JSON decoder (422). Answers `204 No Content`.
|
||||
async fn report_activity(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
@@ -363,6 +385,199 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> 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
|
||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||
/// scanner starts with its cache cold; wire
|
||||
@@ -374,6 +589,7 @@ pub fn state_for(
|
||||
home_dir: impl Into<String>,
|
||||
presets: Vec<SpawnPreset>,
|
||||
projects: Arc<crate::projects::ProjectScanner>,
|
||||
leader: impl Into<String>,
|
||||
) -> LocalApiState {
|
||||
LocalApiState {
|
||||
manager,
|
||||
@@ -381,6 +597,7 @@ pub fn state_for(
|
||||
home_dir: Arc::new(home_dir.into()),
|
||||
presets: Arc::new(presets),
|
||||
projects,
|
||||
leader: Arc::new(leader.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,12 +615,23 @@ async fn attach_ws(
|
||||
State(s): State<LocalApiState>,
|
||||
ws: axum::extract::WebSocketUpgrade,
|
||||
) -> Result<Response, Response> {
|
||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon",
|
||||
));
|
||||
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
||||
Ok(subscription) => subscription,
|
||||
Err(SessionError::NotFound(_)) => {
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon",
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
let info = error.to_error_info();
|
||||
return Err(error_response(
|
||||
session_error_status(&info.code),
|
||||
&info.code,
|
||||
&info.message,
|
||||
));
|
||||
}
|
||||
};
|
||||
let manager = s.manager.clone();
|
||||
Ok(ws.on_upgrade(move |socket| async move {
|
||||
@@ -411,6 +639,75 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2377
-110
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,23 @@
|
||||
//! original `cwd`, command, env, metadata, etc. Files are deleted on
|
||||
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still
|
||||
//! 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 maestro_protocol::Session;
|
||||
use maestro_acp::schema::SessionNotification;
|
||||
use maestro_protocol::{Session, TranscriptRow};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -38,6 +51,120 @@ impl SidecarStore {
|
||||
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
|
||||
/// if it does not exist.
|
||||
pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
|
||||
@@ -59,13 +186,13 @@ impl SidecarStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the sidecar for `id`. No-op if it does not exist.
|
||||
/// Delete the sidecar, event log, and recovery marker for `id`. No-op for
|
||||
/// whichever does not exist.
|
||||
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
||||
match std::fs::remove_file(self.path_for(id)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
remove_if_present(&self.events_path_for(id))?;
|
||||
remove_if_present(&self.dir.join(format!("{id}.approvals.jsonl")))?;
|
||||
remove_if_present(&self.recovery_path_for(id))?;
|
||||
remove_if_present(&self.path_for(id))
|
||||
}
|
||||
|
||||
/// Read every sidecar JSON in the directory. Files that fail to parse are
|
||||
@@ -81,6 +208,8 @@ impl SidecarStore {
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
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") {
|
||||
continue;
|
||||
}
|
||||
@@ -95,3 +224,425 @@ impl SidecarStore {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! What size the shared tmux window should be, given every client watching it.
|
||||
//!
|
||||
//! A tmux window has one size, but a session can have several clients attached
|
||||
//! at once — two browser panes, a browser and the TUI. Each reports the size
|
||||
//! *it* can display, and applying whichever arrived last makes the window
|
||||
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
|
||||
//! repaint at a new height, and the rows it drew at the old height are never
|
||||
//! cleared: the operator ends up looking at a stale input box, types into a
|
||||
//! box that is not where the screen says it is, and concludes the pane is
|
||||
//! stuck.
|
||||
//!
|
||||
//! So the window is sized to the smallest attached client on each axis — the
|
||||
//! same rule tmux uses for its own multiple clients, and the only size every
|
||||
//! client can render without truncation. The size last handed to tmux is
|
||||
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
/// One attached client within a session. Allocated by
|
||||
/// [`crate::session::SessionManager`] and opaque to everything else.
|
||||
pub type ViewportId = u64;
|
||||
|
||||
/// Per-session client sizes, and what tmux was last told.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Viewports {
|
||||
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
|
||||
applied: HashMap<Uuid, (u16, u16)>,
|
||||
}
|
||||
|
||||
impl Viewports {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record what `viewport` can display. Returns the size tmux must be set
|
||||
/// to, or `None` when the window already has it.
|
||||
pub fn set(
|
||||
&mut self,
|
||||
session: Uuid,
|
||||
viewport: ViewportId,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Option<(u16, u16)> {
|
||||
self.clients
|
||||
.entry(session)
|
||||
.or_default()
|
||||
.insert(viewport, (cols, rows));
|
||||
self.settle(session)
|
||||
}
|
||||
|
||||
/// Drop a client that has detached. Returns the size tmux must be set to
|
||||
/// now that it no longer constrains the window.
|
||||
///
|
||||
/// The last client leaving settles nothing: the window keeps the size it
|
||||
/// has rather than snapping to some remembered default no one is watching.
|
||||
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
|
||||
let clients = self.clients.get_mut(&session)?;
|
||||
clients.remove(&viewport);
|
||||
if clients.is_empty() {
|
||||
self.clients.remove(&session);
|
||||
self.applied.remove(&session);
|
||||
return None;
|
||||
}
|
||||
self.settle(session)
|
||||
}
|
||||
|
||||
/// The smallest size every attached client can render, but only when it
|
||||
/// differs from what tmux was last told.
|
||||
///
|
||||
/// A zero on either axis is a client that cannot display anything yet — a
|
||||
/// pane still laying out, a hidden tab — and must not drag the shared
|
||||
/// window down with it, so it is held but left out of the arbitration.
|
||||
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
|
||||
let clients = self.clients.get(&session)?;
|
||||
let mut cols = u16::MAX;
|
||||
let mut rows = u16::MAX;
|
||||
for &(c, r) in clients.values() {
|
||||
if c == 0 || r == 0 {
|
||||
continue;
|
||||
}
|
||||
cols = cols.min(c);
|
||||
rows = rows.min(r);
|
||||
}
|
||||
if cols == u16::MAX || rows == u16::MAX {
|
||||
return None;
|
||||
}
|
||||
let target = (cols, rows);
|
||||
if self.applied.get(&session) == Some(&target) {
|
||||
return None;
|
||||
}
|
||||
self.applied.insert(session, target);
|
||||
Some(target)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_first_report_settles_the_window() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_that_changes_nothing_costs_no_resize() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
v.set(s, 1, 80, 24);
|
||||
assert_eq!(v.set(s, 1, 80, 24), None);
|
||||
assert_eq!(v.set(s, 1, 80, 24), None);
|
||||
}
|
||||
|
||||
/// The bug this module exists for: two clients of different sizes used to
|
||||
/// resize the window past each other on every report.
|
||||
#[test]
|
||||
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
|
||||
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
|
||||
// Whatever order they re-report in, the answer stays the smaller one.
|
||||
assert_eq!(v.set(s, 1, 53, 46), None);
|
||||
assert_eq!(v.set(s, 2, 53, 20), None);
|
||||
assert_eq!(v.set(s, 1, 53, 46), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_axis_is_arbitrated_on_its_own() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
v.set(s, 1, 200, 20);
|
||||
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_sized_client_does_not_shrink_the_window() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
|
||||
assert_eq!(v.set(s, 2, 0, 0), None);
|
||||
assert_eq!(v.set(s, 2, 0, 30), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_the_small_client_gives_the_window_back() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
v.set(s, 1, 80, 46);
|
||||
v.set(s, 2, 80, 20);
|
||||
assert_eq!(v.release(s, 2), Some((80, 46)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_the_last_client_settles_nothing() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
v.set(s, 1, 80, 24);
|
||||
assert_eq!(v.release(s, 1), None);
|
||||
// ...and the session is forgotten, so a fresh attach settles again.
|
||||
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_an_unknown_viewport_is_harmless() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
assert_eq!(v.release(s, 7), None);
|
||||
v.set(s, 1, 80, 24);
|
||||
assert_eq!(v.release(s, 7), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_do_not_arbitrate_against_each_other() {
|
||||
let mut v = Viewports::new();
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
|
||||
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
|
||||
assert_eq!(v.set(a, 1, 80, 24), None);
|
||||
}
|
||||
}
|
||||
@@ -111,16 +111,33 @@ impl WorktreeManager {
|
||||
// Clear registrations whose directory is gone first, so a
|
||||
// previously-leaked entry doesn't push this branch to a suffix.
|
||||
prune(base_repo)?;
|
||||
// Anything occupying the target — a tree we left behind, one the
|
||||
// operator made, an unrelated directory — is sidestepped with a
|
||||
// numeric suffix on the directory; the branch stays whatever was
|
||||
// requested (#77). The daemon never adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
let (branch, path) = if create_branch {
|
||||
// Worktrees are commonly abandoned without deleting their branch,
|
||||
// so a random handle can collide with one of its own past runs.
|
||||
// Unlike the `add_existing` arm below, the suffix goes on the
|
||||
// branch name itself — the path is derived from it anyway.
|
||||
let mut candidate = branch.to_string();
|
||||
let mut path = self.target_path(base_repo, &candidate);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
|
||||
n += 1;
|
||||
candidate = format!("{branch}-{n}");
|
||||
path = self.target_path(base_repo, &candidate);
|
||||
}
|
||||
(candidate, path)
|
||||
} else {
|
||||
// Existing branch: the name must stay exact, so only the
|
||||
// directory — anything occupying it, ours or the operator's — is
|
||||
// sidestepped with a numeric suffix (#77). The daemon never
|
||||
// adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
(branch.to_string(), path)
|
||||
};
|
||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||
// yet; the target itself must not.
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -129,10 +146,10 @@ impl WorktreeManager {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
||||
if create_branch {
|
||||
cmd.arg("-b").arg(branch).arg(&path);
|
||||
cmd.arg("-b").arg(&branch).arg(&path);
|
||||
} else {
|
||||
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
||||
cmd.arg(&path).arg(branch);
|
||||
cmd.arg(&path).arg(&branch);
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
check(out)?;
|
||||
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
||||
Some(candidate.to_path_buf())
|
||||
}
|
||||
|
||||
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
|
||||
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["show-ref", "--verify", "--quiet"])
|
||||
.arg(format!("refs/heads/{branch}"))
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||
/// no longer exists.
|
||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,11 +42,13 @@ async fn fixture(ttl: Duration) -> Fixture {
|
||||
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
||||
manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -74,11 +75,13 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
||||
let s = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd_path,
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -50,11 +50,13 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
};
|
||||
manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -19,6 +19,7 @@ use tempfile::TempDir;
|
||||
|
||||
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -28,6 +29,7 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
|
||||
"/home/test",
|
||||
presets,
|
||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
|
||||
@@ -4,12 +4,17 @@ mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::DaemonFixture;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_daemon::local_api;
|
||||
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView,
|
||||
WorktreeSpec,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
@@ -36,6 +41,14 @@ async fn spawn(hostname: &str) -> Harness {
|
||||
async fn spawn_with(
|
||||
hostname: &str,
|
||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||
) -> Harness {
|
||||
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
|
||||
}
|
||||
|
||||
async fn spawn_with_leader(
|
||||
hostname: &str,
|
||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||
leader: &str,
|
||||
) -> Harness {
|
||||
let fx = DaemonFixture::new();
|
||||
let manager = fx.manager.clone();
|
||||
@@ -48,6 +61,7 @@ async fn spawn_with(
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
leader,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -77,6 +91,649 @@ fn ws_url(addr: SocketAddr, path: &str) -> String {
|
||||
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]
|
||||
async fn info_returns_daemon_identity() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -91,6 +748,38 @@ async fn info_returns_daemon_identity() {
|
||||
assert!(resp["version"].is_string());
|
||||
}
|
||||
|
||||
/// #159: the web client reads its leader off `/api/info` instead of a
|
||||
/// hardcoded constant, so the payload has to carry the configured one — not
|
||||
/// the default dressed up as one.
|
||||
#[tokio::test]
|
||||
async fn info_advertises_the_configured_leader() {
|
||||
let h = spawn_with_leader(
|
||||
"naps-laptop",
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
"ctrl-b",
|
||||
)
|
||||
.await;
|
||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["leader"], "ctrl-b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn info_reports_the_default_leader_when_unconfigured() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["leader"], "ctrl-a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn info_reports_build_and_instance() {
|
||||
// The status indicator needs a release to name and a process to compare
|
||||
@@ -133,11 +822,13 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
};
|
||||
let created: Session = reqwest::Client::new()
|
||||
.post(url(h.addr, "/api/sessions"))
|
||||
@@ -349,11 +1040,13 @@ async fn patch_session_renames_it() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -391,11 +1084,13 @@ async fn post_focus_stamps_last_focused_at() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -424,11 +1119,13 @@ async fn delete_owned_session_kills_it() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -445,11 +1142,13 @@ async fn delete_owned_session_kills_it() {
|
||||
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||
h.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -554,6 +1253,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -563,6 +1263,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -606,6 +1307,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -615,6 +1317,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -639,11 +1342,13 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -674,6 +1379,39 @@ async fn attach_ws_streams_pty_bytes() {
|
||||
assert!(saw_hello, "PTY did not echo through the attach WS");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fake = FakeAcp::new();
|
||||
let id = structured_session(&h, &fake).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
|
||||
.json(&maestro_protocol::SendInputRequest {
|
||||
text: "ignored".into(),
|
||||
submit: false,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
let error: ApiError = response.json().await.unwrap();
|
||||
assert_eq!(error.error.code, "not_terminal");
|
||||
assert_eq!(error.error.message, "session is not a terminal session");
|
||||
|
||||
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
|
||||
.await
|
||||
.expect_err("structured session must not upgrade to a PTY socket");
|
||||
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
|
||||
panic!("expected an HTTP handshake rejection, got {error}");
|
||||
};
|
||||
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||
let body = response.body().as_deref().expect("ApiError response body");
|
||||
let error: ApiError = serde_json::from_slice(body).unwrap();
|
||||
assert_eq!(error.error.code, "not_terminal");
|
||||
assert_eq!(error.error.message, "session is not a terminal session");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_ws_for_unknown_session_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -707,11 +1445,13 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
||||
let created = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -739,6 +1479,79 @@ 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:
|
||||
/// two clients connected at different times both get their own Snapshot.
|
||||
#[tokio::test]
|
||||
@@ -751,11 +1564,13 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
||||
|
||||
h.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -51,11 +51,13 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||
};
|
||||
manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -64,11 +64,13 @@ impl Fx {
|
||||
|
||||
fn request(&self, command: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: self.tmp.path().to_path_buf(),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -555,3 +557,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
|
||||
Some(&"http://127.0.0.1:7070".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// Two clients attached to one session used to resize the shared window past
|
||||
/// each other on every report they made. Every flip repaints a full-screen
|
||||
/// agent at a new height and orphans the rows it drew at the old one, which
|
||||
/// reads to the operator as a pane that has stopped taking input.
|
||||
#[tokio::test]
|
||||
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
|
||||
let fx = Fx::new();
|
||||
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
let web = fx.manager.attach_viewport();
|
||||
let tui = fx.manager.attach_viewport();
|
||||
|
||||
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
|
||||
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
|
||||
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
|
||||
// redraw loop do. Neither report changes the arbitrated size.
|
||||
for _ in 0..5 {
|
||||
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
|
||||
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
|
||||
}
|
||||
|
||||
let resizes: Vec<_> = fx
|
||||
.fake
|
||||
.calls()
|
||||
.into_iter()
|
||||
.filter_map(|c| match c {
|
||||
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
resizes,
|
||||
vec![(53, 46), (53, 20)],
|
||||
"the window was resized more than the arbitrated size actually changed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The smaller client leaving hands the window back to the one still attached.
|
||||
#[tokio::test]
|
||||
async fn releasing_a_client_resizes_to_what_is_left() {
|
||||
let fx = Fx::new();
|
||||
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
let web = fx.manager.attach_viewport();
|
||||
let tui = fx.manager.attach_viewport();
|
||||
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
|
||||
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
|
||||
|
||||
fx.manager.release_viewport(s.id, tui).await;
|
||||
|
||||
let resizes: Vec<_> = fx
|
||||
.fake
|
||||
.calls()
|
||||
.into_iter()
|
||||
.filter_map(|c| match c {
|
||||
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,13 @@ async fn create_then_kill_round_trip() {
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -63,11 +65,13 @@ async fn create_appends_to_spawn_history() {
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -92,11 +96,13 @@ async fn create_without_history_logs_nothing() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -114,11 +120,13 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -153,6 +161,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// The shell prints the marker before doing anything else and
|
||||
// then sleeps so the session stays alive. Before NAP-253 this
|
||||
@@ -161,6 +170,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -200,6 +210,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// Print a line, then switch to the alternate screen the way a
|
||||
// full-screen program does on startup. Only the second marker is
|
||||
@@ -209,6 +220,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -258,6 +270,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// Alternate screen, then button-event tracking (?1002) with SGR
|
||||
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
||||
@@ -266,6 +279,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -311,11 +325,13 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -373,11 +389,13 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -423,11 +441,13 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -488,6 +508,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -497,6 +518,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
branch: "feat/indicator".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -580,6 +602,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -589,6 +612,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
branch: "feat/keep".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -617,6 +641,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -626,6 +651,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -680,6 +706,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -689,6 +716,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
branch: "feat/pushed".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -729,6 +757,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -738,6 +767,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
||||
branch: "feat/forced".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -774,6 +804,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -783,6 +814,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
branch: "feat/clear".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -814,6 +846,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -823,6 +856,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
branch: "feat/rusty".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -855,6 +889,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -864,6 +899,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
||||
branch: "feat/exited".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -916,6 +952,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -925,6 +962,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
branch: "feat/clean".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -976,11 +1014,13 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1010,6 +1050,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
let first = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -1019,6 +1060,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
branch: "feat/one".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create first");
|
||||
@@ -1027,6 +1069,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
let second = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: inner_worktree.clone(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -1036,6 +1079,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
branch: "feat/two".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create second");
|
||||
@@ -1065,11 +1109,13 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1103,11 +1149,13 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1128,11 +1176,13 @@ async fn set_focused_true_stamps_last_focused_at() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
@@ -1176,11 +1226,13 @@ async fn attaching_stamps_last_focused_at() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -6,8 +6,8 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_protocol::{Session, SessionStatus};
|
||||
use maestro_daemon::sidecar::{PersistedEvent, SidecarStore};
|
||||
use maestro_protocol::{Session, SessionStatus, SessionView, TranscriptRow, TranscriptRowKind};
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
@@ -32,6 +32,8 @@ fn sample(id: Uuid) -> Session {
|
||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
view: SessionView::Terminal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +68,30 @@ fn delete_removes_file_and_is_idempotent() {
|
||||
let s = sample(id);
|
||||
|
||||
store.write(&s).unwrap();
|
||||
store.mark_for_recovery(id).unwrap();
|
||||
assert!(store.path_for(id).exists());
|
||||
assert!(store.recovery_path_for(id).exists());
|
||||
|
||||
store.delete(id).unwrap();
|
||||
assert!(!store.path_for(id).exists());
|
||||
assert!(!store.recovery_path_for(id).exists());
|
||||
|
||||
// second delete is a no-op
|
||||
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]
|
||||
fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -103,6 +120,37 @@ fn load_all_returns_empty_when_directory_missing() {
|
||||
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]
|
||||
fn write_creates_parent_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -113,3 +161,237 @@ fn write_creates_parent_directory() {
|
||||
assert!(nested.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);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
|
||||
"/home/test",
|
||||
maestro_config::SpawnSection::default().resolve().unwrap(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -405,13 +405,17 @@ 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]
|
||||
fn add_fails_when_branch_already_exists() {
|
||||
fn add_new_suffixes_branch_name_on_branch_collision() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// pre-create the branch
|
||||
// pre-create the branch, unused by any worktree.
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
@@ -423,9 +427,17 @@ fn add_fails_when_branch_already_exists() {
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("git exited"), "{msg}");
|
||||
let path = mgr
|
||||
.add_new(&repo, "feat/dup")
|
||||
.expect("sidesteps the branch collision");
|
||||
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`
|
||||
|
||||
@@ -13,6 +13,7 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
futures-util.workspace = true
|
||||
libc.workspace = true
|
||||
maestro-protocol = { workspace = true, features = ["client"] }
|
||||
maestro-testing.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -87,6 +87,14 @@ 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
|
||||
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
|
||||
/// its sessions.
|
||||
|
||||
+213
-25
@@ -15,7 +15,7 @@ use std::time::{Duration, Instant};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_e2e::util::wait_for_async;
|
||||
use maestro_e2e::{Daemon, TestClient};
|
||||
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
|
||||
use maestro_protocol::{CreateSessionRequest, SessionStatus, SessionView, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
@@ -27,14 +27,71 @@ async fn bring_up() -> (Daemon, TestClient) {
|
||||
|
||||
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
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
|
||||
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
|
||||
/// object carries only the fields the lifecycle actually honours.
|
||||
@@ -49,11 +106,13 @@ fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool)
|
||||
/// Spawn request carrying a worktree spec against `base`.
|
||||
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: base.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +202,13 @@ async fn kill_removes_clean_worktree() {
|
||||
init_repo(base.path());
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: base.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
@@ -196,11 +257,13 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
|
||||
.join("unsaved");
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: base.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
@@ -254,11 +317,13 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
||||
run_git(&["branch", "feature/existing"], base.path());
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: base.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||
auto_approve: false,
|
||||
};
|
||||
|
||||
let s = client
|
||||
@@ -323,10 +388,11 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a second spawn onto a branch that already has a worktree is rejected
|
||||
/// with a structured worktree error; the first worktree stays intact.
|
||||
/// #66/#77: a second spawn onto the same branch used to be rejected outright;
|
||||
/// since the branch-collision suffixing landed, it succeeds instead — the
|
||||
/// second worktree lands on `<branch>-2` and the first stays untouched.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn second_spawn_on_same_branch_is_rejected() {
|
||||
async fn second_spawn_on_same_branch_gets_suffixed() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
@@ -336,28 +402,33 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await
|
||||
.expect("first spawn");
|
||||
let dup = client
|
||||
let second = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await;
|
||||
match dup {
|
||||
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
|
||||
assert_eq!(
|
||||
info.code, "worktree_failed",
|
||||
"expected worktree error: {info:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected rejection for duplicate branch, got {other:?}"),
|
||||
}
|
||||
.await
|
||||
.expect("second spawn suffixes instead of rejecting");
|
||||
|
||||
// Exactly one registration, one directory, still usable.
|
||||
// Count branch records only: the worktree path now contains the branch
|
||||
// name too (`worktrees/feature/dup`), so a substring match over every line
|
||||
// would count the same tree twice.
|
||||
let holders = git_worktree_list(base.path())
|
||||
.iter()
|
||||
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
|
||||
.count();
|
||||
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
||||
let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
|
||||
assert_eq!(
|
||||
second.cwd, suffixed,
|
||||
"second worktree must land on the suffixed path"
|
||||
);
|
||||
assert!(suffixed.is_dir());
|
||||
|
||||
// Both registrations exist: the original branch, untouched, and the
|
||||
// suffixed one the second spawn created.
|
||||
let registrations = git_worktree_list(base.path());
|
||||
assert!(
|
||||
registrations
|
||||
.iter()
|
||||
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
|
||||
"original branch must stay registered: {registrations:?}"
|
||||
);
|
||||
assert!(
|
||||
registrations
|
||||
.iter()
|
||||
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
|
||||
"suffixed branch must be registered too: {registrations:?}"
|
||||
);
|
||||
assert!(
|
||||
base.path()
|
||||
.join("worktrees")
|
||||
@@ -368,7 +439,8 @@ async fn second_spawn_on_same_branch_is_rejected() {
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(first.id).await.expect("kill");
|
||||
client.kill_session(first.id).await.expect("kill first");
|
||||
client.kill_session(second.id).await.expect("kill second");
|
||||
}
|
||||
|
||||
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||
@@ -497,6 +569,57 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A random handle can reuse the name of a branch left behind by an earlier,
|
||||
/// already-removed worktree — `git worktree add -b` refuses outright in that
|
||||
/// case, even though the directory is free. The daemon suffixes the branch
|
||||
/// itself (not just the directory) and both land on `-2`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn colliding_branch_name_gets_suffixed_too() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
|
||||
// No worktree directory in the way, but the branch already exists.
|
||||
run_git(&["branch", "handle"], base.path());
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "handle", true))
|
||||
.await
|
||||
.expect("spawn onto a taken branch name");
|
||||
|
||||
let suffixed = base.path().join("worktrees").join("handle-2");
|
||||
assert_eq!(
|
||||
s.cwd, suffixed,
|
||||
"the directory must follow the suffixed branch name"
|
||||
);
|
||||
assert!(suffixed.is_dir());
|
||||
|
||||
let registrations = git_worktree_list(base.path());
|
||||
assert!(
|
||||
registrations.iter().any(|l| l.contains("handle-2")),
|
||||
"the -2 branch's worktree must be registered: {registrations:?}"
|
||||
);
|
||||
|
||||
let branches = String::from_utf8(
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base.path())
|
||||
.args(["branch", "--list"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
branches.contains("handle-2"),
|
||||
"the branch itself must be named handle-2: {branches}"
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #77/#88: the target path holds a plain directory the daemon never made and
|
||||
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
||||
/// beside it and leaves every byte of it alone.
|
||||
@@ -703,11 +826,13 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
||||
|
||||
let _dirty = client
|
||||
.spawn_session(&CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: dirty_repo.path().to_path_buf(),
|
||||
command: "sh -c 'echo unsaved > scratch.txt'".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||
auto_approve: false,
|
||||
})
|
||||
.await
|
||||
.expect("spawn dirty");
|
||||
@@ -751,11 +876,13 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
||||
|
||||
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,6 +1061,67 @@ async fn daemon_restart_recovers_session() {
|
||||
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")]
|
||||
async fn concurrent_spawns_each_get_unique_ids() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
+142
-55
@@ -1,4 +1,4 @@
|
||||
//! Claude Code activity hook (#106).
|
||||
//! Agent activity hook (#106).
|
||||
//!
|
||||
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
|
||||
//! the daemon's first-start auto-install:
|
||||
@@ -6,28 +6,40 @@
|
||||
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
||||
//! posts an [`ActivityState`](maestro-protocol) to
|
||||
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
|
||||
//! global hook runs in every Claude Code session on the machine, so it exits
|
||||
//! global hook runs in every agent session on the machine, so it exits
|
||||
//! 0 and silently when either #103 env var is unset, and it never lets a
|
||||
//! daemon failure escape (short curl timeout, status ignored).
|
||||
//! * [`install`] — idempotent registration of that script in
|
||||
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
|
||||
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
|
||||
//! in the file is preserved verbatim.
|
||||
//! * [`install`] — idempotent registration of that script in one [`Target`]'s
|
||||
//! hook file. Only entries maestro itself wrote (identified by
|
||||
//! [`HOOK_MARKER`]) are touched; every other hook and every other field in
|
||||
//! the file is preserved verbatim.
|
||||
//!
|
||||
//! One script serves every [`Target`]: Claude Code and Codex read the same
|
||||
//! `{"hooks": {Event: [{"hooks": [{"type": "command", "command": …}]}]}}`
|
||||
//! shape and hand the hook the same JSON on stdin. Only the file's location
|
||||
//! and the event set differ.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Marker substring every maestro-installed hook command carries. The
|
||||
/// installer rewrites or removes exactly these entries — nothing else in the
|
||||
/// user's settings file.
|
||||
///
|
||||
/// It still says `claude` although the script now serves every [`Target`]:
|
||||
/// the marker is how an install from an older maestro is recognised, and
|
||||
/// renaming it would orphan every entry already on disk.
|
||||
pub const HOOK_MARKER: &str = "maestro-claude-hook";
|
||||
|
||||
/// File name of the installed script (carries [`HOOK_MARKER`]).
|
||||
/// File name of the installed script (carries [`HOOK_MARKER`]). One script
|
||||
/// for every [`Target`] — see the note on [`HOOK_MARKER`] for the name.
|
||||
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||
|
||||
/// Hook events installed only with the `haiku-autoname` feature (#131).
|
||||
/// `UserPromptSubmit` carries the prompt the user just submitted; the script
|
||||
/// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
|
||||
/// still-provisionally-named session after its first one.
|
||||
/// Hook events installed only with the `haiku-autoname` feature (#131), for
|
||||
/// every [`Target`] — Claude Code and Codex both spell the event
|
||||
/// `UserPromptSubmit` and both put the submitted text in the payload's
|
||||
/// `prompt`. The script forwards the whole payload to
|
||||
/// `/api/sessions/:id/prompt`, which names a still-provisionally-named
|
||||
/// session after its first one.
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")];
|
||||
|
||||
@@ -43,7 +55,7 @@ pub const PROMPT_EVENTS: [(&str, &str); 0] = [];
|
||||
/// Claude Code's own name for "the turn ended on an API error"; there is no
|
||||
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||
/// `Error`.
|
||||
pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
pub const CLAUDE_EVENTS: [(&str, &str); 5] = [
|
||||
// Tool starting: the agent is demonstrably producing work, even if the
|
||||
// tool itself runs for minutes.
|
||||
("PreToolUse", "Active"),
|
||||
@@ -58,17 +70,86 @@ pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
("StopFailure", "Error"),
|
||||
];
|
||||
|
||||
/// Codex hook event → `ActivityState` spelling posted to the daemon.
|
||||
///
|
||||
/// Codex's event set is close to Claude Code's but not identical: a pending
|
||||
/// approval arrives as `PermissionRequest` rather than `Notification`, and
|
||||
/// there is no `StopFailure`, so a codex session never reports `Error`.
|
||||
pub const CODEX_EVENTS: [(&str, &str); 4] = [
|
||||
("PreToolUse", "Active"),
|
||||
("PostToolUse", "Active"),
|
||||
// Codex is blocked on the operator approving a tool call.
|
||||
("PermissionRequest", "AwaitingInput"),
|
||||
("Stop", "AwaitingInput"),
|
||||
];
|
||||
|
||||
/// An agent whose hook file maestro writes into.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// `~/.claude/settings.json`.
|
||||
ClaudeCode,
|
||||
/// `~/.codex/hooks.json`. Codex asks the operator to trust a hook it has
|
||||
/// not seen before, so an install here does nothing until they approve it
|
||||
/// once inside codex — maestro never writes that approval itself.
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// Every target an install covers. Both files are written whether or not
|
||||
/// the agent is on the machine: an unused hook file costs nothing, and
|
||||
/// the alternative is a session spawned after the daemon started missing
|
||||
/// the hook because its agent arrived later.
|
||||
pub const ALL: [Target; 2] = [Target::ClaudeCode, Target::Codex];
|
||||
|
||||
/// How the target is named in logs and CLI output.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Target::ClaudeCode => "claude-code",
|
||||
Target::Codex => "codex",
|
||||
}
|
||||
}
|
||||
|
||||
/// The file this target's hook entries are registered in, under `home`.
|
||||
pub fn settings_path(self, home: &Path) -> std::path::PathBuf {
|
||||
match self {
|
||||
Target::ClaudeCode => home.join(".claude/settings.json"),
|
||||
Target::Codex => home.join(".codex/hooks.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity events this target emits.
|
||||
pub fn activity_events(self) -> &'static [(&'static str, &'static str)] {
|
||||
match self {
|
||||
Target::ClaudeCode => &CLAUDE_EVENTS,
|
||||
Target::Codex => &CODEX_EVENTS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every event installed for this target: its activity events plus, with
|
||||
/// `haiku-autoname` on, the prompt event both agents spell the same way.
|
||||
pub fn events(self) -> impl Iterator<Item = (&'static str, &'static str)> {
|
||||
self.activity_events()
|
||||
.iter()
|
||||
.chain(PROMPT_EVENTS.iter())
|
||||
.copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
||||
# Exits silently unless running inside a session maestro spawned (#103 env).
|
||||
# Installed by maestro (#106): report agent activity to its daemon.
|
||||
# One script for every agent maestro installs hooks into; the state to post
|
||||
# is chosen by the caller, so the two event sets differ only in what is
|
||||
# registered where. Exits silently unless running inside a session maestro
|
||||
# spawned (#103 env).
|
||||
# Event -> ActivityState mapping:
|
||||
# PreToolUse -> Active (tool started; agent is working)
|
||||
# PostToolUse -> Active (tool finished)
|
||||
# Notification -> AwaitingInput (permission / idle notice)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (turn ended on an API error)
|
||||
# Prompt -> the payload is forwarded to the prompt route (#131)
|
||||
# PreToolUse -> Active (tool started; agent is working)
|
||||
# PostToolUse -> Active (tool finished)
|
||||
# Notification -> AwaitingInput (claude code: permission / idle)
|
||||
# PermissionRequest -> AwaitingInput (codex: waiting on an approval)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (claude code: turn ended on an error)
|
||||
# Prompt -> the payload is forwarded to the prompt route (#131)
|
||||
state="$1"
|
||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||
[ -n "$MAESTRO_URL" ] || exit 0
|
||||
@@ -115,34 +196,29 @@ pub fn script_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// The user-level Claude Code settings file for a given home directory.
|
||||
pub fn settings_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".claude/settings.json")
|
||||
}
|
||||
|
||||
/// Whether `settings_path` already carries a maestro hook (#106). Presence
|
||||
/// only: an install from an older release answers `true` even when it is
|
||||
/// missing events this build ships — see [`is_current`].
|
||||
/// Whether the file at `settings_path` already carries a maestro hook
|
||||
/// (#106). Presence only: an install from an older release answers `true`
|
||||
/// even when it is missing events this build ships — see [`is_current`].
|
||||
pub fn is_installed(settings_path: &Path) -> bool {
|
||||
settings_json(settings_path)
|
||||
.map(|root| find_maestro_commands(&root).next().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether what is on disk is what this build ships: the script at
|
||||
/// `script_path` byte-for-byte, and exactly one settings entry per event in
|
||||
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
|
||||
/// Whether what is on disk is what this build ships for `target`: the script
|
||||
/// at `script_path` byte-for-byte, and exactly one settings entry per one of
|
||||
/// the target's [`Target::events`], pointing at that script.
|
||||
///
|
||||
/// This is the upgrade question. A maestro that gains an event — #131 added
|
||||
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
|
||||
/// existing user with a present but incomplete install, which
|
||||
/// [`is_installed`] cannot tell apart from a current one.
|
||||
pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
|
||||
/// This is the upgrade question, asked per [`Target`]. A maestro that gains
|
||||
/// an event — #131 added `UserPromptSubmit` long after #106 had installed
|
||||
/// itself — leaves every existing user with a present but incomplete
|
||||
/// install, which [`is_installed`] cannot tell apart from a current one.
|
||||
pub fn is_current(target: Target, settings_path: &Path, script_path: &Path) -> bool {
|
||||
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
|
||||
return false;
|
||||
}
|
||||
settings_json(settings_path)
|
||||
.map(|root| maestro_entries(&root) == expected_entries(script_path))
|
||||
.map(|root| maestro_entries(&root) == expected_entries(target, script_path))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -173,14 +249,13 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
|
||||
found
|
||||
}
|
||||
|
||||
/// What [`install`] writes for `script_path`, in the same shape.
|
||||
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
|
||||
let mut want: Vec<(String, String)> = HOOK_EVENTS
|
||||
.iter()
|
||||
.chain(PROMPT_EVENTS.iter())
|
||||
/// What [`install`] writes for `target` and `script_path`, in the same shape.
|
||||
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> {
|
||||
let mut want: Vec<(String, String)> = target
|
||||
.events()
|
||||
.map(|(event, state)| {
|
||||
(
|
||||
(*event).to_string(),
|
||||
event.to_string(),
|
||||
format!("{} {state}", script_path.display()),
|
||||
)
|
||||
})
|
||||
@@ -207,28 +282,40 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the full hook setup for `home`: write the script under
|
||||
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
|
||||
/// Install the full hook setup for one target under `home`: write the script
|
||||
/// under `.local/share/maestro/` and register it in the target's hook file.
|
||||
/// Idempotent; returns whether anything changed on disk.
|
||||
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
|
||||
pub fn install_target(target: Target, home: &Path) -> Result<bool, HooksError> {
|
||||
let script = script_path(home);
|
||||
let settings = settings_path(home);
|
||||
let settings = target.settings_path(home);
|
||||
// A script left over from an older release counts as a change even when
|
||||
// the settings entries pointing at it are already right.
|
||||
let changed = !is_current(&settings, &script);
|
||||
let changed = !is_current(target, &settings, &script);
|
||||
write_script(&script)?;
|
||||
install(&settings, &script)?;
|
||||
install(target, &settings, &script)?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
|
||||
/// settings file at `settings_path`. Idempotent: existing maestro entries are
|
||||
/// updated in place wherever they sit, stale ones removed, duplicates folded;
|
||||
/// every non-maestro entry and every unrelated top-level field survives.
|
||||
/// Install for every [`Target::ALL`], reporting each one separately. One
|
||||
/// target failing — an unwritable file, unparseable JSON — never stops the
|
||||
/// others: an agent whose hook could not be written is one agent without
|
||||
/// activity reporting, not a failed install.
|
||||
pub fn install_for_home(home: &Path) -> Vec<(Target, Result<bool, HooksError>)> {
|
||||
Target::ALL
|
||||
.iter()
|
||||
.map(|&target| (target, install_target(target, home)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Register the hook command for every one of `target`'s [`Target::events`]
|
||||
/// in the settings file at `settings_path`. Idempotent: existing maestro
|
||||
/// entries are updated in place wherever they sit, stale ones removed,
|
||||
/// duplicates folded; every non-maestro entry and every unrelated top-level
|
||||
/// field survives.
|
||||
///
|
||||
/// Never inspects or writes anywhere but `settings_path` — callers pass the
|
||||
/// user's real home or a temp one in tests, never a repository.
|
||||
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
|
||||
pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
|
||||
let mut root = settings_json(settings_path)?;
|
||||
|
||||
// Strip every maestro entry wherever it currently sits, so an older
|
||||
@@ -236,7 +323,7 @@ pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksErro
|
||||
strip_maestro_entries(&mut root);
|
||||
|
||||
let hooks = ensure_object(&mut root, "hooks");
|
||||
for (event, state) in HOOK_EVENTS.iter().chain(PROMPT_EVENTS.iter()).copied() {
|
||||
for (event, state) in target.events() {
|
||||
let groups = ensure_array(hooks, event);
|
||||
let mut group = serde_json::Map::new();
|
||||
group.insert(
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
||||
//! and the settings-file installer. Everything happens under a temp `$HOME`;
|
||||
//! the developer's own `~/.claude/settings.json` is never touched.
|
||||
//! the developer's own `~/.claude/settings.json` and `~/.codex/hooks.json`
|
||||
//! are never touched.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// How many commands one install writes: every activity event, plus the
|
||||
/// prompt event when `haiku-autoname` is on (#131).
|
||||
const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
|
||||
/// How many commands one install writes for a target: its activity events,
|
||||
/// plus the prompt event when `haiku-autoname` is on (#131).
|
||||
fn installed_events(target: Target) -> usize {
|
||||
target.events().count()
|
||||
}
|
||||
|
||||
/// The Claude Code hook file under a temp home. Most installer tests only
|
||||
/// need one target, and this is the one they were written against.
|
||||
fn settings_path(home: &Path) -> std::path::PathBuf {
|
||||
Target::ClaudeCode.settings_path(home)
|
||||
}
|
||||
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
@@ -70,7 +79,7 @@ fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a
|
||||
/// exit 0, no output, stdin drained.
|
||||
#[test]
|
||||
fn script_exits_silently_without_maestro_env() {
|
||||
for (event, _) in HOOK_EVENTS {
|
||||
for (event, _) in CLAUDE_EVENTS {
|
||||
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
||||
@@ -103,7 +112,7 @@ fn script_posts_mapped_state_to_the_activity_endpoint() {
|
||||
/// Every event maps to the state the contract specifies.
|
||||
#[test]
|
||||
fn event_state_mapping_matches_the_contract() {
|
||||
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
|
||||
let map: std::collections::HashMap<&str, &str> = CLAUDE_EVENTS.into_iter().collect();
|
||||
assert_eq!(map["PreToolUse"], "Active");
|
||||
assert_eq!(map["PostToolUse"], "Active");
|
||||
assert_eq!(map["Notification"], "AwaitingInput");
|
||||
@@ -203,10 +212,10 @@ fn fresh_install_registers_every_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
write_script(&script_path(home)).unwrap();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings_path(home));
|
||||
assert_eq!(cmds.len(), INSTALLED_EVENTS);
|
||||
assert_eq!(cmds.len(), installed_events(Target::ClaudeCode));
|
||||
let joined = cmds.join("\n");
|
||||
assert_eq!(joined.matches("Active").count(), 2);
|
||||
assert!(joined.contains("Error"));
|
||||
@@ -221,15 +230,15 @@ fn fresh_install_registers_every_event() {
|
||||
fn install_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
let once = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
assert_eq!(once, twice);
|
||||
assert_eq!(
|
||||
installed_commands(&settings_path(home)).len(),
|
||||
INSTALLED_EVENTS
|
||||
installed_events(Target::ClaudeCode)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -257,12 +266,12 @@ fn install_updates_an_older_maestro_hook_in_place() {
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(&settings, &new_script).unwrap();
|
||||
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.len(),
|
||||
INSTALLED_EVENTS,
|
||||
installed_events(Target::ClaudeCode),
|
||||
"old entry replaced, not kept: {cmds:?}"
|
||||
);
|
||||
assert!(!cmds.iter().any(|c| c == old_script));
|
||||
@@ -298,7 +307,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(&settings, &new_script).unwrap();
|
||||
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
@@ -311,7 +320,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||
INSTALLED_EVENTS,
|
||||
installed_events(Target::ClaudeCode),
|
||||
"{cmds:?}"
|
||||
);
|
||||
}
|
||||
@@ -321,9 +330,15 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
fn is_installed_tracks_reality() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!is_installed(&settings_path(home)));
|
||||
install_for_home(home).unwrap();
|
||||
assert!(is_installed(&settings_path(home)));
|
||||
for target in Target::ALL {
|
||||
assert!(!is_installed(&target.settings_path(home)));
|
||||
}
|
||||
for (_, result) in install_for_home(home) {
|
||||
result.unwrap();
|
||||
}
|
||||
for target in Target::ALL {
|
||||
assert!(is_installed(&target.settings_path(home)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The full home-level install leaves the script executable.
|
||||
@@ -331,7 +346,9 @@ fn is_installed_tracks_reality() {
|
||||
fn install_for_home_writes_an_executable_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_for_home(home).unwrap();
|
||||
for (_, result) in install_for_home(home) {
|
||||
result.unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -350,7 +367,7 @@ fn install_for_home_writes_an_executable_script() {
|
||||
fn install_registers_the_prompt_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
||||
@@ -367,7 +384,7 @@ fn install_registers_the_prompt_event() {
|
||||
fn install_registers_no_prompt_event_without_the_feature() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
||||
@@ -436,8 +453,12 @@ fn the_script_posts_a_prompt_payload_unchanged() {
|
||||
fn is_current_rejects_an_install_missing_an_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_for_home(home).unwrap();
|
||||
assert!(is_current(&settings_path(home), &script_path(home)));
|
||||
install_target(Target::ClaudeCode, home).unwrap();
|
||||
assert!(is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
|
||||
// Drop one event, as an older install would have left the file.
|
||||
let mut root: serde_json::Value =
|
||||
@@ -450,7 +471,11 @@ fn is_current_rejects_an_install_missing_an_event() {
|
||||
.unwrap();
|
||||
|
||||
assert!(is_installed(&settings_path(home)));
|
||||
assert!(!is_current(&settings_path(home), &script_path(home)));
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
}
|
||||
|
||||
/// A settings file that is complete but points at a script from an older
|
||||
@@ -459,9 +484,13 @@ fn is_current_rejects_an_install_missing_an_event() {
|
||||
fn is_current_rejects_a_stale_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_for_home(home).unwrap();
|
||||
install_target(Target::ClaudeCode, home).unwrap();
|
||||
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
assert!(!is_current(&settings_path(home), &script_path(home)));
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
}
|
||||
|
||||
/// Nothing installed at all is not current.
|
||||
@@ -469,18 +498,93 @@ fn is_current_rejects_a_stale_script() {
|
||||
fn is_current_rejects_a_missing_install() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!is_current(&settings_path(home), &script_path(home)));
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
}
|
||||
|
||||
/// `install_for_home` reports a stale script as a change, even when the
|
||||
/// `install_target` reports a stale script as a change, even when the
|
||||
/// settings entries pointing at it already read correctly.
|
||||
#[test]
|
||||
fn install_for_home_reports_a_stale_script_as_a_change() {
|
||||
fn install_target_reports_a_stale_script_as_a_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(install_for_home(home).unwrap());
|
||||
assert!(!install_for_home(home).unwrap());
|
||||
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
||||
assert!(!install_target(Target::ClaudeCode, home).unwrap());
|
||||
|
||||
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
assert!(install_for_home(home).unwrap());
|
||||
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
||||
}
|
||||
|
||||
/// Codex is hooked in its own file, with its own event set: the two agents
|
||||
/// share the script but not the events, so neither file carries the other's
|
||||
/// spellings (#131 follow-up — a codex session was never named because
|
||||
/// nothing installed its `UserPromptSubmit` hook).
|
||||
#[test]
|
||||
fn each_target_gets_its_own_file_and_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
for (_, result) in install_for_home(home) {
|
||||
result.unwrap();
|
||||
}
|
||||
|
||||
let claude = events_in(&Target::ClaudeCode.settings_path(home));
|
||||
let codex = events_in(&Target::Codex.settings_path(home));
|
||||
assert_eq!(
|
||||
Target::Codex.settings_path(home),
|
||||
home.join(".codex/hooks.json")
|
||||
);
|
||||
|
||||
// Claude Code's failure signal has no codex equivalent, and codex's
|
||||
// approval notice is spelled differently.
|
||||
assert!(claude.contains(&"StopFailure".to_string()));
|
||||
assert!(claude.contains(&"Notification".to_string()));
|
||||
assert!(!codex.contains(&"StopFailure".to_string()));
|
||||
assert!(!codex.contains(&"Notification".to_string()));
|
||||
assert!(codex.contains(&"PermissionRequest".to_string()));
|
||||
|
||||
// Both report work and both report a finished turn.
|
||||
for event in ["PreToolUse", "PostToolUse", "Stop"] {
|
||||
assert!(claude.contains(&event.to_string()), "claude {event}");
|
||||
assert!(codex.contains(&event.to_string()), "codex {event}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
installed_commands(&Target::Codex.settings_path(home)).len(),
|
||||
installed_events(Target::Codex)
|
||||
);
|
||||
}
|
||||
|
||||
/// #131: a codex session names itself the same way a Claude Code one does —
|
||||
/// same event spelling, same script, same `Prompt` argument.
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
#[test]
|
||||
fn codex_reports_its_first_prompt_too() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_target(Target::Codex, home).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(Target::Codex.settings_path(home)).unwrap())
|
||||
.unwrap();
|
||||
let cmd = root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(cmd.ends_with(" Prompt"), "{cmd}");
|
||||
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
||||
}
|
||||
|
||||
/// Every event name a settings file registers a maestro hook under.
|
||||
fn events_in(settings: &Path) -> Vec<String> {
|
||||
serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(settings).unwrap())
|
||||
.unwrap()
|
||||
.get("hooks")
|
||||
.and_then(|h| h.as_object())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(event, _)| event.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::routes::{self, UrlError};
|
||||
use crate::{
|
||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||
SetFocusRequest,
|
||||
SetFocusRequest, StructuredApprovalDecision, StructuredPromptRequest,
|
||||
};
|
||||
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
@@ -105,6 +105,11 @@ impl DaemonClient {
|
||||
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 --------------------------------------------------------
|
||||
|
||||
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
||||
@@ -179,6 +184,57 @@ impl DaemonClient {
|
||||
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> {
|
||||
self.get(routes::INFO, &[]).await
|
||||
}
|
||||
|
||||
@@ -166,54 +166,547 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
||||
cur
|
||||
}
|
||||
|
||||
/// Command wrappers that run another program: the program is what follows.
|
||||
const WRAPPERS: [&str; 8] = [
|
||||
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
|
||||
];
|
||||
|
||||
/// The program a command line runs, basenamed and stripped of its arguments —
|
||||
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
||||
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
||||
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
|
||||
/// `npx`, `pnpm dlx`, …) are looked through, and quoting/escaping is honoured
|
||||
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
|
||||
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
|
||||
/// substitution, an unterminated quote) falls back to the naive first word
|
||||
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
|
||||
pub fn command_program(command: &str) -> String {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
match resolve(command) {
|
||||
Some(r) => match r.words.get(r.program_index) {
|
||||
Some(word) => basename(word).to_string(),
|
||||
// Nothing but wrappers and assignments: say what was there
|
||||
// rather than rendering an empty fragment.
|
||||
None => command.trim().to_string(),
|
||||
},
|
||||
None => legacy_first_word(command),
|
||||
}
|
||||
}
|
||||
|
||||
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
||||
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
||||
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
||||
/// `claude-code-acp` — the daemon's actual structured-session child — is
|
||||
/// deliberately absent: the daemon launches it directly and ignores
|
||||
/// `req.command`, so it is never itself a valid user-facing launch form.
|
||||
const BY_PROGRAM: [(&str, &str); 17] = [
|
||||
("claude", "claude"),
|
||||
("claude-code", "claude"),
|
||||
("codex", "codex"),
|
||||
("opencode", "opencode"),
|
||||
("gemini", "gemini"),
|
||||
("aider", "aider"),
|
||||
("cursor", "cursor"),
|
||||
("cursor-agent", "cursor"),
|
||||
("pi", "pi"),
|
||||
("bash", "shell"),
|
||||
("zsh", "shell"),
|
||||
("sh", "shell"),
|
||||
("fish", "shell"),
|
||||
("dash", "shell"),
|
||||
("ksh", "shell"),
|
||||
("nu", "shell"),
|
||||
("xonsh", "shell"),
|
||||
];
|
||||
|
||||
/// 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
|
||||
/// — including a command line [`resolve`] would only guess at. The command
|
||||
/// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
|
||||
/// tools the web's `splitCommand().tool` does.
|
||||
pub fn command_tool(command: &str) -> Option<&'static str> {
|
||||
let r = resolve(command)?;
|
||||
let program = basename(r.words.get(r.program_index)?).to_lowercase();
|
||||
BY_PROGRAM
|
||||
.iter()
|
||||
.find(|(name, _)| *name == program)
|
||||
.map(|(_, tool)| *tool)
|
||||
}
|
||||
|
||||
/// Whether a command line resolves to an agent Maestro can run as a
|
||||
/// structured (ACP) session — Claude Code or Codex, the two adapters
|
||||
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
|
||||
/// control on this, and the daemon rejects a `Structured` request for
|
||||
/// anything else, so the answer has to come from one place.
|
||||
pub fn supports_structured_view(command: &str) -> bool {
|
||||
matches!(command_tool(command), Some("claude" | "codex"))
|
||||
}
|
||||
|
||||
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
||||
/// of the word that is the actual program (past every assignment/wrapper it
|
||||
/// resolved through).
|
||||
struct Resolution {
|
||||
words: Vec<String>,
|
||||
program_index: usize,
|
||||
}
|
||||
|
||||
/// Tokenize `command` and walk past leading `FOO=bar` assignments and known
|
||||
/// wrapper forms (interleaved in any order/count) to find the program. An
|
||||
/// assignment run is only consumed at the very start of the line or right
|
||||
/// after a wrapper whose own semantics accept one there ([`Wrapper::accepts_assignment`]) —
|
||||
/// `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
|
||||
/// skipping to `claude`, since `command`/`nohup` just exec their next argument
|
||||
/// as the program, they don't setenv from an assignment-shaped one (#199).
|
||||
/// `None` when [`tokenize`] can't safely make sense of the line at all.
|
||||
fn resolve(command: &str) -> Option<Resolution> {
|
||||
let words = tokenize(command)?;
|
||||
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
|
||||
let mut i = 0;
|
||||
while i < words.len() {
|
||||
let word = words[i];
|
||||
if is_env_assignment(word) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let wrapper = basename(word).to_lowercase();
|
||||
if WRAPPERS.contains(&wrapper.as_str()) {
|
||||
i += 1;
|
||||
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
|
||||
let mut assignment_allowed = true;
|
||||
loop {
|
||||
if assignment_allowed {
|
||||
while matches!(words.get(i), Some(word) if is_env_assignment(word)) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
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,
|
||||
}
|
||||
}
|
||||
match words.get(i) {
|
||||
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(),
|
||||
// Nothing but wrappers and assignments: say what was there rather than
|
||||
// rendering an empty fragment.
|
||||
None => command.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||
fn is_env_assignment(word: &str) -> bool {
|
||||
let mut chars = word.chars();
|
||||
/// One token from [`tokenize`]: its dequoted text, and — when both the name
|
||||
/// and `=` were entirely unquoted/unescaped in the source — the byte length
|
||||
/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
|
||||
/// makes the word an executable named `FOO=1`; quoting/escaping only the value
|
||||
/// preserves the assignment, matching Bash's assignment-word grammar (#199).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Word {
|
||||
text: String,
|
||||
plain_eq: Option<usize>,
|
||||
}
|
||||
|
||||
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
|
||||
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
|
||||
/// `None` the moment it needs real shell grammar this resolver doesn't
|
||||
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
|
||||
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
|
||||
/// separator, like `;` — #199), or an unterminated quote/escape. Never
|
||||
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
|
||||
/// backslash immediately before a newline, quoted or not, is a line
|
||||
/// continuation: removed entirely rather than embedding the newline or
|
||||
/// ending the word (#199).
|
||||
fn tokenize(command: &str) -> Option<Vec<Word>> {
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut has_current = false;
|
||||
let mut plain_eq: Option<usize> = None;
|
||||
let mut assignment_name_is_plain = true;
|
||||
let mut chars = command.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
' ' | '\t' | '\r' => {
|
||||
if has_current {
|
||||
words.push(Word {
|
||||
text: std::mem::take(&mut current),
|
||||
plain_eq: plain_eq.take(),
|
||||
});
|
||||
has_current = false;
|
||||
assignment_name_is_plain = true;
|
||||
}
|
||||
}
|
||||
'\n' => return None,
|
||||
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
|
||||
'$' if chars.peek() == Some(&'(') => return None,
|
||||
'\'' => {
|
||||
has_current = true;
|
||||
if plain_eq.is_none() {
|
||||
assignment_name_is_plain = false;
|
||||
}
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('\'') => break,
|
||||
Some(c2) => current.push(c2),
|
||||
None => return None, // unterminated single quote
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
has_current = true;
|
||||
if plain_eq.is_none() {
|
||||
assignment_name_is_plain = false;
|
||||
}
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('"') => break,
|
||||
Some('\\') => match chars.next() {
|
||||
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
|
||||
Some('\n') => {} // line continuation, removed entirely
|
||||
Some(c2) => {
|
||||
current.push('\\');
|
||||
current.push(c2);
|
||||
}
|
||||
None => return None, // trailing backslash
|
||||
},
|
||||
Some(c2) => current.push(c2),
|
||||
None => return None, // unterminated double quote
|
||||
}
|
||||
}
|
||||
}
|
||||
'\\' => match chars.next() {
|
||||
Some('\n') => {} // line continuation, removed entirely
|
||||
Some(c2) => {
|
||||
has_current = true;
|
||||
if plain_eq.is_none() {
|
||||
assignment_name_is_plain = false;
|
||||
}
|
||||
current.push(c2);
|
||||
}
|
||||
None => return None, // trailing backslash
|
||||
},
|
||||
other => {
|
||||
has_current = true;
|
||||
if other == '=' && plain_eq.is_none() && assignment_name_is_plain {
|
||||
plain_eq = Some(current.len());
|
||||
}
|
||||
current.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_current {
|
||||
words.push(Word {
|
||||
text: current,
|
||||
plain_eq,
|
||||
});
|
||||
}
|
||||
Some(words)
|
||||
}
|
||||
|
||||
/// A wrapper's known `-`/`--` options: which take no value, and which
|
||||
/// consume either a following word or a `--flag=value` as theirs. A
|
||||
/// `-`-prefixed word this table doesn't know halts resolution rather than
|
||||
/// guessing past it (#199) — callers read that as [`OptionTable::consume`]
|
||||
/// returning `None`.
|
||||
struct OptionTable {
|
||||
flags: &'static [&'static str],
|
||||
valued: &'static [&'static str],
|
||||
/// Whether this wrapper's short options may be combined (`-iv`) or take
|
||||
/// an attached value (`-uroot`), the way real getopt-based tools do
|
||||
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
|
||||
/// `dlx`) don't get this — guessing at bundling they don't actually
|
||||
/// support would resolve through options they'd reject outright.
|
||||
bundles_short_options: bool,
|
||||
}
|
||||
|
||||
impl OptionTable {
|
||||
/// Consume this table's options starting at `words[i]`, returning the
|
||||
/// index just past them (which may equal `i` if there are none) — or
|
||||
/// `None` on an option this table doesn't recognise, or a valued option
|
||||
/// missing its operand.
|
||||
fn consume(&self, words: &[&str], mut i: usize) -> Option<usize> {
|
||||
loop {
|
||||
let Some(&word) = words.get(i) else {
|
||||
return Some(i);
|
||||
};
|
||||
if word == "--" {
|
||||
return Some(i + 1);
|
||||
}
|
||||
if word == "-" || !word.starts_with('-') {
|
||||
return Some(i);
|
||||
}
|
||||
if let Some((flag, value)) = word.split_once('=') {
|
||||
return if self.valued.contains(&flag) && !value.is_empty() {
|
||||
i += 1;
|
||||
continue;
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
if self.flags.contains(&word) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if self.valued.contains(&word) {
|
||||
let value = words.get(i + 1)?;
|
||||
if value.is_empty() || value.starts_with('-') {
|
||||
return None;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if self.bundles_short_options
|
||||
&& !word.starts_with("--")
|
||||
&& word.len() > 2
|
||||
&& self.matches_short_cluster(word)
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
|
||||
/// option with its value attached) — getopt's traditional short-option
|
||||
/// forms. `false` the moment a character isn't a short option this
|
||||
/// table knows, or a valued one ends the word with no attached value
|
||||
/// left to be its operand (#199: rejected rather than guessed to come
|
||||
/// from the next word instead).
|
||||
fn matches_short_cluster(&self, word: &str) -> bool {
|
||||
if !word.is_ascii() {
|
||||
return false;
|
||||
}
|
||||
let bytes = word.as_bytes();
|
||||
let mut buf = [b'-', 0];
|
||||
let mut idx = 1;
|
||||
while idx < bytes.len() {
|
||||
buf[1] = bytes[idx];
|
||||
let short = std::str::from_utf8(&buf).expect("ascii buffer");
|
||||
if self.flags.contains(&short) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if self.valued.contains(&short) {
|
||||
return idx + 1 < bytes.len();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
const NO_OPTIONS: OptionTable = OptionTable {
|
||||
flags: &[],
|
||||
valued: &[],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const ENV_OPTS: OptionTable = OptionTable {
|
||||
flags: &[
|
||||
"-i",
|
||||
"--ignore-environment",
|
||||
"-0",
|
||||
"--null",
|
||||
"-v",
|
||||
"--debug",
|
||||
],
|
||||
valued: &["-u", "--unset", "-C", "--chdir"],
|
||||
bundles_short_options: true,
|
||||
};
|
||||
|
||||
const SUDO_OPTS: OptionTable = OptionTable {
|
||||
flags: &[
|
||||
"-i",
|
||||
"--login",
|
||||
"-n",
|
||||
"--non-interactive",
|
||||
"-E",
|
||||
"--preserve-env",
|
||||
"-H",
|
||||
"--set-home",
|
||||
"-A",
|
||||
"--askpass",
|
||||
"-b",
|
||||
"--background",
|
||||
"-k",
|
||||
"--reset-timestamp",
|
||||
"-K",
|
||||
"--remove-timestamp",
|
||||
"-S",
|
||||
"--stdin",
|
||||
],
|
||||
valued: &["-u", "--user", "-g", "--group"],
|
||||
bundles_short_options: true,
|
||||
};
|
||||
|
||||
/// `-v`/`-V` only print what would run — they never execute it — so
|
||||
/// resolving through them would claim a program runs that `command` never
|
||||
/// launches (#199). Only `-p`, which still executes normally, is safe to
|
||||
/// look through.
|
||||
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
|
||||
flags: &["-p"],
|
||||
valued: &[],
|
||||
bundles_short_options: true,
|
||||
};
|
||||
|
||||
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
|
||||
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
|
||||
/// `/usr/bin/time`, a different program a bare `time` word doesn't
|
||||
/// necessarily name. Assuming them here would be guessing which `time`
|
||||
/// resolves (#199).
|
||||
fn resolve_time(words: &[&str], i: usize) -> Option<usize> {
|
||||
let mut next = i + 1;
|
||||
if words.get(next) == Some(&"-p") {
|
||||
next += 1;
|
||||
}
|
||||
if words.get(next) == Some(&"--") {
|
||||
next += 1;
|
||||
}
|
||||
match words.get(next) {
|
||||
Some(word) if word.starts_with('-') => None,
|
||||
_ => Some(next),
|
||||
}
|
||||
}
|
||||
|
||||
/// `-c`/`--call` embeds a whole shell command string rather than naming the
|
||||
/// program positionally — resolving past it would mean guessing at a
|
||||
/// program buried inside that string, so it's left unrecognised and
|
||||
/// resolution stops at `npx` (#199).
|
||||
const NPX_OPTS: OptionTable = OptionTable {
|
||||
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
|
||||
valued: &["-p", "--package", "--shell"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const PNPM_OPTS: OptionTable = OptionTable {
|
||||
flags: &[
|
||||
"--silent",
|
||||
"-s",
|
||||
"--stream",
|
||||
"--aggregate-output",
|
||||
"--no-progress",
|
||||
],
|
||||
// `--package`/`--package=value` is documented in this pre-`dlx`
|
||||
// position (`pnpm --package=x dlx y`), alongside the identical flag
|
||||
// `dlx` itself also accepts (`PNPM_DLX_OPTS`).
|
||||
valued: &["-C", "--dir", "--filter", "--package"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const YARN_OPTS: OptionTable = OptionTable {
|
||||
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
|
||||
valued: &["--cwd"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const PNPM_DLX_OPTS: OptionTable = OptionTable {
|
||||
flags: &["-s", "--silent"],
|
||||
valued: &["--package"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const YARN_DLX_OPTS: OptionTable = OptionTable {
|
||||
flags: &["-q", "--quiet"],
|
||||
valued: &["-p", "--package"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
const BUN_X_OPTS: OptionTable = OptionTable {
|
||||
flags: &["--bun", "--no-install", "--verbose", "--silent"],
|
||||
valued: &["-p", "--package"],
|
||||
bundles_short_options: false,
|
||||
};
|
||||
|
||||
/// A resolver for one wrapper form: given the tokenized command and the
|
||||
/// index of the wrapper word itself, the index of the word after it (past
|
||||
/// its options and any subcommand it requires), or `None` if this occurrence
|
||||
/// isn't a form we resolve through.
|
||||
type WrapperResolver = fn(&[&str], usize) -> Option<usize>;
|
||||
|
||||
/// One recognised wrapper form: how to walk past it, and whether an
|
||||
/// assignment word is a valid follow-on once it's been walked past.
|
||||
struct Wrapper {
|
||||
resolve: WrapperResolver,
|
||||
/// `true` only for wrappers that themselves interpret a following
|
||||
/// `FOO=bar` as an environment assignment for the command they launch
|
||||
/// (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
|
||||
/// grammar for a brand new simple command (`time`). Every other wrapper
|
||||
/// here is a plain executable that execs its next argument as the
|
||||
/// program outright — an assignment-shaped one names a program literally
|
||||
/// called `FOO=1`, not an env var (#199).
|
||||
accepts_assignment: bool,
|
||||
}
|
||||
|
||||
fn wrapper_resolver(name: &str) -> Option<Wrapper> {
|
||||
let (resolve, accepts_assignment): (WrapperResolver, bool) = match name {
|
||||
"env" => (|words, i| ENV_OPTS.consume(words, i + 1), true),
|
||||
"sudo" => (|words, i| SUDO_OPTS.consume(words, i + 1), true),
|
||||
"command" => (|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1), false),
|
||||
"nohup" => (|words, i| NO_OPTIONS.consume(words, i + 1), false),
|
||||
"time" => (resolve_time, true),
|
||||
"npx" => (|words, i| NPX_OPTS.consume(words, i + 1), false),
|
||||
"pnpm" => (
|
||||
|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i),
|
||||
false,
|
||||
),
|
||||
"yarn" => (
|
||||
|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i),
|
||||
false,
|
||||
),
|
||||
"bun" => (resolve_bun, false),
|
||||
"bunx" => (|words, i| BUN_X_OPTS.consume(words, i + 1), false),
|
||||
"uvx" => (|_words, i| Some(i + 1), false),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Wrapper {
|
||||
resolve,
|
||||
accepts_assignment,
|
||||
})
|
||||
}
|
||||
|
||||
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
|
||||
/// — without it, the next word is a `package.json` script name, not a
|
||||
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
|
||||
/// itself takes launcher-specific options too, consumed before the executable.
|
||||
fn resolve_dlx(
|
||||
opts: &OptionTable,
|
||||
dlx_opts: &OptionTable,
|
||||
words: &[&str],
|
||||
i: usize,
|
||||
) -> Option<usize> {
|
||||
let j = opts.consume(words, i + 1)?;
|
||||
if words.get(j) != Some(&"dlx") {
|
||||
return None;
|
||||
}
|
||||
dlx_opts.consume(words, j + 1)
|
||||
}
|
||||
|
||||
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
|
||||
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
|
||||
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
|
||||
/// the executable.
|
||||
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
|
||||
if words.get(i + 1) != Some(&"x") {
|
||||
return None;
|
||||
}
|
||||
BUN_X_OPTS.consume(words, i + 2)
|
||||
}
|
||||
|
||||
/// `FOO=bar`, the shell's own prefix for one command's environment —
|
||||
/// recognised only when the `=` came from plain, unquoted/unescaped source
|
||||
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
|
||||
/// an assignment.
|
||||
fn is_env_assignment(word: &Word) -> bool {
|
||||
let Some(eq) = word.plain_eq else {
|
||||
return false;
|
||||
};
|
||||
let name = &word.text[..eq];
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
let rest: String = chars.collect();
|
||||
match rest.split_once('=') {
|
||||
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
None => false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
fn basename(word: &str) -> &str {
|
||||
@@ -233,6 +726,7 @@ mod tests {
|
||||
|
||||
fn session(cwd: &str) -> Session {
|
||||
Session {
|
||||
view: Default::default(),
|
||||
id: Uuid::from_u128(1),
|
||||
cwd: PathBuf::from(cwd),
|
||||
command: "bash".into(),
|
||||
@@ -249,6 +743,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,20 +955,132 @@ mod tests {
|
||||
"claude"
|
||||
);
|
||||
assert_eq!(command_program(" zsh -l "), "zsh");
|
||||
assert_eq!(command_program("hx src/main.rs"), "hx");
|
||||
}
|
||||
|
||||
/// The #199 resolver contract: every case in the shared parity fixture,
|
||||
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
|
||||
/// resolver regression in either language fails its own suite.
|
||||
#[test]
|
||||
fn resolves_the_shared_command_resolution_fixture() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Case {
|
||||
command: String,
|
||||
program: String,
|
||||
tool: Option<String>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Fixture {
|
||||
cases: Vec<Case>,
|
||||
}
|
||||
let raw = include_str!("../../../fixtures/command-resolution.json");
|
||||
let fixture: Fixture = serde_json::from_str(raw).expect("valid fixture JSON");
|
||||
assert!(!fixture.cases.is_empty());
|
||||
for case in &fixture.cases {
|
||||
assert_eq!(
|
||||
command_program(&case.command),
|
||||
case.program,
|
||||
"program for {:?}",
|
||||
case.command
|
||||
);
|
||||
assert_eq!(
|
||||
command_tool(&case.command),
|
||||
case.tool.as_deref(),
|
||||
"tool for {:?}",
|
||||
case.command
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn word(text: &str) -> Word {
|
||||
Word {
|
||||
text: text.into(),
|
||||
plain_eq: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
|
||||
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
|
||||
#[test]
|
||||
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
|
||||
assert_eq!(
|
||||
tokenize("claude --resume"),
|
||||
Some(vec![word("claude"), word("--resume")])
|
||||
);
|
||||
assert_eq!(
|
||||
tokenize("\"/opt/my agent/claude\" --resume"),
|
||||
Some(vec![word("/opt/my agent/claude"), word("--resume")])
|
||||
);
|
||||
assert_eq!(
|
||||
tokenize("FOO=\"bar baz\" claude"),
|
||||
Some(vec![
|
||||
Word {
|
||||
text: "FOO=bar baz".into(),
|
||||
plain_eq: Some(3),
|
||||
},
|
||||
word("claude"),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
tokenize("claude --name=$HOME"),
|
||||
Some(vec![
|
||||
word("claude"),
|
||||
Word {
|
||||
text: "--name=$HOME".into(),
|
||||
plain_eq: Some(6),
|
||||
},
|
||||
]),
|
||||
"a bare $ is kept literal, never expanded"
|
||||
);
|
||||
}
|
||||
|
||||
/// Real shell grammar this resolver doesn't attempt — pipelines, list
|
||||
/// operators, subshells/substitution, an unescaped newline, an
|
||||
/// unterminated quote — is rejected outright rather than guessed
|
||||
/// through (#199).
|
||||
#[test]
|
||||
fn tokenize_rejects_ambiguous_shell_grammar() {
|
||||
for command in [
|
||||
"claude | tee log.txt",
|
||||
"claude; echo done",
|
||||
"claude && echo done",
|
||||
"echo hi $(claude)",
|
||||
"claude \"unterminated",
|
||||
"claude 'unterminated",
|
||||
"claude \\",
|
||||
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
|
||||
] {
|
||||
assert_eq!(tokenize(command), None, "{command:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A backslash immediately before a newline is a line continuation:
|
||||
/// removed entirely, joining what it split rather than embedding a
|
||||
/// literal newline or ending the command (#199).
|
||||
#[test]
|
||||
fn tokenize_treats_backslash_newline_as_a_continuation() {
|
||||
assert_eq!(
|
||||
tokenize("clau\\\nde --resume"),
|
||||
Some(vec![word("claude"), word("--resume")])
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
|
||||
/// quoting or escaping the `=` itself disqualifies the word, even
|
||||
/// though the dequoted text looks identical (#199).
|
||||
#[test]
|
||||
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
|
||||
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
|
||||
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
||||
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_program_looks_through_paths_env_and_wrappers() {
|
||||
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
|
||||
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
|
||||
assert_eq!(command_program("npx opencode"), "opencode");
|
||||
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_program_keeps_a_line_that_is_only_wrappers() {
|
||||
assert_eq!(command_program("sudo"), "sudo");
|
||||
assert_eq!(command_program(""), "");
|
||||
fn structured_view_support_follows_the_resolved_program() {
|
||||
assert!(supports_structured_view("claude --resume"));
|
||||
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
|
||||
assert!(supports_structured_view("npx claude-code"));
|
||||
assert!(!supports_structured_view("opencode"));
|
||||
assert!(!supports_structured_view("bash -l"));
|
||||
assert!(!supports_structured_view("claude-code-acp"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,33 @@ pub struct Session {
|
||||
/// sorts a never-focused session after every session that has one.
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -129,9 +156,9 @@ pub enum SessionStatus {
|
||||
Dead,
|
||||
}
|
||||
|
||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
||||
/// inspection — never by parsing the terminal byte stream.
|
||||
/// Session activity, orthogonal to [`SessionStatus`] (NAP-340). Terminal
|
||||
/// sessions use pane/process inference and agent reports; structured sessions
|
||||
/// use their ACP turn lifecycle (docs/SIDEBAR.md).
|
||||
///
|
||||
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
||||
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
||||
@@ -139,16 +166,19 @@ pub enum SessionStatus {
|
||||
/// forgiven.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||
pub enum ActivityState {
|
||||
/// Producing output, or the foreground process is busy.
|
||||
/// Producing output, the foreground process is busy, or an ACP turn is active.
|
||||
Active,
|
||||
/// Foreground process alive but quiescent. The forward-compatible default.
|
||||
#[default]
|
||||
Idle,
|
||||
/// Blocked on a `read()` from the pty — waiting on the user.
|
||||
/// Blocked on terminal input or waiting for an ACP approval.
|
||||
AwaitingInput,
|
||||
/// The agent running in the session reported a failure (#102). Never
|
||||
/// inferred by the daemon; only set when an agent reports it (#105).
|
||||
/// An agent reported a failure, or a structured session/turn failed.
|
||||
Error,
|
||||
/// The ACP adapter is booting; the session is registered but cannot accept
|
||||
/// prompts yet. Older clients that do not know this variant degrade it to
|
||||
/// `Idle` via the lenient deserializer — acceptable.
|
||||
Starting,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ActivityState {
|
||||
@@ -161,6 +191,7 @@ impl<'de> Deserialize<'de> for ActivityState {
|
||||
"Active" => Self::Active,
|
||||
"AwaitingInput" => Self::AwaitingInput,
|
||||
"Error" => Self::Error,
|
||||
"Starting" => Self::Starting,
|
||||
// Known or not, any other spelling degrades to the neutral default
|
||||
_ => Self::Idle,
|
||||
})
|
||||
@@ -199,9 +230,10 @@ where
|
||||
"Idle" => Ok(ActivityState::Idle),
|
||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||
"Error" => Ok(ActivityState::Error),
|
||||
"Starting" => Ok(ActivityState::Starting),
|
||||
other => Err(D::Error::unknown_variant(
|
||||
other,
|
||||
&["Active", "Idle", "AwaitingInput", "Error"],
|
||||
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -352,6 +384,15 @@ pub struct CreateSessionRequest {
|
||||
pub metadata: HashMap<String, Value>,
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -491,6 +532,11 @@ pub struct SpawnPreset {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
/// When `true`, the daemon auto-approves every ACP permission request
|
||||
/// for structured sessions spawned from this preset instead of surfacing
|
||||
/// them to the client.
|
||||
#[serde(default)]
|
||||
pub auto_approve: bool,
|
||||
}
|
||||
|
||||
/// Build provenance of the running daemon: the commit it was compiled
|
||||
@@ -554,6 +600,12 @@ pub struct DaemonInfo {
|
||||
/// network blip, even when the build did not change.
|
||||
#[serde(default)]
|
||||
pub instance: String,
|
||||
/// Canonical leader key the operator configured (`[tui].leader`, default
|
||||
/// `ctrl-a`), served so the web client binds the same chord the TUI does
|
||||
/// instead of its own hardcoded constant (#159). Empty from a daemon too
|
||||
/// old to serve it, which clients read as "use your default".
|
||||
#[serde(default)]
|
||||
pub leader: String,
|
||||
}
|
||||
|
||||
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||
@@ -717,6 +769,150 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -856,6 +1052,8 @@ mod tests {
|
||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
view: SessionView::Terminal,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,6 +1265,8 @@ mod tests {
|
||||
branch: "feat/foo".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
view: SessionView::Terminal,
|
||||
auto_approve: false,
|
||||
};
|
||||
assert_eq!(req, round_trip(&req));
|
||||
}
|
||||
@@ -1081,6 +1281,74 @@ mod tests {
|
||||
assert!(req.worktree.is_none());
|
||||
assert!(req.env.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
|
||||
|
||||
@@ -71,6 +71,41 @@ pub fn attach(id: Uuid) -> String {
|
||||
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
|
||||
/// registers the same paths [`session`] and [`attach`] build.
|
||||
pub mod patterns {
|
||||
@@ -80,6 +115,13 @@ pub mod patterns {
|
||||
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
|
||||
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
|
||||
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.
|
||||
@@ -129,6 +171,11 @@ pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -178,6 +225,10 @@ mod tests {
|
||||
events_url("http://host:7070/").unwrap(),
|
||||
"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]
|
||||
@@ -206,6 +257,22 @@ mod tests {
|
||||
attach(id()),
|
||||
"/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!(
|
||||
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
||||
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
@@ -240,6 +307,22 @@ mod tests {
|
||||
patterns::ATTACH.trim_end_matches(":id"),
|
||||
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]
|
||||
@@ -258,5 +341,9 @@ mod tests {
|
||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {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,7 +22,8 @@ use chrono::Utc;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
||||
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
@@ -31,6 +32,7 @@ use uuid::Uuid;
|
||||
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
|
||||
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
Session {
|
||||
view: Default::default(),
|
||||
id,
|
||||
cwd: cwd.into(),
|
||||
command: command.into(),
|
||||
@@ -47,6 +49,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +61,8 @@ struct Inner {
|
||||
forced_error: Option<(u16, ApiError)>,
|
||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||
/// Every ACP prompt sent to a structured session, in order.
|
||||
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -90,6 +95,10 @@ impl MockDaemon {
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_PROMPT,
|
||||
post(structured_prompt),
|
||||
)
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
@@ -176,6 +185,16 @@ impl MockDaemon {
|
||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||
}
|
||||
|
||||
/// Every ACP prompt this daemon received, oldest first.
|
||||
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
|
||||
self.state
|
||||
.inner
|
||||
.lock()
|
||||
.expect("poisoned")
|
||||
.structured_prompts
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||
}
|
||||
@@ -264,6 +283,26 @@ async fn send_input(
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn structured_prompt(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<StructuredPromptRequest>,
|
||||
) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
}
|
||||
let mut guard = s.inner.lock().expect("poisoned");
|
||||
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
guard.structured_prompts.push((id, req));
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
|
||||
@@ -21,6 +21,7 @@ thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
tracing.workspace = true
|
||||
unicode-width.workspace = true
|
||||
uuid.workspace = true
|
||||
vt100.workspace = true
|
||||
|
||||
|
||||
@@ -53,6 +53,17 @@ pub enum Action {
|
||||
RenameHighlighted,
|
||||
|
||||
// Chrome
|
||||
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
|
||||
/// over the sessions, ranked by the shared matcher.
|
||||
OpenCommandBar,
|
||||
/// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B`
|
||||
/// byte to the focused pane instead of opening the command bar, giving
|
||||
/// readline's kill-to-end-of-line back.
|
||||
SendLiteralCtrlK,
|
||||
/// Escape hatch (#158): the leader pressed twice sends one literal
|
||||
/// leader byte to the focused pane and drops the pending chord, the way
|
||||
/// tmux resolves a doubled prefix key.
|
||||
SendLiteralLeader,
|
||||
ToggleSidebar,
|
||||
ShowHelp,
|
||||
Quit,
|
||||
@@ -94,6 +105,9 @@ impl Action {
|
||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||
Action::KillHighlighted => "Kill selected session".into(),
|
||||
Action::RenameHighlighted => "Rename selected session".into(),
|
||||
Action::OpenCommandBar => "Go to a session".into(),
|
||||
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
|
||||
Action::SendLiteralLeader => "Send literal leader key to session".into(),
|
||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||
Action::ShowHelp => "Show help".into(),
|
||||
Action::Quit => "Quit Maestro".into(),
|
||||
@@ -120,6 +134,9 @@ impl Action {
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
||||
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
|
||||
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
|
||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||
"Quit" => return Ok(Action::Quit),
|
||||
@@ -239,10 +256,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The command bar is an ordinary keymap entry, so it must round-trip
|
||||
/// like every other action (#153).
|
||||
#[test]
|
||||
fn open_command_bar_round_trips() {
|
||||
assert_eq!(
|
||||
Action::parse("OpenCommandBar").unwrap(),
|
||||
Action::OpenCommandBar
|
||||
);
|
||||
assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar");
|
||||
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session");
|
||||
}
|
||||
|
||||
/// The escape hatch (#156) is an ordinary action like any other.
|
||||
#[test]
|
||||
fn send_literal_ctrl_k_round_trips() {
|
||||
assert_eq!(
|
||||
Action::parse("SendLiteralCtrlK").unwrap(),
|
||||
Action::SendLiteralCtrlK
|
||||
);
|
||||
assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK");
|
||||
assert_eq!(
|
||||
Action::SendLiteralCtrlK.help_label(),
|
||||
"Send literal Ctrl-K to session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_round_trips() {
|
||||
for a in [
|
||||
Action::EnterInsert,
|
||||
Action::OpenCommandBar,
|
||||
Action::SendLiteralCtrlK,
|
||||
Action::RenameHighlighted,
|
||||
Action::Quit,
|
||||
Action::OpenSpawnModal,
|
||||
|
||||
+1379
-65
File diff suppressed because it is too large
Load Diff
@@ -207,6 +207,7 @@ mod tests {
|
||||
|
||||
fn session(id: u128, command: &str) -> Session {
|
||||
Session {
|
||||
view: Default::default(),
|
||||
id: Uuid::from_u128(id),
|
||||
command: command.into(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
@@ -223,6 +224,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("pageup", "ScrollPanePageUp"),
|
||||
("pagedown", "ScrollPanePageDown"),
|
||||
// Chrome
|
||||
// The command bar (#153). An ordinary entry, so it is rebindable and the
|
||||
// help overlay picks it up from the live keymap.
|
||||
("ctrl-k", "OpenCommandBar"),
|
||||
("ctrl-w b", "ToggleSidebar"),
|
||||
("?", "ShowHelp"),
|
||||
("ctrl-w q", "Quit"),
|
||||
@@ -75,13 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||
];
|
||||
|
||||
/// Default Insert-mode bindings — deliberately empty. Every key typed in
|
||||
/// Default Insert-mode bindings — almost empty on purpose. Every key typed in
|
||||
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
||||
/// it constantly, so binding it here would make it unreachable. The only way
|
||||
/// back to the sidebar is the leader (`LeaveInsert`), which
|
||||
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
||||
/// the Insert escape with it.
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
||||
///
|
||||
/// The one exception is the command bar (#153): `ctrl-k` opens it from inside
|
||||
/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's
|
||||
/// kill-to-end-of-line is given back through the leader (#156).
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")];
|
||||
|
||||
impl TuiConfig {
|
||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||
@@ -107,7 +114,7 @@ impl TuiConfig {
|
||||
TuiTheme::Nord => Palette::NORD,
|
||||
TuiTheme::Dracula => Palette::DRACULA,
|
||||
};
|
||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
||||
let leader_str = section.leader();
|
||||
let leader = InputKey::parse(leader_str)
|
||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
||||
@@ -139,6 +146,25 @@ impl TuiConfig {
|
||||
keymap
|
||||
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
|
||||
.expect("leader canonical form is valid by construction");
|
||||
// Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from
|
||||
// the same leader spec so rebinding leader rebinds this chord too.
|
||||
keymap
|
||||
.set(
|
||||
Mode::Insert,
|
||||
&format!("{} ctrl-k", leader.canonical()),
|
||||
"SendLiteralCtrlK",
|
||||
)
|
||||
.expect("leader canonical form is valid by construction");
|
||||
// Leader, leader → the literal-leader escape hatch (#158), tmux-style:
|
||||
// pressing it twice sends the leader byte itself. Built from the same
|
||||
// leader spec, so rebinding leader rebinds this chord too.
|
||||
keymap
|
||||
.set(
|
||||
Mode::Insert,
|
||||
&format!("{} {}", leader.canonical(), leader.canonical()),
|
||||
"SendLiteralLeader",
|
||||
)
|
||||
.expect("leader canonical form is valid by construction");
|
||||
|
||||
for (input, action) in §ion.keymap_normal {
|
||||
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
||||
@@ -271,6 +297,60 @@ mod tests {
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
|
||||
}
|
||||
|
||||
/// The literal-Ctrl-K escape hatch (#156) is chorded off the default
|
||||
/// leader, `ctrl-a ctrl-k` — not the command bar.
|
||||
#[test]
|
||||
fn default_leader_escapes_a_literal_ctrl_k() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"),
|
||||
Some(Action::SendLiteralCtrlK),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not
|
||||
/// just the Insert escape — proving the chord is built from the
|
||||
/// configured leader rather than a hardcoded `ctrl-a`.
|
||||
#[test]
|
||||
fn rebinding_leader_moves_the_literal_ctrl_k_chord() {
|
||||
let mut section = TuiSection::default();
|
||||
section.leader = Some("ctrl-b".into());
|
||||
let cfg = TuiConfig::load(§ion).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"),
|
||||
Some(Action::SendLiteralCtrlK),
|
||||
);
|
||||
// The old leader's chord is gone along with its Insert escape.
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None);
|
||||
}
|
||||
|
||||
/// The literal-leader escape hatch (#158) is chorded off the leader
|
||||
/// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution.
|
||||
#[test]
|
||||
fn default_leader_escapes_a_literal_leader() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"),
|
||||
Some(Action::SendLiteralLeader),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebinding the leader must rebind the literal-leader chord with it,
|
||||
/// not just the Insert escape and the Ctrl-K chord — proving it too is
|
||||
/// built from the configured leader rather than a hardcoded `ctrl-a`.
|
||||
#[test]
|
||||
fn rebinding_leader_moves_the_literal_leader_chord() {
|
||||
let mut section = TuiSection::default();
|
||||
section.leader = Some("ctrl-b".into());
|
||||
let cfg = TuiConfig::load(§ion).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-b"),
|
||||
Some(Action::SendLiteralLeader),
|
||||
);
|
||||
// The old leader's chord is gone along with the rest of its bindings.
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_normal_override_replaces_default() {
|
||||
let mut section = TuiSection::default();
|
||||
|
||||
@@ -250,6 +250,7 @@ mod tests {
|
||||
dirty: false,
|
||||
},
|
||||
instance: instance.into(),
|
||||
leader: maestro_config::DEFAULT_LEADER.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+450
-12
@@ -41,8 +41,10 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||
};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -86,9 +88,11 @@ pub enum DraftField {
|
||||
/// apply immediately, mirroring the web form's per-click choice.
|
||||
WorktreeChoice,
|
||||
Name,
|
||||
/// Disclosure collapsing the worktree/branch controls below (#93).
|
||||
/// Disclosure collapsing the optional view and worktree controls below.
|
||||
/// Closed by default; Enter / Space toggles.
|
||||
Advanced,
|
||||
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
|
||||
StructuredToggle,
|
||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||
WorktreeToggle,
|
||||
/// New-vs-existing branch source; Left / Right switches.
|
||||
@@ -228,6 +232,10 @@ pub struct DraftPane {
|
||||
/// respawns there without touching the picker. Consumed by the first
|
||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||
pub seed_cwd: Option<PathBuf>,
|
||||
/// Command to preselect once the preset list lands — "spawn similar"'s
|
||||
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
|
||||
/// `None` once applied or when nothing seeded it.
|
||||
pub seed_command: Option<String>,
|
||||
/// The repository picked in step one ([`group_key`] of its main box),
|
||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||
/// cached, so a project-list refresh can't leave it stale.
|
||||
@@ -247,6 +255,14 @@ pub struct DraftPane {
|
||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||
/// take the default target.
|
||||
pub advanced_collapsed: bool,
|
||||
/// ACP-backed structured transcript. Supported only when the resolved
|
||||
/// command is Claude Code or Codex, and on by default whenever it is —
|
||||
/// until the operator works the toggle themselves.
|
||||
pub structured_view: bool,
|
||||
/// Set once the operator toggles structured view by hand. From then on a
|
||||
/// command change never re-enables what they turned off, the same guard
|
||||
/// `name_touched` gives the name field.
|
||||
pub structured_view_touched: bool,
|
||||
/// Enclosing repository of the chosen target — from the project scan for
|
||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||
pub base_repo: Option<PathBuf>,
|
||||
@@ -287,10 +303,11 @@ impl Default for DraftPane {
|
||||
// while its data loads, and stay usable when the daemon reports none.
|
||||
let mut presets = builtin_presets();
|
||||
presets.push(custom_preset());
|
||||
Self {
|
||||
let mut draft = Self {
|
||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
presets,
|
||||
selected_preset: 0,
|
||||
seed_command: None,
|
||||
custom_command: crate::text_input::TextInput::default(),
|
||||
groups: Vec::new(),
|
||||
filter: crate::text_input::TextInput::default(),
|
||||
@@ -306,6 +323,8 @@ impl Default for DraftPane {
|
||||
name_touched: false,
|
||||
worktree_enabled: true,
|
||||
advanced_collapsed: true,
|
||||
structured_view: false,
|
||||
structured_view_touched: false,
|
||||
base_repo: None,
|
||||
probed_cwd: None,
|
||||
probe_seq: 0,
|
||||
@@ -324,7 +343,11 @@ impl Default for DraftPane {
|
||||
// straight away, and Tab from here reaches the command tiles.
|
||||
focused_field: DraftField::DirPicker,
|
||||
home_dir: String::new(),
|
||||
}
|
||||
};
|
||||
// The built-in tile the form opens on decides whether structured view
|
||||
// starts ticked, exactly as a later preset list would.
|
||||
draft.sync_structured_view();
|
||||
draft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +365,28 @@ impl DraftPane {
|
||||
.iter()
|
||||
.position(|p| !p.command.is_empty())
|
||||
.unwrap_or(0);
|
||||
self.apply_seed_command();
|
||||
self.sync_structured_view();
|
||||
}
|
||||
|
||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||
/// daemon offers one, otherwise the custom escape hatch with the command
|
||||
/// typed in.
|
||||
fn apply_seed_command(&mut self) {
|
||||
let Some(command) = self.seed_command.take() else {
|
||||
return;
|
||||
};
|
||||
match self.presets.iter().position(|p| p.command == command) {
|
||||
Some(index) => self.selected_preset = index,
|
||||
None => {
|
||||
self.selected_preset = self
|
||||
.presets
|
||||
.iter()
|
||||
.position(|p| p.command.is_empty())
|
||||
.unwrap_or(self.selected_preset);
|
||||
self.custom_command = crate::text_input::TextInput::with_value(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the project snapshot: sort fresh-first (#54), group by
|
||||
@@ -637,6 +682,28 @@ impl DraftPane {
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_structured_view(&self) -> bool {
|
||||
supports_structured_view(self.command())
|
||||
}
|
||||
|
||||
/// Re-resolve the toggle after the command changed: an unsupported
|
||||
/// command forces it off, a supported one turns it back on unless the
|
||||
/// operator has already made the choice themselves.
|
||||
fn sync_structured_view(&mut self) {
|
||||
if !self.supports_structured_view() {
|
||||
self.structured_view = false;
|
||||
} else if !self.structured_view_touched {
|
||||
self.structured_view = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_structured_view(&mut self) {
|
||||
if self.supports_structured_view() {
|
||||
self.structured_view = !self.structured_view;
|
||||
self.structured_view_touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen working directory: a picked project row, else the raw path
|
||||
/// input when it holds something.
|
||||
pub fn cwd(&self) -> Option<PathBuf> {
|
||||
@@ -667,12 +734,22 @@ impl DraftPane {
|
||||
);
|
||||
}
|
||||
let worktree = self.build_worktree()?;
|
||||
let auto_approve = self
|
||||
.presets
|
||||
.get(self.selected_preset)
|
||||
.is_some_and(|p| p.auto_approve);
|
||||
Ok(CreateSessionRequest {
|
||||
view: if self.structured_view && self.supports_structured_view() {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
},
|
||||
cwd,
|
||||
command: command.to_string(),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree,
|
||||
auto_approve,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -727,6 +804,9 @@ impl DraftPane {
|
||||
order.push(DraftField::Name);
|
||||
order.push(DraftField::Advanced);
|
||||
if !self.advanced_collapsed {
|
||||
if self.supports_structured_view() {
|
||||
order.push(DraftField::StructuredToggle);
|
||||
}
|
||||
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||
order.push(DraftField::WorktreeChoice);
|
||||
}
|
||||
@@ -768,6 +848,7 @@ impl DraftPane {
|
||||
}
|
||||
self.selected_preset =
|
||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||
self.sync_structured_view();
|
||||
}
|
||||
|
||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||
@@ -974,9 +1055,9 @@ impl DraftPane {
|
||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||
// highlighted row isn't the picked one until a first Enter chooses
|
||||
// it (the next Enter, now that it's active, falls through to
|
||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
||||
// than submitting, same as the web form's own `<button>` controls.
|
||||
// submit); and the disclosure/checkbox fields, which activate on
|
||||
// Enter like buttons rather than submitting, matching the web
|
||||
// form controls.
|
||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||
let choose_only =
|
||||
@@ -996,6 +1077,10 @@ impl DraftPane {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
|
||||
self.toggle_structured_view();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter => DraftInputResult::Submit,
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
@@ -1013,6 +1098,14 @@ impl DraftPane {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
ctrl: false,
|
||||
..
|
||||
} if self.focused_field == DraftField::StructuredToggle => {
|
||||
self.toggle_structured_view();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
||||
self.move_selection(-1);
|
||||
DraftInputResult::Consumed
|
||||
@@ -1056,7 +1149,10 @@ impl DraftPane {
|
||||
other => {
|
||||
match self.focused_field {
|
||||
DraftField::Preset | DraftField::Submit => {}
|
||||
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
||||
DraftField::CustomCommand => {
|
||||
self.custom_command.handle_key(other);
|
||||
self.sync_structured_view();
|
||||
}
|
||||
// Typing filters the list; the highlight resets to the top
|
||||
// so a narrowed search always starts at its best match.
|
||||
DraftField::DirPicker => {
|
||||
@@ -1094,9 +1190,9 @@ impl DraftPane {
|
||||
|
||||
let show_custom = self.is_custom();
|
||||
let show_raw = self.cwd_raw_visible;
|
||||
// The worktree choice step, the worktree toggle and its body all stay
|
||||
// behind the "Advanced options" disclosure (#93): collapsed hides
|
||||
// them, regardless of what they'd otherwise show.
|
||||
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
|
||||
// Optional view and worktree controls stay behind the "Advanced
|
||||
// options" disclosure (#93), regardless of what they'd otherwise show.
|
||||
let show_wt_toggle = !self.advanced_collapsed;
|
||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||
let show_wt_choice =
|
||||
@@ -1115,6 +1211,7 @@ impl DraftPane {
|
||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||
+ u16::from(show_custom)
|
||||
+ u16::from(show_raw)
|
||||
+ u16::from(show_structured)
|
||||
+ u16::from(show_wt_toggle)
|
||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||
@@ -1134,6 +1231,9 @@ impl DraftPane {
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // name
|
||||
constraints.push(Constraint::Length(1)); // advanced header
|
||||
if show_structured {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
if show_wt_choice {
|
||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||
}
|
||||
@@ -1332,6 +1432,24 @@ impl DraftPane {
|
||||
advanced_row,
|
||||
);
|
||||
|
||||
if show_structured {
|
||||
let row = next_row();
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(format!("{:<GUTTER$}", "view:")),
|
||||
Span::styled(
|
||||
if self.structured_view {
|
||||
"[x] structured view"
|
||||
} else {
|
||||
"[ ] structured view"
|
||||
},
|
||||
field_style(DraftField::StructuredToggle),
|
||||
),
|
||||
])),
|
||||
row,
|
||||
);
|
||||
}
|
||||
|
||||
if show_wt_choice {
|
||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||
// worktrees newest-first.
|
||||
@@ -1679,6 +1797,7 @@ mod tests {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
}]);
|
||||
assert_eq!(
|
||||
draft.command(),
|
||||
@@ -1688,6 +1807,51 @@ mod tests {
|
||||
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
||||
}
|
||||
|
||||
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
|
||||
/// the tile that runs it when the daemon offers one.
|
||||
#[test]
|
||||
fn a_seeded_command_selects_its_own_preset() {
|
||||
let mut draft = DraftPane {
|
||||
seed_command: Some("aider".into()),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert!(!draft.is_custom());
|
||||
assert_eq!(draft.seed_command, None, "consumed by the first load");
|
||||
}
|
||||
|
||||
/// A command no preset runs falls back to the custom tile with the command
|
||||
/// typed in, so the clone still spawns what it cloned.
|
||||
#[test]
|
||||
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
|
||||
let mut draft = DraftPane {
|
||||
seed_command: Some("bash -l".into()),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
}]);
|
||||
assert!(draft.is_custom());
|
||||
assert_eq!(draft.command(), "bash -l");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||
let mut draft = DraftPane {
|
||||
@@ -1748,6 +1912,280 @@ mod tests {
|
||||
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_is_default_on_and_visible_for_supported_agents() {
|
||||
let mut draft = DraftPane {
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "OpenCode".into(),
|
||||
command: "opencode".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert!(draft.structured_view, "on by default for a supported agent");
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(rendered.contains("[x] structured view"), "{rendered}");
|
||||
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "codex");
|
||||
assert!(
|
||||
draft.structured_view,
|
||||
"Codex keeps the structured selection"
|
||||
);
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"a hidden value must not stay checked"
|
||||
);
|
||||
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(!rendered.contains("structured view"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_request_is_guarded_against_a_stale_command() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
structured_view: true,
|
||||
..DraftPane::default()
|
||||
};
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
draft.selected_preset = 1;
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
/// A custom command typed into the free-text field, run through the same
|
||||
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
|
||||
/// env prefix, or a wrapper still resolves to Claude Code or Codex, so
|
||||
/// the toggle stays offered and the request carries `Structured`; an
|
||||
/// unrelated program does not.
|
||||
#[test]
|
||||
fn structured_view_follows_command_resolution_for_custom_commands() {
|
||||
let cases: &[(&str, bool)] = &[
|
||||
("claude", true),
|
||||
("claude --resume", true),
|
||||
("CLAUDE", true),
|
||||
("/usr/local/bin/claude -p hi", true),
|
||||
("./bin/claude", true),
|
||||
("FOO=1 BAR=2 claude", true),
|
||||
("npx claude-code", true),
|
||||
("npx --yes claude-code", true),
|
||||
("sudo claude", true),
|
||||
("sudo -u root claude", true),
|
||||
("env -i claude", true),
|
||||
("nohup claude", true),
|
||||
("time claude", true),
|
||||
("command claude", true),
|
||||
("pnpm --silent dlx claude-code", true),
|
||||
("yarn --silent dlx claude-code", true),
|
||||
("bun x claude-code", true),
|
||||
("\"/opt/my agent/claude\" --resume", true),
|
||||
("codex", true),
|
||||
("codex --full-auto", true),
|
||||
("CODEX=1 codex", true),
|
||||
("/usr/local/bin/codex exec", true),
|
||||
("npx codex", true),
|
||||
("sudo -u root codex", true),
|
||||
("env -i codex", true),
|
||||
("command codex", true),
|
||||
("pnpm --silent dlx codex", true),
|
||||
("\"/opt/my agent/codex\" exec", true),
|
||||
("bash", false),
|
||||
("opencode", false),
|
||||
("/usr/local/bin/bash -l", false),
|
||||
// A package-manager wrapper without `dlx`/`x` runs a
|
||||
// `package.json` script, not the package (#199) — must not be
|
||||
// mistaken for a Claude launch.
|
||||
("pnpm claude", false),
|
||||
("yarn claude", false),
|
||||
// The daemon's structured-session child, never a valid
|
||||
// user-facing launch form (#199).
|
||||
("claude-code-acp", false),
|
||||
];
|
||||
for &(command, supported) in cases {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.selected_preset = draft.presets.len() - 1;
|
||||
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||
draft.sync_structured_view();
|
||||
|
||||
assert_eq!(
|
||||
draft.field_order().contains(&DraftField::StructuredToggle),
|
||||
supported,
|
||||
"toggle visibility for {command:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
draft.structured_view, supported,
|
||||
"default selection for {command:?}"
|
||||
);
|
||||
let view = draft.build_request().unwrap().view;
|
||||
assert_eq!(
|
||||
view,
|
||||
if supported {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
},
|
||||
"request view for {command:?}"
|
||||
);
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"the toggle turns the default off for {command:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A configured preset (the spawn-form "alias" for a command) is gated
|
||||
/// the same way as free text: a preset whose command resolves to Claude
|
||||
/// Code or Codex through a wrapper still offers structured view.
|
||||
#[test]
|
||||
fn structured_view_follows_command_resolution_for_presets() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude-wrapped".into(),
|
||||
label: "Claude (npx)".into(),
|
||||
command: "npx claude-code --resume".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex-wrapped".into(),
|
||||
label: "Codex (npx)".into(),
|
||||
command: "npx codex exec".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(draft.command(), "npx claude-code --resume");
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
assert!(draft.structured_view);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
// Switching between supported presets preserves the selection.
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "npx codex exec");
|
||||
assert!(draft.structured_view);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
// Switching to a preset that does not resolve to an ACP agent clears the
|
||||
// stale selection instead of letting an invalid request through.
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert!(!draft.structured_view, "stale selection must clear");
|
||||
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
/// The default only applies until the operator has an opinion: once they
|
||||
/// untick the box, moving between supported commands leaves it unticked
|
||||
/// instead of turning it back on under them.
|
||||
#[test]
|
||||
fn an_unticked_structured_view_survives_a_command_change() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert!(draft.structured_view);
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(!draft.structured_view);
|
||||
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "codex");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"the operator's choice outranks the default"
|
||||
);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
/// Editing the custom-command text away from a supported agent clears an armed
|
||||
/// selection immediately, matching the preset-switch case above — an
|
||||
/// invalid `Structured` request can never reach `build_request`.
|
||||
#[test]
|
||||
fn structured_view_clears_when_custom_command_edited_away_from_supported_agent() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
advanced_collapsed: false,
|
||||
focused_field: DraftField::CustomCommand,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.selected_preset = draft.presets.len() - 1;
|
||||
draft.custom_command = crate::text_input::TextInput::with_value("codex");
|
||||
draft.sync_structured_view();
|
||||
assert!(draft.structured_view);
|
||||
|
||||
typed(&mut draft, " -p");
|
||||
assert_eq!(draft.command(), "codex -p");
|
||||
assert!(draft.structured_view, "still Codex, selection survives");
|
||||
|
||||
for _ in 0.."codex -p".chars().count() {
|
||||
draft.on_input(&InputKey::Backspace);
|
||||
}
|
||||
typed(&mut draft, "bash");
|
||||
assert_eq!(draft.command(), "bash");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"editing the program away from Codex clears the stale selection"
|
||||
);
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -49,6 +49,33 @@ pub fn resolve(
|
||||
.into_iter()
|
||||
.next()
|
||||
});
|
||||
defaults(project, presets, handle)
|
||||
}
|
||||
|
||||
/// Resolve the defaults for a spawn pinned to one directory — the command
|
||||
/// bar's `~` (#154), where the operator named the directory and nothing may
|
||||
/// override it. Unlike [`resolve`] there is no recency fallback: a directory
|
||||
/// the project scan does not know leaves `project` `None`, which is an
|
||||
/// unresolvable default, so the keystroke opens the pre-filled form instead of
|
||||
/// spawning somewhere else.
|
||||
pub fn resolve_at(
|
||||
dir: &std::path::Path,
|
||||
projects: &[Project],
|
||||
presets: &[SpawnPreset],
|
||||
handle: Option<String>,
|
||||
) -> FastForwardDefaults {
|
||||
let project = projects.iter().find(|p| p.path == dir).cloned();
|
||||
defaults(project, presets, handle)
|
||||
}
|
||||
|
||||
/// The project's last command, or the first non-custom preset, wrapped up with
|
||||
/// the rest of the defaults. Shared by both resolvers so the two entry points
|
||||
/// can never settle on different commands.
|
||||
fn defaults(
|
||||
project: Option<Project>,
|
||||
presets: &[SpawnPreset],
|
||||
handle: Option<String>,
|
||||
) -> FastForwardDefaults {
|
||||
let command = project
|
||||
.as_ref()
|
||||
.and_then(|p| p.last_command.clone())
|
||||
@@ -81,6 +108,7 @@ impl FastForwardDefaults {
|
||||
return None;
|
||||
}
|
||||
Some(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
cwd: project.path.clone(),
|
||||
command: command.to_string(),
|
||||
env: Default::default(),
|
||||
@@ -93,6 +121,7 @@ impl FastForwardDefaults {
|
||||
branch: handle.to_string(),
|
||||
create_branch: true,
|
||||
}),
|
||||
auto_approve: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,9 +185,50 @@ mod tests {
|
||||
id: id.into(),
|
||||
label: id.into(),
|
||||
command: command.into(),
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The command bar's `~` names the directory; nothing may override it.
|
||||
#[test]
|
||||
fn resolve_at_pins_the_directory_over_the_recency_ranking() {
|
||||
let projects = vec![
|
||||
project("/repos/fresh", 0, Some("aider")),
|
||||
project("/repos/pinned", 30, Some("zsh")),
|
||||
];
|
||||
let resolved = resolve_at(
|
||||
std::path::Path::new("/repos/pinned"),
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
Some("otter".into()),
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.project.as_ref().map(|p| p.path.clone()),
|
||||
Some(PathBuf::from("/repos/pinned"))
|
||||
);
|
||||
assert_eq!(resolved.command.as_deref(), Some("zsh"));
|
||||
let req = resolved.build_request().expect("every default resolved");
|
||||
assert_eq!(req.cwd, PathBuf::from("/repos/pinned"));
|
||||
}
|
||||
|
||||
/// A directory the scan does not know resolves no project, so there is no
|
||||
/// request to build — the caller opens the pre-filled form instead of
|
||||
/// spawning somewhere else.
|
||||
#[test]
|
||||
fn resolve_at_takes_no_fallback_for_an_unknown_directory() {
|
||||
let projects = vec![project("/repos/fresh", 0, Some("aider"))];
|
||||
let resolved = resolve_at(
|
||||
std::path::Path::new("/elsewhere"),
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
Some("otter".into()),
|
||||
);
|
||||
assert_eq!(resolved.project, None);
|
||||
// The preset still resolves; the missing project is what blocks it.
|
||||
assert_eq!(resolved.command.as_deref(), Some("claude"));
|
||||
assert_eq!(resolved.build_request(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_every_default_from_the_most_recent_project() {
|
||||
let projects = vec![
|
||||
|
||||
@@ -55,6 +55,7 @@ pub enum MouseKind {
|
||||
Click,
|
||||
DblClick,
|
||||
Drag,
|
||||
Hover,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
}
|
||||
@@ -148,6 +149,7 @@ impl InputKey {
|
||||
MouseKind::Click => "click",
|
||||
MouseKind::DblClick => "dblclick",
|
||||
MouseKind::Drag => "drag",
|
||||
MouseKind::Hover => "hover",
|
||||
MouseKind::ScrollUp => "scrollup",
|
||||
MouseKind::ScrollDown => "scrolldown",
|
||||
};
|
||||
@@ -178,6 +180,7 @@ impl InputKey {
|
||||
"click" => MouseKind::Click,
|
||||
"dblclick" => MouseKind::DblClick,
|
||||
"drag" => MouseKind::Drag,
|
||||
"hover" => MouseKind::Hover,
|
||||
"scrollup" => MouseKind::ScrollUp,
|
||||
"scrolldown" => MouseKind::ScrollDown,
|
||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||
@@ -297,6 +300,7 @@ where
|
||||
let mk = match kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||
MouseEventKind::Moved => MouseKind::Hover,
|
||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||
_ => return None,
|
||||
@@ -387,6 +391,9 @@ pub struct ChordState {
|
||||
held: Vec<String>,
|
||||
last_press: Option<Instant>,
|
||||
timeout: Duration,
|
||||
/// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting
|
||||
/// to be replayed as a fresh keystroke by the caller.
|
||||
replay: Option<InputKey>,
|
||||
}
|
||||
|
||||
impl ChordState {
|
||||
@@ -395,6 +402,7 @@ impl ChordState {
|
||||
held: Vec::new(),
|
||||
last_press: None,
|
||||
timeout,
|
||||
replay: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +412,13 @@ impl ChordState {
|
||||
/// binding; wait for the next key.
|
||||
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
|
||||
/// up in the keymap. The held buffer is cleared.
|
||||
///
|
||||
/// A held prefix that is itself directly bound (e.g. a leader key bound
|
||||
/// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves
|
||||
/// to that binding the moment the next key fails to extend it, rather
|
||||
/// than swallowing the held key and losing its meaning — the new key
|
||||
/// never belonged to the chord and is handed back via [`Self::take_replay`]
|
||||
/// so the caller can process it as its own keystroke.
|
||||
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
|
||||
if let Some(t) = self.last_press {
|
||||
if t.elapsed() > self.timeout {
|
||||
@@ -428,11 +443,31 @@ impl ChordState {
|
||||
|
||||
if is_prefix {
|
||||
self.held.push(key_s);
|
||||
ChordResult::Pending
|
||||
} else {
|
||||
self.held.clear();
|
||||
ChordResult::Resolved(candidate)
|
||||
return ChordResult::Pending;
|
||||
}
|
||||
|
||||
if known_bindings.contains(&candidate) {
|
||||
self.held.clear();
|
||||
return ChordResult::Resolved(candidate);
|
||||
}
|
||||
|
||||
if !self.held.is_empty() {
|
||||
let held_str = self.held.join(" ");
|
||||
self.held.clear();
|
||||
if known_bindings.contains(&held_str) {
|
||||
self.replay = Some(key.clone());
|
||||
return ChordResult::Resolved(held_str);
|
||||
}
|
||||
}
|
||||
|
||||
ChordResult::Resolved(candidate)
|
||||
}
|
||||
|
||||
/// Take the key bumped out of an abandoned chord by the last [`Self::feed`]
|
||||
/// call, if any. The caller feeds it through again as a brand new
|
||||
/// keystroke.
|
||||
pub fn take_replay(&mut self) -> Option<InputKey> {
|
||||
self.replay.take()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,6 +685,9 @@ mod tests {
|
||||
let s = "mouse:dblclick(sidebar-row)";
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s);
|
||||
let s = "mouse:hover(pane)";
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -733,6 +771,41 @@ mod tests {
|
||||
assert_eq!(r, ChordResult::Resolved("g x".into()));
|
||||
}
|
||||
|
||||
/// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and
|
||||
/// `ctrl-a ctrl-k`) must still fire its own binding when the next key
|
||||
/// doesn't complete the chord — the held key's meaning isn't lost, and
|
||||
/// the new key comes back via `take_replay` for the caller to process on
|
||||
/// its own (#156).
|
||||
#[test]
|
||||
fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() {
|
||||
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let r = chord.feed(&InputKey::ctrl_char('a'), &known);
|
||||
assert_eq!(r, ChordResult::Pending);
|
||||
|
||||
let r = chord.feed(&InputKey::char('j'), &known);
|
||||
assert_eq!(r, ChordResult::Resolved("ctrl-a".into()));
|
||||
assert_eq!(chord.take_replay(), Some(InputKey::char('j')));
|
||||
}
|
||||
|
||||
/// The full chord still resolves normally when the next key completes
|
||||
/// it — no replay, no early fire of the shorter binding.
|
||||
#[test]
|
||||
fn a_completed_chord_off_a_bound_prefix_has_no_replay() {
|
||||
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
chord.feed(&InputKey::ctrl_char('a'), &known);
|
||||
let r = chord.feed(&InputKey::ctrl_char('k'), &known);
|
||||
assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into()));
|
||||
assert_eq!(chord.take_replay(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chord_state_lone_key_resolves_immediately() {
|
||||
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod action;
|
||||
pub mod app;
|
||||
pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod command_bar;
|
||||
pub mod config;
|
||||
pub mod daemon_link;
|
||||
pub mod draft;
|
||||
@@ -22,6 +23,8 @@ pub mod render;
|
||||
pub mod session_task;
|
||||
pub mod sidebar;
|
||||
pub mod state;
|
||||
pub mod structured_pane;
|
||||
pub mod structured_task;
|
||||
pub mod terminal;
|
||||
pub mod text_input;
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ impl Surface {
|
||||
pub fn activity(&self, state: ActivityState) -> Color {
|
||||
match state {
|
||||
ActivityState::Active => self.active,
|
||||
ActivityState::Idle => self.idle,
|
||||
ActivityState::Idle | ActivityState::Starting => self.idle,
|
||||
ActivityState::AwaitingInput => self.awaiting,
|
||||
ActivityState::Error => self.error,
|
||||
}
|
||||
@@ -928,9 +928,10 @@ pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const STATES: [ActivityState; 4] = [
|
||||
const STATES: [ActivityState; 5] = [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::palette;
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||
use crate::structured_pane::StructuredPane;
|
||||
|
||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
||||
@@ -80,12 +81,16 @@ pub struct RenderInputs<'a> {
|
||||
pub home_dir: &'a str,
|
||||
pub pane_title: &'a str,
|
||||
pub pane: Option<&'a Pane>,
|
||||
pub structured_pane: Option<&'a StructuredPane>,
|
||||
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
pub help: &'a HelpOverlay,
|
||||
/// Edit buffer behind the rename prompt (#125).
|
||||
pub rename_input: &'a crate::text_input::TextInput,
|
||||
/// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is
|
||||
/// up.
|
||||
pub command_bar: Option<&'a crate::command_bar::CommandBar>,
|
||||
/// Liveness + release of the daemon, for the status bar's indicator and the
|
||||
/// restart overlay (`docs/RESTART.md`).
|
||||
pub link: &'a crate::daemon_link::LinkStatus,
|
||||
@@ -144,14 +149,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||
if let Some(p) = inputs.pane {
|
||||
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
|
||||
// chrome; every remaining cell belongs to the emulator.
|
||||
// chrome; every remaining cell belongs to the active session pane.
|
||||
map.pane = Some(pane_area);
|
||||
|
||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||
// always see the current mode + key hints.
|
||||
render_status_bar(f, status_area, inputs.state, inputs.link);
|
||||
render_status_bar(
|
||||
f,
|
||||
status_area,
|
||||
inputs.state,
|
||||
inputs.link,
|
||||
inputs.structured_pane.is_some(),
|
||||
);
|
||||
|
||||
match inputs.state.overlay() {
|
||||
Some(OverlayKind::Spawn) => {
|
||||
@@ -198,6 +211,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
}
|
||||
map.modal = Some(area);
|
||||
}
|
||||
Some(OverlayKind::CommandBar) => {
|
||||
if let Some(bar) = inputs.command_bar {
|
||||
if let Some((x, y)) = bar.render(f, area) {
|
||||
f.set_cursor_position((x, y));
|
||||
}
|
||||
map.modal = Some(area);
|
||||
}
|
||||
}
|
||||
Some(OverlayKind::Help) => {
|
||||
inputs.help.render(f, area);
|
||||
map.modal = Some(area);
|
||||
@@ -242,7 +263,13 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||
/// mode + focused panel + the most useful key hints so the user is never
|
||||
/// guessing what state they're in.
|
||||
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) {
|
||||
fn render_status_bar(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
link: &LinkStatus,
|
||||
structured: bool,
|
||||
) {
|
||||
// The daemon indicator is pinned right and sized to its own text, so a
|
||||
// narrow terminal truncates the key hints rather than the release.
|
||||
let label = link_label(link);
|
||||
@@ -260,9 +287,13 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkSta
|
||||
Focus::Sidebar => "sidebar",
|
||||
Focus::Pane => "pane",
|
||||
};
|
||||
let hint = match state.mode {
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
||||
Mode::Insert => "^a: return to sidebar",
|
||||
let hint = match (structured && state.focus == Focus::Pane, state.mode) {
|
||||
(true, Mode::Normal) => "j/k: rows • enter: expand • i: compose • esc: cancel",
|
||||
(true, Mode::Insert) => "enter: send • esc: cancel • ^a: sidebar",
|
||||
(false, Mode::Normal) => {
|
||||
"j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit"
|
||||
}
|
||||
(false, Mode::Insert) => "^a: return to sidebar",
|
||||
};
|
||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||
let line = ratatui::text::Line::from(vec![
|
||||
@@ -424,10 +455,12 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
structured_pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link,
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(120, 24);
|
||||
@@ -520,10 +553,12 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
structured_pane: None,
|
||||
draft: Some(&draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
|
||||
@@ -574,10 +609,12 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
structured_pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
@@ -615,10 +652,12 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
structured_pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
|
||||
@@ -221,7 +221,7 @@ impl SessionTask {
|
||||
}
|
||||
}
|
||||
|
||||
fn jitter(base: Duration) -> Duration {
|
||||
pub(crate) fn jitter(base: Duration) -> Duration {
|
||||
let ms = base.as_millis() as u64;
|
||||
let now_nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -484,6 +484,7 @@ mod tests {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("name".into(), serde_json::Value::String(name.into()));
|
||||
Session {
|
||||
view: Default::default(),
|
||||
id: Uuid::from_u128(id),
|
||||
command: "bash".into(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
@@ -500,6 +501,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,6 +979,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
@@ -1007,6 +1010,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
@@ -1250,6 +1254,7 @@ mod tests {
|
||||
#[test]
|
||||
fn falls_back_to_command_when_name_metadata_is_absent() {
|
||||
let s = Session {
|
||||
view: Default::default(),
|
||||
id: Uuid::from_u128(1),
|
||||
command: "vim".into(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
@@ -1266,6 +1271,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
};
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
|
||||
@@ -52,6 +52,10 @@ pub enum OverlayKind {
|
||||
/// so this stays comparable.
|
||||
Rename(Uuid),
|
||||
Help,
|
||||
/// The `ctrl-k` command bar (#153). Its query buffer and result list live
|
||||
/// on `App` as [`crate::command_bar::CommandBar`], for the same reason the
|
||||
/// rename buffer does — so this stays comparable.
|
||||
CommandBar,
|
||||
Toast(String),
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,773 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! The TUI's structured actions use the shared typed daemon client; assert the
|
||||
//! exact route and bare approval decision body at the HTTP boundary.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use maestro_protocol::{DaemonClient, StructuredApprovalDecision, StructuredPromptRequest};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Seen {
|
||||
prompt: Option<String>,
|
||||
cancelled: bool,
|
||||
approval: Option<(String, StructuredApprovalDecision)>,
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Seen>>;
|
||||
|
||||
async fn prompt(
|
||||
State(seen): State<Shared>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(body): Json<StructuredPromptRequest>,
|
||||
) {
|
||||
seen.lock().unwrap().prompt = Some(body.prompt);
|
||||
}
|
||||
|
||||
async fn cancel(State(seen): State<Shared>, Path(_id): Path<Uuid>) {
|
||||
seen.lock().unwrap().cancelled = true;
|
||||
}
|
||||
|
||||
async fn approval(
|
||||
State(seen): State<Shared>,
|
||||
Path((_id, nonce)): Path<(Uuid, String)>,
|
||||
Json(decision): Json<StructuredApprovalDecision>,
|
||||
) {
|
||||
seen.lock().unwrap().approval = Some((nonce, decision));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn prompt_cancel_and_approval_use_structured_api_contract() {
|
||||
let seen = Shared::default();
|
||||
let app = Router::new()
|
||||
.route("/api/sessions/:id/structured/prompt", post(prompt))
|
||||
.route("/api/sessions/:id/structured/cancel", post(cancel))
|
||||
.route(
|
||||
"/api/sessions/:id/structured/approvals/:nonce",
|
||||
post(approval),
|
||||
)
|
||||
.with_state(seen.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
|
||||
let client = DaemonClient::new(format!("http://{addr}"));
|
||||
let id = Uuid::from_u128(141);
|
||||
client.send_structured_prompt(id, "ship it").await.unwrap();
|
||||
client.cancel_structured_turn(id).await.unwrap();
|
||||
client
|
||||
.resolve_structured_approval(
|
||||
id,
|
||||
"one-use-nonce",
|
||||
StructuredApprovalDecision::AllowForSession,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen.prompt.as_deref(), Some("ship it"));
|
||||
assert!(seen.cancelled);
|
||||
assert_eq!(
|
||||
seen.approval,
|
||||
Some((
|
||||
"one-use-nonce".into(),
|
||||
StructuredApprovalDecision::AllowForSession
|
||||
))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Projection socket coverage: snapshots replace state on reconnect and live
|
||||
//! deltas patch rows without client-side event folding.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use maestro_protocol::{
|
||||
ReducedState, StructuredEvent, TranscriptDelta, TranscriptRow, TranscriptRowKind,
|
||||
TranscriptSnapshot,
|
||||
};
|
||||
use maestro_tui::session_task::SessionState;
|
||||
use maestro_tui::structured_task::StructuredTask;
|
||||
use serde_json::json;
|
||||
|
||||
async fn structured_handler(
|
||||
State(connects): State<Arc<AtomicUsize>>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |mut socket| async move {
|
||||
let attempt = connects.fetch_add(1, Ordering::SeqCst);
|
||||
let snapshot = if attempt == 0 {
|
||||
TranscriptSnapshot {
|
||||
cursor: 1,
|
||||
rows: vec![row("old", json!({"content":{"type":"text","text":"old"}}))],
|
||||
}
|
||||
} else {
|
||||
TranscriptSnapshot {
|
||||
cursor: 3,
|
||||
rows: vec![row("live", json!({"content":{"type":"text","text":"one"}}))],
|
||||
}
|
||||
};
|
||||
send(
|
||||
&mut socket,
|
||||
StructuredEvent::TranscriptSnapshot { snapshot },
|
||||
)
|
||||
.await;
|
||||
if attempt == 0 {
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
}
|
||||
send(
|
||||
&mut socket,
|
||||
StructuredEvent::ProjectionUpdate {
|
||||
transcript: Some(TranscriptDelta::Patch {
|
||||
row: row("live", json!({"content":{"type":"text","text":"two"}})),
|
||||
}),
|
||||
state: ReducedState {
|
||||
model: Some("test-model".into()),
|
||||
..ReducedState::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
})
|
||||
}
|
||||
|
||||
fn row(id: &str, data: serde_json::Value) -> TranscriptRow {
|
||||
TranscriptRow {
|
||||
id: id.into(),
|
||||
kind: TranscriptRowKind::AgentText,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(socket: &mut axum::extract::ws::WebSocket, event: StructuredEvent) {
|
||||
socket
|
||||
.send(Message::Text(serde_json::to_string(&event).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn reconnect_snapshot_replaces_then_delta_patches_projection() {
|
||||
let connects = Arc::new(AtomicUsize::new(0));
|
||||
let app = Router::new()
|
||||
.route("/api/structured/x", any(structured_handler))
|
||||
.with_state(connects.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
|
||||
let handle = StructuredTask::spawn(format!("ws://{addr}/api/structured/x"));
|
||||
for _ in 0..250 {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
let state = handle.state.lock().unwrap().clone();
|
||||
if state.reduced.model.as_deref() == Some("test-model") {
|
||||
assert_eq!(state.rows.len(), 1);
|
||||
assert_eq!(state.rows[0].id, "live");
|
||||
assert_eq!(state.rows[0].data["content"]["text"], "two");
|
||||
assert!(connects.load(Ordering::SeqCst) >= 2);
|
||||
handle.end();
|
||||
assert_eq!(handle.connection(), SessionState::Ended);
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!(
|
||||
"structured projection never reached reconnect delta; connection={:?}",
|
||||
handle.connection()
|
||||
);
|
||||
}
|
||||
@@ -29,3 +29,6 @@ tracing-subscriber.workspace = true
|
||||
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
|
||||
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
|
||||
maestro-tui = { path = "../maestro-tui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Process-level routing for the umbrella `maestro` binary (#10): each
|
||||
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
|
||||
//! not just parse successfully.
|
||||
//!
|
||||
//! A malformed `--config` file makes every crate's dispatch fail identically
|
||||
//! at config load, before any subcommand touches tmux, the network, or a
|
||||
//! terminal. That lets the assertions tell "clap parsed it but the handler
|
||||
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
|
||||
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
|
||||
//! exit 1).
|
||||
//!
|
||||
//! The absence tests below only compile when the matching feature is off, so
|
||||
//! they run under CI's per-feature-subset matrix (`--no-default-features
|
||||
//! --features cli|daemon|tui`) rather than the default all-features build.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn bin() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
|
||||
}
|
||||
|
||||
fn bad_config() -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
|
||||
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
|
||||
f
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(bin())
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
|
||||
}
|
||||
|
||||
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
|
||||
/// config crate's error surfacing through — never clap's own usage-error
|
||||
/// page, which would mean the subcommand never reached its handler.
|
||||
fn assert_dispatched(out: &Output) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
|
||||
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
|
||||
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
|
||||
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
|
||||
}
|
||||
|
||||
/// Never reached a handler: clap rejected the subcommand outright because the
|
||||
/// feature that defines it is off. Only used by the `not(feature = "...")`
|
||||
/// tests below, which themselves only exist in a single-feature build.
|
||||
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
|
||||
fn assert_unrecognized(out: &Output, name: &str) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
|
||||
assert!(
|
||||
stderr.contains("unrecognized subcommand"),
|
||||
"stderr:\n{stderr}"
|
||||
);
|
||||
assert!(stderr.contains(name), "stderr:\n{stderr}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
#[test]
|
||||
fn list_dispatches_into_maestro_cli() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cli"))]
|
||||
#[test]
|
||||
fn list_is_unrecognized_without_cli_feature() {
|
||||
assert_unrecognized(&run(&["list"]), "list");
|
||||
}
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
#[test]
|
||||
fn daemon_dispatches_into_maestro_daemon() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "daemon"))]
|
||||
#[test]
|
||||
fn daemon_is_unrecognized_without_daemon_feature() {
|
||||
assert_unrecognized(&run(&["daemon"]), "daemon");
|
||||
}
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
#[test]
|
||||
fn tui_dispatches_into_maestro_tui() {
|
||||
let cfg = bad_config();
|
||||
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
|
||||
assert_dispatched(&out);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
#[test]
|
||||
fn tui_is_unrecognized_without_tui_feature() {
|
||||
assert_unrecognized(&run(&["tui"]), "tui");
|
||||
}
|
||||
+21
-8
@@ -45,8 +45,21 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
|
||||
`preventDefault`s it, since selecting all of a terminal pane does nothing
|
||||
useful.
|
||||
|
||||
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
|
||||
none of them needs one.
|
||||
One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
|
||||
`GET /api/info`, and the web binds that instead of its own constant (#159). A
|
||||
daemon too old to send the field, or an unreachable `/api/info`, leaves the web
|
||||
on `ctrl-a`. The section is named `[tui]` for history — it is the leader for
|
||||
every client, not the TUI's alone.
|
||||
|
||||
The leader itself needs the same kind of way back: holding it consumes every
|
||||
press, on both clients, even before anything is bound behind it. Pressing it
|
||||
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
|
||||
focused pane and clears the pending chord, the way tmux resolves a doubled
|
||||
prefix key. This chord is also built from the configured leader, so a
|
||||
rebound leader carries its own escape hatch with it.
|
||||
|
||||
Only Ctrl-K and the leader itself get an escape hatch. No other control
|
||||
character is intercepted, so none of them needs one.
|
||||
|
||||
### Focus round-trip
|
||||
|
||||
@@ -145,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
|
||||
ambiguous.
|
||||
- Escape, or backspace at an empty query, returns to the result list **with the
|
||||
previous query intact**. Drilling in and backing out is free.
|
||||
- Each action raises the flow it already has: kill goes through the existing
|
||||
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the
|
||||
existing prompt pre-filled with the session's own name
|
||||
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the
|
||||
session's directory and command. The bar picks the action; it never
|
||||
reimplements one.
|
||||
- Each action raises the flow it already has: kill through the confirmation
|
||||
both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
|
||||
(`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
|
||||
session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
|
||||
seeded from the session's directory and command. The bar picks the action; it
|
||||
never reimplements one.
|
||||
|
||||
No actions beyond those three, and no action across more than one session.
|
||||
|
||||
|
||||
+44
-5
@@ -18,10 +18,14 @@ wizard, and cleans up — only the unified `maestro` binary ends up persisted in
|
||||
|
||||
**Updating is the same command.** Re-running `./install.sh` detects an existing
|
||||
systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead
|
||||
of `start`. No `--update` flag needed. **The restart keeps your sessions:** the unit sets
|
||||
of `start`. No `--update` flag needed. **The restart keeps your terminal sessions:** the unit sets
|
||||
`KillMode=process`, so systemd signals only the daemon — the tmux server (and
|
||||
every session) survives, and the new daemon re-adopts them on start. A unit
|
||||
written before this default lacks the line; re-run `./install.sh` to refresh it.
|
||||
every tmux-backed session) survives, and the new daemon re-adopts them on
|
||||
start. Structured sessions (an agent run as a daemon child over ACP) are not
|
||||
tmux panes, so shutdown ends their active turn; the transcript survives and
|
||||
the next daemon attempts one resume (`docs/RESTART.md#structured-sessions`). A
|
||||
unit written before this default lacks the line; re-run `./install.sh` to
|
||||
refresh it.
|
||||
|
||||
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
|
||||
uses your already-configured git credentials for this host (HTTPS helper or
|
||||
@@ -94,7 +98,10 @@ running a local daemon, for a host that only talks to another machine's.
|
||||
|
||||
The daemon unit carries the same `KillMode=process` as the wizard-written one,
|
||||
so `systemctl --user restart maestro-daemon` leaves the tmux server and every
|
||||
session alive.
|
||||
terminal session alive. Structured sessions end their active turn on
|
||||
shutdown and resume once on the next daemon: ACP state comes back via
|
||||
`session/load`, while the event log only replays Maestro's own transcript
|
||||
projection (`docs/RESTART.md#structured-sessions`).
|
||||
|
||||
## Thin builds for single-role hosts
|
||||
|
||||
@@ -136,7 +143,8 @@ cover every v1 action — only override what you want to change:
|
||||
```toml
|
||||
[tui]
|
||||
theme = "dark" # see the theme list below
|
||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
||||
leader = "ctrl-a" # one byte that escapes Insert mode; the web reads
|
||||
# the same setting off /api/info
|
||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||
|
||||
[tui.keymap.normal]
|
||||
@@ -187,6 +195,37 @@ An unknown name is rejected as an `[tui].theme` configuration error.
|
||||
The full action vocabulary is the `Action` enum in
|
||||
`crates/maestro-tui/src/action.rs`.
|
||||
|
||||
## Structured sessions: 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 daemon serves the SPA off its own listener. Open
|
||||
|
||||
+2
-1
@@ -46,7 +46,8 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||
| A throwaway tab | Dashed border, italic label. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
|
||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
|
||||
+39
-4
@@ -2,10 +2,16 @@
|
||||
|
||||
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.
|
||||
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
|
||||
lose their sockets. This document is the contract for what a client does about
|
||||
it — what the daemon publishes, how a client tells a restart from a blip, and
|
||||
what the user sees while it happens.
|
||||
tmux keeps **terminal** sessions alive across that (see `docs/INSTALL.md`),
|
||||
but clients lose their sockets either way. This document is the contract for
|
||||
what a client does about it — what the daemon publishes, how a client tells a
|
||||
restart from a blip, and what the user sees while it happens.
|
||||
|
||||
A **structured** session (`docs/STRUCTURED-VIEW.md`) has no tmux pane behind
|
||||
it to survive on: its agent is a child of the daemon process itself, so a
|
||||
restart takes it down with the daemon. See
|
||||
[Structured sessions](#structured-sessions) below for exactly what that
|
||||
costs and what it doesn't.
|
||||
|
||||
## What the daemon publishes
|
||||
|
||||
@@ -68,6 +74,35 @@ resolves, so a blocked client is never a trapped one. It clears itself the
|
||||
moment a probe succeeds — there is no "retry" button, because the client is
|
||||
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
|
||||
|
||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||
|
||||
+48
-10
@@ -160,6 +160,7 @@ The state word is never spelled out — the colour carries it.
|
||||
| ---------------- | --------- | ---------------- |
|
||||
| `Active` | `--live` | `active` |
|
||||
| `Idle` | `--muted` | `idle` |
|
||||
| `Starting` | `--muted` | `idle` |
|
||||
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||
| `Error` | `--danger`| `error` |
|
||||
|
||||
@@ -189,7 +190,7 @@ furthest down wins the paint.
|
||||
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
|
||||
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
|
||||
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
|
||||
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
|
||||
| Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) |
|
||||
|
||||
The web ladder is written as one ordered run of equal-specificity rules in
|
||||
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
||||
@@ -231,7 +232,24 @@ cutover points land within one tick.
|
||||
|
||||
## Precedence
|
||||
|
||||
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
|
||||
### Activity source by session kind
|
||||
|
||||
The daemon supplies one `activity` field to both clients, but its source depends
|
||||
on the session kind:
|
||||
|
||||
| Session kind | Source |
|
||||
|---|---|
|
||||
| Terminal | The existing agent-report override and five-minute TTL, falling back to PTY quiescence, foreground process and `/proc` input-block inference. |
|
||||
| Structured | ACP state directly: a pending approval is `AwaitingInput`; otherwise an in-flight turn is `Active`; no turn is `Idle`. A failed turn or dead structured session is `Error`, and the next turn clears a failed-turn error. |
|
||||
|
||||
Structured sessions never enter the terminal inference path and ignore activity
|
||||
reports inherited through agent hooks. They have no PTY whose output or blocked
|
||||
read could describe their state. This is a second source, not a replacement for
|
||||
the terminal source.
|
||||
|
||||
### Terminal report override
|
||||
|
||||
For terminal sessions, an agent-reported `ActivityState` (#105) overrides the daemon's inference for
|
||||
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
|
||||
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
|
||||
@@ -250,6 +268,10 @@ Two rulings specific to a reported `Error` (#114):
|
||||
process changes do not clear it — only the agent itself may withdraw what
|
||||
the agent itself reported. Re-reporting `Error` extends the TTL rather than
|
||||
stacking reports.
|
||||
- **Only Claude Code reports `Error`.** Codex has no counterpart to
|
||||
`StopFailure` in its hook event set, so a codex turn that ends on an API
|
||||
error reports `AwaitingInput` and its card carries no error mark. A card
|
||||
without the mark means "nothing reported a failure", not "nothing failed".
|
||||
|
||||
If a client receives an `ActivityState` variant it does not know, it must not
|
||||
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
|
||||
@@ -277,10 +299,10 @@ session is genuinely `Active` again — new work, not merely a refocus. Only
|
||||
after that fresh `Active` cycle does the *next* `AwaitingInput` glow again.
|
||||
Unfocusing and refocusing alone never re-arms it.
|
||||
|
||||
This applies uniformly to `AwaitingInput` from either source — the daemon's
|
||||
own inference or an agent's report (#105) — since the rail draws them
|
||||
identically. It does not touch `Error`; that state's own TTL (below) is
|
||||
unrelated and unaffected by focus.
|
||||
This applies uniformly to `AwaitingInput` from terminal inference, an agent
|
||||
report (#105), or a structured session's pending approval, since the rail draws
|
||||
them identically. It does not touch `Error`; terminal reports keep their TTL,
|
||||
while a structured failed-turn error clears when the next turn starts.
|
||||
|
||||
Each client reports its own pane's focus with `POST /api/sessions/:id/focus`
|
||||
(`{"focused": bool}`) whenever the pane it is showing changes — on attach, on
|
||||
@@ -303,9 +325,10 @@ 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
|
||||
than jumping to the end of the list.
|
||||
|
||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
||||
`ctrl-w h`.
|
||||
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
|
||||
by default — and the same chord back): an attached terminal owns every plain
|
||||
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
|
||||
uses `ctrl-w h`.
|
||||
|
||||
Everything else is the same key on both clients:
|
||||
|
||||
@@ -316,7 +339,7 @@ Everything else is the same key on both clients:
|
||||
| `Tab` | hand the keyboard to the pane |
|
||||
| `c` | collapse or expand the highlighted folder |
|
||||
| `r` | rename the highlighted session |
|
||||
| `x` | kill the highlighted session |
|
||||
| `x` | kill the highlighted session (confirms) |
|
||||
| `n` | open the spawn form |
|
||||
| `N` | spawn with every default resolved |
|
||||
| `?` | show the shortcut overlay |
|
||||
@@ -324,3 +347,18 @@ Everything else is the same key on both clients:
|
||||
The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||
web client's are fixed. A binding added to one client belongs in this table and
|
||||
in both help overlays.
|
||||
|
||||
## Killing a session
|
||||
|
||||
**Every kill confirms first, in both clients** (#160). A kill is destructive
|
||||
and unattended agents live in these sessions, so neither client sends
|
||||
`DELETE /api/sessions/:id` off a single press. The TUI raises
|
||||
`OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its
|
||||
`✕` button and the command bar's action page both use. Cancel holds the focus
|
||||
in the web's dialog, so nothing kills on a stray Enter.
|
||||
|
||||
The confirmation names what the card last saw in the way, when anything was —
|
||||
the daemon will refuse an unforced kill over such a worktree, and saying so
|
||||
before the attempt is cheaper than a refusal. What comes back from that refusal
|
||||
is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns
|
||||
what a forced kill leaves behind.
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
# 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.
|
||||
+12
-4
@@ -35,10 +35,18 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
|
||||
back to a short hash of the branch.
|
||||
|
||||
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
||||
a free one. The suffix names the **directory only**. The branch is whatever was
|
||||
requested; a second worktree of the same branch is not what happens here, because
|
||||
git refuses to check the same branch out twice and the branch picker already
|
||||
marks branches that are checked out elsewhere.
|
||||
a free one.
|
||||
|
||||
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
|
||||
itself, and the directory follows since it is derived from the branch — a
|
||||
random handle can otherwise reuse the name of a branch some earlier, already
|
||||
removed worktree left behind, and `git worktree add -b` refuses outright even
|
||||
though the directory is free.
|
||||
|
||||
For an *existing* branch (`create_branch: false`) the name must stay exact, so
|
||||
only the directory is suffixed — a second worktree of the same branch is not
|
||||
what happens here, because git refuses to check the same branch out twice and
|
||||
the branch picker already marks branches that are checked out elsewhere.
|
||||
|
||||
### The `worktree_root` override
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"_description": "the parity fixture for #199's command resolver — every case here is asserted identically by crates/maestro-protocol/src/display.rs (command_program/command_tool) and web/src/toolIcon.ts (splitCommand). `program` is the resolved, basenamed program; `tool` is the icon id it maps to, or null when unrecognised/ambiguous.",
|
||||
"cases": [
|
||||
{ "command": "claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "claude --resume", "program": "claude", "tool": "claude" },
|
||||
{ "command": "CLAUDE", "program": "CLAUDE", "tool": "claude" },
|
||||
{ "command": "/usr/local/bin/claude -p hi", "program": "claude", "tool": "claude" },
|
||||
{ "command": "./bin/claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "FOO=1 BAR=2 claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "FOO=\"bar baz\" claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "FOO='bar baz' claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "FOO=bar\\ baz claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "'/usr/local/bin/claude'", "program": "claude", "tool": "claude" },
|
||||
{ "command": "\"/opt/my agent/claude\" --resume", "program": "claude", "tool": "claude" },
|
||||
{ "command": "env -i claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "env --ignore-environment claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo -u root claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo --user=root claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo -- claude --resume", "program": "claude", "tool": "claude" },
|
||||
{ "command": "nohup claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time -p claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time -- claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time -p -- claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "time -pp claude", "program": "time", "tool": null },
|
||||
{ "command": "time -p -p claude", "program": "time", "tool": null },
|
||||
{ "command": "time -o /tmp/t.log claude", "program": "time", "tool": null },
|
||||
{ "command": "command claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "command -v claude", "program": "command", "tool": null },
|
||||
{ "command": "command -V claude", "program": "command", "tool": null },
|
||||
{ "command": "npx --yes claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "npx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm -C ./pkg dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm dlx -s claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm dlx --silent claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "yarn --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "yarn dlx -q claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "yarn dlx --quiet claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bun x claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bunx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "uvx claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bun x --bun claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bunx --bun claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bun x -p @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bunx --package @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bun x --package=@anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bunx --no-install claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bun x --verbose claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bunx --silent claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "bun --bun x claude-code", "program": "bun", "tool": null },
|
||||
{ "command": "pnpm dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "pnpm dlx --package= claude-code", "program": "pnpm", "tool": null },
|
||||
{ "command": "pnpm dlx --package", "program": "pnpm", "tool": null },
|
||||
{ "command": "pnpm dlx -c \"claude-code\"", "program": "pnpm", "tool": null },
|
||||
{ "command": "pnpm --package=typescript dlx tsc", "program": "tsc", "tool": null },
|
||||
{ "command": "pnpm dlx --unknown-flag claude-code", "program": "pnpm", "tool": null },
|
||||
{ "command": "yarn dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "yarn dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "yarn dlx -p foo claude-code", "program": "claude-code", "tool": "claude" },
|
||||
{ "command": "yarn dlx --package= claude-code", "program": "yarn", "tool": null },
|
||||
{ "command": "yarn dlx -p", "program": "yarn", "tool": null },
|
||||
{ "command": "yarn dlx -p --quiet claude-code", "program": "yarn", "tool": null },
|
||||
{ "command": "bunx --package= claude-code", "program": "bunx", "tool": null },
|
||||
{ "command": "bunx --package", "program": "bunx", "tool": null },
|
||||
{ "command": "bun x -p --silent claude-code", "program": "bun", "tool": null },
|
||||
{ "command": "sudo env -i claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "FOO=1 sudo BAR=2 claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "env -iv claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo -nE claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo -uroot claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo -u", "program": "sudo", "tool": null },
|
||||
{ "command": "env --unset= claude", "program": "env", "tool": null },
|
||||
{ "command": "sudo --user= claude", "program": "sudo", "tool": null },
|
||||
{ "command": "sudo --user \"\" claude", "program": "sudo", "tool": null },
|
||||
{ "command": "sudo -nu claude", "program": "sudo", "tool": null },
|
||||
{ "command": "env -S 'python3 -u' script.py", "program": "env", "tool": null },
|
||||
{ "command": "npx -c \"claude --version\"", "program": "npx", "tool": null },
|
||||
{ "command": "npx --call \"claude --version\"", "program": "npx", "tool": null },
|
||||
{ "command": "command FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "nohup FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "npx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||
{ "command": "pnpm dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||
{ "command": "yarn dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||
{ "command": "bun x FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||
{ "command": "bunx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||
{ "command": "uvx FOO=1 claude-code", "program": "FOO=1", "tool": null },
|
||||
{ "command": "sudo time FOO=1 claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "nohup env FOO=1 claude", "program": "claude", "tool": "claude" },
|
||||
{ "command": "sudo command FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "command nohup FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "time command FOO=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "\"FOO=1\" claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "FOO\\=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "F\"O\"O=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "F\\OO=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "\"FOO\"=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "F\"\"OO=1 claude", "program": "FOO=1", "tool": null },
|
||||
{ "command": "claude\nclaude-code", "program": "claude", "tool": null },
|
||||
{ "command": "clau\\\nde --resume", "program": "claude", "tool": "claude" },
|
||||
{ "command": "npx opencode", "program": "opencode", "tool": "opencode" },
|
||||
{ "command": "pnpm dlx opencode run", "program": "opencode", "tool": "opencode" },
|
||||
{ "command": "pnpm claude", "program": "pnpm", "tool": null },
|
||||
{ "command": "yarn claude", "program": "yarn", "tool": null },
|
||||
{ "command": "claude-code-acp", "program": "claude-code-acp", "tool": null },
|
||||
{ "command": "hx src/main.rs", "program": "hx", "tool": null },
|
||||
{ "command": "bash", "program": "bash", "tool": "shell" },
|
||||
{ "command": "opencode", "program": "opencode", "tool": "opencode" },
|
||||
{ "command": "sudo", "program": "sudo", "tool": null },
|
||||
{ "command": "", "program": "", "tool": null },
|
||||
{ "command": "npx --unknown-flag claude-code", "program": "npx", "tool": null },
|
||||
{ "command": "claude | tee log.txt", "program": "claude", "tool": null },
|
||||
{ "command": "claude; echo done", "program": "claude;", "tool": null },
|
||||
{ "command": "claude && echo done", "program": "claude", "tool": null },
|
||||
{ "command": "echo hi $(claude)", "program": "echo", "tool": null },
|
||||
{ "command": "claude \"unterminated", "program": "claude", "tool": null }
|
||||
]
|
||||
}
|
||||
@@ -148,7 +148,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
|
||||
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
Generated
+14
-1
@@ -11,7 +11,8 @@
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"marked": "^18.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.11.6",
|
||||
@@ -1222,6 +1223,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.11",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
|
||||
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
+3
-2
@@ -6,7 +6,7 @@
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b tsconfig.build.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -16,7 +16,8 @@
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"marked": "^18.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.11.6",
|
||||
|
||||
Generated
+10
@@ -20,6 +20,9 @@ importers:
|
||||
'@xterm/xterm':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
marked:
|
||||
specifier: ^18.0.11
|
||||
version: 18.0.11
|
||||
devDependencies:
|
||||
happy-dom:
|
||||
specifier: ^20.11.6
|
||||
@@ -424,6 +427,11 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
marked@18.0.11:
|
||||
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -864,6 +872,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
marked@18.0.11: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
+68
-1
@@ -11,6 +11,9 @@ import type {
|
||||
Projects,
|
||||
RecentDirs,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
StructuredConfigRequest,
|
||||
StructuredPromptRequest,
|
||||
Uuid,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -91,6 +94,21 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Change one option advertised by a live ACP session. */
|
||||
export async function setStructuredConfig(
|
||||
id: Uuid,
|
||||
req: StructuredConfigRequest,
|
||||
): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredConfig(id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Report whether this client's pane is the one currently showing `id` — not
|
||||
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||
@@ -152,4 +170,53 @@ export async function fetchBranches(path: string): Promise<Branches> {
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export { eventsWsUrl } from "./routes.js";
|
||||
/** Send a prompt to a structured session's ACP turn (#138). `409
|
||||
* turn_in_flight` when one is already active — the caller reflects that from
|
||||
* `ReducedState.turn` instead of racing this call. */
|
||||
export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredPrompt(id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ prompt } satisfies StructuredPromptRequest),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
+38
-61
@@ -4,24 +4,15 @@ import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
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 { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
import type { ClientControl, Uuid } from "./types.js";
|
||||
import { WebglPolicy } from "./webglRenderer.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 },
|
||||
);
|
||||
export type { AttachInfo } from "./paneTitle.js";
|
||||
|
||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||
* rationed rather than handed to every connection. */
|
||||
@@ -38,21 +29,7 @@ const webglPolicy = new WebglPolicy();
|
||||
* session ending) tears the socket down — so switching away and back is
|
||||
* instant, with scrollback already in place.
|
||||
*/
|
||||
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;
|
||||
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
||||
dispose(): void;
|
||||
}
|
||||
export interface AttachConnection extends PaneConnection {}
|
||||
|
||||
export interface AttachOptions {
|
||||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
||||
@@ -66,23 +43,6 @@ export interface AttachOptions {
|
||||
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(
|
||||
sessionId: Uuid,
|
||||
opts: AttachOptions = {},
|
||||
@@ -92,21 +52,19 @@ export function createAttachConnection(
|
||||
el.className = "attach";
|
||||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
||||
// tooltip for when you really need it.
|
||||
const titleHtml = 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;
|
||||
const titleHtml = paneTitleHtml(sessionId, info);
|
||||
const titleAttr = paneTitleAttr(sessionId, info);
|
||||
el.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
||||
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||
</header>
|
||||
<div class="attach-body"></div>
|
||||
`;
|
||||
|
||||
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
|
||||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
@@ -217,15 +175,6 @@ 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
|
||||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||
// • normal buffer → term.scrollLines walks the scrollback.
|
||||
@@ -375,6 +324,9 @@ export function createAttachConnection(
|
||||
let ws: WebSocket;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
|
||||
// Follow it once per socket; later scrolling remains under the operator's control.
|
||||
let awaitingReplay = true;
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
@@ -394,6 +346,7 @@ export function createAttachConnection(
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
awaitingReplay = true;
|
||||
ws = new WebSocket(attachWsUrl(sessionId));
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
@@ -414,10 +367,20 @@ export function createAttachConnection(
|
||||
return;
|
||||
}
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(ev.data));
|
||||
const replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(ev.data), () => {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
} else if (ev.data instanceof Blob) {
|
||||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
||||
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
|
||||
void ev.data.arrayBuffer().then((b) => {
|
||||
const replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(b), () => {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -466,6 +429,12 @@ export function createAttachConnection(
|
||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||
sendControl({ type: "Resize", cols, rows });
|
||||
});
|
||||
// xterm has no built-in copy-on-select; mirror the selection to the
|
||||
// clipboard ourselves (best-effort — insecure contexts lack the API).
|
||||
const selectionDisp = term.onSelectionChange(() => {
|
||||
const text = term.getSelection();
|
||||
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
const visual = window.visualViewport ?? null;
|
||||
@@ -506,6 +475,13 @@ export function createAttachConnection(
|
||||
focusTerminal() {
|
||||
term.focus();
|
||||
},
|
||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||
// bar already take.
|
||||
sendInput,
|
||||
updateInfo(next: AttachInfo) {
|
||||
titleEl.innerHTML = paneTitleHtml(sessionId, next);
|
||||
titleEl.title = paneTitleAttr(sessionId, next);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
@@ -518,6 +494,7 @@ export function createAttachConnection(
|
||||
visual?.removeEventListener("resize", onResize);
|
||||
dataDisp.dispose();
|
||||
resizeDisp.dispose();
|
||||
selectionDisp.dispose();
|
||||
releaseWebgl();
|
||||
try {
|
||||
ws.close();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The sidebar's half of the command bar's action page (#151): the bar picks
|
||||
// kill or rename and raises the flow the card already has, so what the page
|
||||
// runs is what the ✕ button and the `r` key run.
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
});
|
||||
|
||||
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector(".kill-dialog")?.remove();
|
||||
mounted?.dispose();
|
||||
mounted = null;
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: { name: "amber-falcon" },
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function mount(s: Session): HTMLElement {
|
||||
const handle = renderSidebar(storeWith([s]));
|
||||
mounted = handle;
|
||||
document.body.appendChild(handle.el);
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function raise(name: string, s: Session): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
||||
}
|
||||
|
||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("kill from the action page", () => {
|
||||
it("goes through the same confirmation the sidebar raises (#160)", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const s = session();
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
|
||||
"Kill amber-falcon",
|
||||
);
|
||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
||||
window.fetch = ((url: string) => {
|
||||
if (String(url).includes("force=true")) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "kill_guarded",
|
||||
message: "worktree has unsaved work",
|
||||
unsaved_work: { untracked_files: 1 },
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const s = session({ blocked_work: { untracked_files: 1 } });
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename from the action page", () => {
|
||||
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
||||
folder?.click();
|
||||
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
||||
});
|
||||
|
||||
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||
expect(input?.value).toBe("amber-falcon");
|
||||
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
||||
});
|
||||
});
|
||||
+40
-1
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
|
||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
});
|
||||
|
||||
it("kills straight away, without a first confirmation", async () => {
|
||||
it("confirms before an unguarded kill (#160)", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
|
||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("kills nothing when the confirmation is cancelled (#160)", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||
await flush();
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
|
||||
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000008",
|
||||
blocked_work: { untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
expect(dialog?.textContent).toContain("refused unless it is forced");
|
||||
});
|
||||
|
||||
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||
|
||||
+119
-47
@@ -1,12 +1,13 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||
import { openForceKillDialog } from "./killDialog.js";
|
||||
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
||||
import { openContextMenu } from "./contextMenu.js";
|
||||
import { toggleHelp } from "./help.js";
|
||||
import { CommandSet, defineCommands } from "./commands.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
import {
|
||||
abbreviateCwd,
|
||||
abbreviateFolderName,
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
sessionBlockers,
|
||||
isProvisionalName,
|
||||
sessionName,
|
||||
sessionTreePath,
|
||||
} from "./types.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import {
|
||||
@@ -37,19 +39,35 @@ export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to open the command bar with an empty query. Dispatched by
|
||||
* the sidebar's search field, which is the touch entry point to the bar
|
||||
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
|
||||
* Handled in main.ts, which owns the session snapshot the bar needs. */
|
||||
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
|
||||
export function requestOpenCommandBar(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||
* connection pool. */
|
||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||
|
||||
/** Ask the shell to 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
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
/** Ask the sidebar to kill a session — the command bar's action page picking
|
||||
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
||||
* through the same confirm-and-blockers flow the card's ✕ does
|
||||
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
||||
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
||||
|
||||
/** Ask the sidebar to rename a session — the command bar's action page picking
|
||||
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
||||
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
||||
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
||||
|
||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||
* hash navigation, but a split/drag can move focus without ever touching
|
||||
* `location.hash` — so a click back onto a session whose hash still
|
||||
@@ -208,7 +226,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void requestKill(cur, sessionName(cur));
|
||||
requestKill(cur, sessionName(cur));
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||
@@ -226,7 +244,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
||||
onSelect: () => requestKill(cur, sessionName(cur)),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -271,26 +289,33 @@ function treeSignature(node: TreeNode): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
||||
* a forced kill. Anything else surfaces as an alert.
|
||||
* Kill behind a confirmation (#160): a kill is destructive and unattended
|
||||
* agents live in these sessions, so the web asks first, the way the TUI always
|
||||
* has. The daemon decides the rest — it refuses a kill over a worktree holding
|
||||
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
|
||||
* a forced kill. Anything else surfaces in the dialog that asked.
|
||||
*/
|
||||
async function requestKill(s: Session, name: string): Promise<void> {
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
openForceKillDialog({
|
||||
name,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
reason: e.detail,
|
||||
onForce: () => killSession(s.id, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
alert(`Failed to kill: ${(e as Error).message}`);
|
||||
}
|
||||
function requestKill(s: Session, name: string): void {
|
||||
openKillConfirmDialog({
|
||||
name,
|
||||
blockers: sessionBlockers(s),
|
||||
onKill: async () => {
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
openForceKillDialog({
|
||||
name,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
reason: e.detail,
|
||||
onForce: () => killSession(s.id, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,16 +373,12 @@ function beginRename(row: HTMLElement, s: Session): void {
|
||||
input.select();
|
||||
}
|
||||
|
||||
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||
// The tree has one empty state: no sessions at all. The old "No matches" one
|
||||
// belonged to the inline substring filter, which the command bar replaced
|
||||
// (#157) — the bar's own no-results state took over with it.
|
||||
function renderEmpty(onSpawn: () => void): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "empty";
|
||||
if (filtering) {
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">⌕</div>
|
||||
<div class="empty-title">No matches</div>
|
||||
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
||||
return el;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||
<div class="empty-title">No sessions yet</div>
|
||||
@@ -428,12 +449,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// renders with absolute paths until it lands.
|
||||
let homeDir: string | undefined;
|
||||
|
||||
// The field never holds a query: it is the touch entry point to the command
|
||||
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
|
||||
// on-screen keyboard over a field nothing is typed into.
|
||||
const searchHeader = document.createElement("header");
|
||||
searchHeader.className = "sidebar-search";
|
||||
searchHeader.innerHTML = `
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
readonly
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autocomplete="off"
|
||||
@@ -542,11 +567,23 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
handle.addEventListener("pointerup", endDrag);
|
||||
handle.addEventListener("pointercancel", endDrag);
|
||||
|
||||
// Touch entry (#157): the field is a button that looks like a field. It
|
||||
// never holds a query — focusing or tapping it hands the keyboard straight
|
||||
// to the bar's own input, which is also why it blurs first: leaving the
|
||||
// focus here would make the bar impossible to close without reopening it.
|
||||
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
||||
let filter = "";
|
||||
search.addEventListener("input", () => {
|
||||
filter = search.value;
|
||||
refresh();
|
||||
const openBar = () => {
|
||||
search.value = "";
|
||||
search.blur();
|
||||
requestOpenCommandBar();
|
||||
};
|
||||
search.addEventListener("focus", openBar);
|
||||
// A tap can land without a focus event (iOS Safari on a blurred field), so
|
||||
// the pointer path opens the bar too; the blur above keeps the two from
|
||||
// stacking two overlays.
|
||||
search.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
openBar();
|
||||
});
|
||||
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||
@@ -626,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
applyNav();
|
||||
};
|
||||
|
||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
||||
// out: there is no hover there, and a tap already opens the session.
|
||||
// Pointer hover previews only while Ctrl is held — plain hover was too
|
||||
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
||||
// unconditionally via applyNav.
|
||||
let hovered: string | null = null;
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
@@ -635,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
opts.onPreview?.(id);
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||
setHovered(row?.dataset.sessionId ?? null);
|
||||
});
|
||||
@@ -685,7 +723,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
},
|
||||
"list.kill": () => {
|
||||
const hit = highlightedSession();
|
||||
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
||||
if (hit) requestKill(hit.session, sessionName(hit.session));
|
||||
},
|
||||
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
||||
}),
|
||||
@@ -703,8 +741,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
if (renamingId !== null) return;
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir, filter);
|
||||
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
||||
const tree = buildTree(sessions, homeDir);
|
||||
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
|
||||
if (signature === renderedSignature) {
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||
@@ -715,9 +753,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||
);
|
||||
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
||||
return;
|
||||
}
|
||||
if (tree.kind === "folder") {
|
||||
@@ -745,6 +781,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
};
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// The command bar's action page (#151) picks an action and hands it here:
|
||||
// both flows are the ones the card already has, reached by session rather
|
||||
// than by row.
|
||||
const killHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
requestKill(s, sessionName(s));
|
||||
};
|
||||
const renameHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
const row = rowForSession(s);
|
||||
if (row) beginRename(row, s);
|
||||
};
|
||||
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
|
||||
/** The card a session is rendered as, expanding whatever folder hides it
|
||||
* first: the rename prompt lives inside the card, so a collapsed folder
|
||||
* would otherwise swallow the request. */
|
||||
function rowForSession(s: Session): HTMLElement | null {
|
||||
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
||||
const found = find();
|
||||
if (found) return found;
|
||||
// Folder paths are the abbreviated directory's segments joined back up
|
||||
// (see buildTree), so the session's own path is normalised the same way
|
||||
// before it is compared against them.
|
||||
const cwd = sessionTreePath(s);
|
||||
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
||||
for (const folder of [...collapsed]) {
|
||||
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
||||
}
|
||||
rerenderHandler();
|
||||
return find();
|
||||
}
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||
|
||||
@@ -794,6 +864,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// refusing to refresh.
|
||||
renamingId = null;
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
|
||||
+420
-6
@@ -1,18 +1,30 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
closeCommandBar,
|
||||
commandBarCandidates,
|
||||
dedupeCommands,
|
||||
directoryBarCandidates,
|
||||
directoryCandidate,
|
||||
isCommandBarOpen,
|
||||
openCommandBar,
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionBarCandidates,
|
||||
sessionActionCandidates,
|
||||
sessionCandidate,
|
||||
SPAWN_DIRECTORY_EVENT,
|
||||
} from "./commandBar.js";
|
||||
import { COMMAND_SPECS } from "./commands.js";
|
||||
import { COMMAND_SPECS, type Command } from "./commands.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
@@ -173,8 +185,12 @@ describe("the overlay", () => {
|
||||
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
// Sessions only: the mixed list's command half is the sigil describe's
|
||||
// business, and the registry is whatever another test file happened to bind.
|
||||
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
||||
return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} });
|
||||
return openCommandBar(
|
||||
over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} },
|
||||
);
|
||||
}
|
||||
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
@@ -252,7 +268,7 @@ describe("the overlay", () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
openCommandBar({ sessions, homeDir: "/home/me" });
|
||||
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
@@ -262,7 +278,7 @@ describe("the overlay", () => {
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
@@ -301,9 +317,407 @@ describe("the overlay", () => {
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the sigils (#150) ----
|
||||
|
||||
function command(id: string, label: string, keys: string[], run = () => {}): Command {
|
||||
return { id, label, group: "Test", keys, scope: "global", run };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function rowKeys(): string[][] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
|
||||
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
describe("parsing the query line", () => {
|
||||
it("reads a sigil only in first position", () => {
|
||||
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
|
||||
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
|
||||
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
||||
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
|
||||
});
|
||||
|
||||
it("leaves a sigil character elsewhere in the query literal", () => {
|
||||
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
|
||||
// The case that matters: the second `~` of a directory query reaches the
|
||||
// matcher.
|
||||
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
|
||||
});
|
||||
|
||||
it("returns to the mixed list when the sigil is backspaced away", () => {
|
||||
expect(parseBarQuery(">").sigil).toBe(">");
|
||||
expect(parseBarQuery("").sigil).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command candidates", () => {
|
||||
it("dedupes by action, not by chord", () => {
|
||||
const run = () => {};
|
||||
const kill = command("a.kill", "Kill the session", ["x"], run);
|
||||
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
|
||||
const other = command("c.rename", "Rename the session", ["r"]);
|
||||
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
|
||||
});
|
||||
|
||||
it("matches on the label only, and keeps the chord for display", () => {
|
||||
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
|
||||
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
|
||||
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory candidates", () => {
|
||||
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
||||
const c = directoryCandidate("/home/me/dev/api", "/home/me");
|
||||
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
|
||||
});
|
||||
|
||||
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
||||
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
|
||||
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
|
||||
"/home/me/dev/api",
|
||||
"/tmp/scratch",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sigils in the overlay", () => {
|
||||
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
|
||||
const ran: string[] = [];
|
||||
const commands = [
|
||||
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
|
||||
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
ran.length = 0;
|
||||
});
|
||||
|
||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||
return openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands,
|
||||
onOpenSession: () => {},
|
||||
loadDirectories: async () => [],
|
||||
listDirEntries: async () => [],
|
||||
onSpawnDirectory: () => {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||
open();
|
||||
type(">");
|
||||
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
|
||||
expect(rowKeys()).toEqual([["n"], ["x"]]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("matches a command on its label and never on its chord", () => {
|
||||
open();
|
||||
type(">kill");
|
||||
expect(rowNames()).toEqual(["Kill the highlighted session"]);
|
||||
// `n` and `x` are chords, not label text: querying one finds nothing that
|
||||
// the label does not also justify.
|
||||
type(">ctrlx");
|
||||
expect(rowNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the highlighted command on Enter", () => {
|
||||
open();
|
||||
type(">kill");
|
||||
press("Enter");
|
||||
expect(ran).toEqual(["list.kill"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||
open();
|
||||
type("s");
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
"Commands",
|
||||
]);
|
||||
type(">spawn");
|
||||
expect(rowNames()).toEqual(["Open the spawn form"]);
|
||||
// Backspacing the sigil away is ordinary editing.
|
||||
type("spawn");
|
||||
expect(rowNames()).toContain("Open the spawn form");
|
||||
type("");
|
||||
expect(rowNames()).toEqual(["api"]);
|
||||
});
|
||||
|
||||
it("lists recent dirs then projects under `~`", async () => {
|
||||
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
|
||||
type("~");
|
||||
await flush();
|
||||
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||
const asked: string[] = [];
|
||||
open({
|
||||
loadDirectories: async () => ["/home/me/notes"],
|
||||
listDirEntries: async (dir) => {
|
||||
asked.push(dir);
|
||||
return ["maestro", "api"];
|
||||
},
|
||||
});
|
||||
// The second `~` is a literal query character and reaches the matcher.
|
||||
type("~~/dev/mae");
|
||||
await flush();
|
||||
expect(asked).toEqual(["~/dev/"]);
|
||||
expect(rowNames()).toEqual(["~/dev/maestro"]);
|
||||
});
|
||||
|
||||
it("spawns in the highlighted directory on Enter", async () => {
|
||||
const spawned: string[] = [];
|
||||
open({
|
||||
loadDirectories: async () => ["~/dev/api"],
|
||||
onSpawnDirectory: (path) => spawned.push(path),
|
||||
});
|
||||
type("~api");
|
||||
await flush();
|
||||
press("Enter");
|
||||
expect(spawned).toEqual(["/home/me/dev/api"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands,
|
||||
onOpenSession: () => {},
|
||||
loadDirectories: async () => ["/home/me/dev/api"],
|
||||
listDirEntries: async () => [],
|
||||
});
|
||||
type("~");
|
||||
await flush();
|
||||
press("Enter");
|
||||
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
expect(seen).toEqual(["/home/me/dev/api"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the focus round-trip over a pane (#152)", () => {
|
||||
function terminalTextarea(): HTMLTextAreaElement {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.className = "xterm-helper-textarea";
|
||||
document.body.appendChild(ta);
|
||||
return ta;
|
||||
}
|
||||
|
||||
it("hands the keyboard back to the terminal that had it", () => {
|
||||
const ta = terminalTextarea();
|
||||
ta.focus();
|
||||
openCommandBar({ sessions: [], commands: [] });
|
||||
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
|
||||
press("Escape");
|
||||
expect(document.activeElement).toBe(ta);
|
||||
});
|
||||
|
||||
it("reports no focus change to the daemon, opening or closing", () => {
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
try {
|
||||
const ta = terminalTextarea();
|
||||
ta.focus();
|
||||
openCommandBar({ sessions: [], commands: [] });
|
||||
press("Escape");
|
||||
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
|
||||
String(url).includes("/focus"),
|
||||
);
|
||||
expect(focusCalls).toEqual([]);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page (#151)", () => {
|
||||
const sessions = [
|
||||
session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
last_focused_at: "2026-01-01T11:00:00Z",
|
||||
}),
|
||||
session("worker", {
|
||||
metadata: { name: "worker" },
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
];
|
||||
|
||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||
return openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [],
|
||||
onOpenSession: () => {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function headerText(): string | undefined {
|
||||
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
expect(activeName()).toBe("Kill session");
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
type("spwn");
|
||||
expect(rowNames()).toEqual(["Spawn similar"]);
|
||||
type("zzz");
|
||||
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
|
||||
onOpenSession: () => {},
|
||||
});
|
||||
type(">");
|
||||
const before = rowNames();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(before);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
|
||||
type("~");
|
||||
await Promise.resolve();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["~/dev/api"]);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type("");
|
||||
press("Backspace");
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
press("Backspace");
|
||||
expect(headerText()).toContain("api");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
const seen: string[] = [];
|
||||
for (const [hook, key] of [
|
||||
["onKillSession", "Kill session"],
|
||||
["onRenameSession", "Rename session"],
|
||||
["onSpawnSimilar", "Spawn similar"],
|
||||
] as const) {
|
||||
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type(key);
|
||||
press("Enter");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
"onKillSession:worker",
|
||||
"onRenameSession:worker",
|
||||
"onSpawnSimilar:worker",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
const seen: string[] = [];
|
||||
const listeners: Array<[string, EventListener]> = [
|
||||
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
];
|
||||
for (const [name, fn] of listeners) document.addEventListener(name, fn);
|
||||
try {
|
||||
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
|
||||
open();
|
||||
press("Tab");
|
||||
type(action);
|
||||
press("Enter");
|
||||
}
|
||||
} finally {
|
||||
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
|
||||
}
|
||||
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
|
||||
});
|
||||
|
||||
it("offers no action beyond the three", () => {
|
||||
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||
"kill",
|
||||
"rename",
|
||||
"spawn-similar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+460
-32
@@ -1,33 +1,58 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list, opened from the sidebar, closed with Escape.
|
||||
//
|
||||
// Sessions are the only kind here (#149); the sigils, the action page and the
|
||||
// terminal-focus binding land in #150-#152.
|
||||
// Three kinds live here: sessions and commands, ranked together by default,
|
||||
// and directories, which need the `~` sigil. `tab` on a session drills into
|
||||
// its action page (#151), which is the same overlay over three actions.
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
type CandidateKind,
|
||||
type CommandCandidate,
|
||||
type DirectoryCandidate,
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "./matcher.js";
|
||||
import { splitPath } from "./paths.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||
|
||||
/** A matcher candidate that remembers which session it came from, so a result
|
||||
* can be acted on without a second lookup. The extra property is invisible to
|
||||
* the matcher, which only ever reads `kind` and `fields`. */
|
||||
/** A matcher candidate that remembers what it came from, so a result can be
|
||||
* acted on without a second lookup. The extra property is invisible to the
|
||||
* matcher, which only ever reads `kind` and `fields`. */
|
||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||
export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
||||
* ranks; `path` is the one a spawn is made in. */
|
||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||
|
||||
export type BarCandidate = SessionBarCandidate;
|
||||
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||
* single `label` field, ranked exactly like a registry command — and carries
|
||||
* the action it stands for instead of a registry entry. */
|
||||
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||
|
||||
export type BarCandidate =
|
||||
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
||||
* shell owns the spawn, which is the same fast-forward path `N` takes. */
|
||||
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||
|
||||
/** One kind's slice of the ranked list, in rank order. */
|
||||
export interface BarGroup {
|
||||
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: BarResult[];
|
||||
results: MatchResult<C>[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
@@ -36,6 +61,63 @@ const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
directory: "Directories",
|
||||
};
|
||||
|
||||
// ---- the sigils ----
|
||||
|
||||
export type BarSigil = ">" | "~";
|
||||
|
||||
export interface BarQuery {
|
||||
/** The sigil in force, or null for the mixed list. */
|
||||
sigil: BarSigil | null;
|
||||
/** What the matcher sees: everything after a sigil, or the whole query. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** Split a raw query line into its sigil and the query proper.
|
||||
*
|
||||
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
||||
* literal query character, which is what a directory query with a second `~`
|
||||
* in it depends on. Backspacing past a sigil needs no special case either —
|
||||
* the first character goes and the query is a mixed one again. */
|
||||
export function parseBarQuery(raw: string): BarQuery {
|
||||
const first = raw[0];
|
||||
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
||||
return { sigil: null, query: raw };
|
||||
}
|
||||
|
||||
// ---- the session action page ----
|
||||
|
||||
/** The actions a session's action page offers, and no others: the bar picks
|
||||
* one and hands off to the flow that already implements it
|
||||
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||
|
||||
export interface SessionAction {
|
||||
id: SessionActionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||
{ id: "kill", label: "Kill session" },
|
||||
{ id: "rename", label: "Rename session" },
|
||||
{ id: "spawn-similar", label: "Spawn similar" },
|
||||
];
|
||||
|
||||
/** The action page's candidates, in the order above — which is the order an
|
||||
* empty query lists them in, since the matcher keeps input order. */
|
||||
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||
return SESSION_ACTIONS.map((action) => ({
|
||||
kind: "command",
|
||||
action,
|
||||
fields: { label: action.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||
* `command` kind, so what separates them is what they carry. */
|
||||
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||
return candidate.kind === "command" && "action" in candidate;
|
||||
}
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
@@ -99,14 +181,92 @@ export function sessionBarCandidates(
|
||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||
}
|
||||
|
||||
/** Drop the commands that would list twice. Several chords can be bound to one
|
||||
* action — the registry already carries its aliases on one entry, but nothing
|
||||
* stops two entries running the same handler, and listed naively that is two
|
||||
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
||||
* displayed, so a command with two of them is still one row. */
|
||||
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
||||
const seenRun = new Set<Command["run"]>();
|
||||
const seenLabel = new Set<string>();
|
||||
const kept: Command[] = [];
|
||||
for (const command of commands) {
|
||||
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
||||
seenRun.add(command.run);
|
||||
seenLabel.add(command.label);
|
||||
kept.push(command);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Every bound command as a candidate, in registry order. Only the label is a
|
||||
* field: the chord is displayed beside the row, never matched, because
|
||||
* matching it would make `>` results depend on a user's rebinds. */
|
||||
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
||||
return dedupeCommands(commands).map((command) => ({
|
||||
kind: "command",
|
||||
command,
|
||||
fields: { label: command.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
||||
* a path the operator typed or `/api/dirs` completed. */
|
||||
export function expandHome(path: string, homeDir?: string): string {
|
||||
if (!homeDir) return path;
|
||||
if (path === "~") return homeDir;
|
||||
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
||||
}
|
||||
|
||||
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
||||
const absolute = expandHome(path, homeDir);
|
||||
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
||||
}
|
||||
|
||||
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
||||
* first mention of each — recent dirs (newest first) ahead of the project
|
||||
* list, which is the empty-query order the contract asks for. */
|
||||
export function directoryBarCandidates(
|
||||
paths: readonly string[],
|
||||
homeDir?: string,
|
||||
): DirectoryBarCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DirectoryBarCandidate[] = [];
|
||||
for (const path of paths) {
|
||||
if (path === "") continue;
|
||||
const candidate = directoryCandidate(path, homeDir);
|
||||
if (seen.has(candidate.path)) continue;
|
||||
seen.add(candidate.path);
|
||||
out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The directory list the bar starts from: what the spawn form already knows —
|
||||
* `GET /api/recent-dirs` and the project list — either alone if the other
|
||||
* fails. */
|
||||
export async function loadBarDirectories(): Promise<string[]> {
|
||||
const [recents, discovered] = await Promise.all([
|
||||
recentDirs()
|
||||
.then((r) => r.dirs.map((d) => d.cwd))
|
||||
.catch(() => [] as string[]),
|
||||
fetchProjects()
|
||||
.then((list) => list.map((p) => p.path))
|
||||
.catch(() => [] as string[]),
|
||||
]);
|
||||
return [...recents, ...discovered];
|
||||
}
|
||||
|
||||
// ---- grouping ----
|
||||
|
||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||
* and groups appear in the order their best-scoring member does — so the first
|
||||
* row under the first header is the globally best result. */
|
||||
export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
||||
const groups: BarGroup[] = [];
|
||||
export function barGroups<C extends BarCandidate>(
|
||||
results: readonly MatchResult<C>[],
|
||||
): BarGroup<C>[] {
|
||||
const groups: BarGroup<C>[] = [];
|
||||
for (const result of results) {
|
||||
const kind = result.candidate.kind;
|
||||
let group = groups.find((g) => g.kind === kind);
|
||||
@@ -122,7 +282,10 @@ export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||
* this list. */
|
||||
export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] {
|
||||
export function barRows<C extends BarCandidate>(
|
||||
query: string,
|
||||
candidates: readonly C[],
|
||||
): MatchResult<C>[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
@@ -145,9 +308,25 @@ export function renderHighlighted(text: string, ranges: readonly MatchRange[]):
|
||||
return frag;
|
||||
}
|
||||
|
||||
/** The secondary line under a row's name: the field the match was found in when
|
||||
* that was not the name, so a session found through its directory says so. */
|
||||
/** The name line of a row: the session's name, the command's label, or the
|
||||
* directory's path. */
|
||||
function rowName(candidate: BarCandidate): string {
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
return candidate.fields.name;
|
||||
case "command":
|
||||
return candidate.fields.label;
|
||||
case "directory":
|
||||
return candidate.fields.path;
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line under a session row: the field the match was found in
|
||||
* when that was not the name, so a session found through its directory says
|
||||
* so. Only sessions have one — a command shows keycaps instead, and a
|
||||
* directory's path is already the name. */
|
||||
function metaText(result: BarResult): string | undefined {
|
||||
if (result.candidate.kind !== "session") return undefined;
|
||||
const fields = result.candidate.fields;
|
||||
switch (result.field) {
|
||||
case "directory":
|
||||
@@ -162,22 +341,46 @@ function metaText(result: BarResult): string | undefined {
|
||||
}
|
||||
|
||||
function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
const candidate = result.candidate;
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmdbar-row";
|
||||
row.setAttribute("role", "option");
|
||||
row.id = `cmdbar-row-${index}`;
|
||||
row.dataset.index = String(index);
|
||||
row.dataset.sessionId = result.candidate.session.id;
|
||||
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
row.dataset.kind = candidate.kind;
|
||||
if (candidate.kind === "session") {
|
||||
row.dataset.sessionId = candidate.session.id;
|
||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
} else if (isActionCandidate(candidate)) {
|
||||
row.dataset.actionId = candidate.action.id;
|
||||
} else if (candidate.kind === "command") {
|
||||
row.dataset.commandId = candidate.command.id;
|
||||
} else {
|
||||
row.dataset.path = candidate.path;
|
||||
}
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "cmdbar-row-name";
|
||||
const nameText = result.candidate.fields.name;
|
||||
name.append(
|
||||
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
|
||||
);
|
||||
const nameText = rowName(candidate);
|
||||
const nameMatched =
|
||||
result.field === "name" || result.field === "label" || result.field === "path";
|
||||
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
|
||||
row.appendChild(name);
|
||||
|
||||
// The chord is teaching material, not a match target: it is rendered from
|
||||
// the live registry entry and never fed to the matcher.
|
||||
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
|
||||
const keys = document.createElement("span");
|
||||
keys.className = "cmdbar-row-keys";
|
||||
for (const cap of keyCaps(candidate.command.keys[0])) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.textContent = cap;
|
||||
keys.appendChild(kbd);
|
||||
}
|
||||
row.appendChild(keys);
|
||||
return row;
|
||||
}
|
||||
|
||||
const meta = metaText(result);
|
||||
if (meta) {
|
||||
const el = document.createElement("span");
|
||||
@@ -189,6 +392,12 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||
mixed: "Go to a session…",
|
||||
">": "Run a command…",
|
||||
"~": "Spawn in a directory…",
|
||||
};
|
||||
|
||||
// ---- the overlay ----
|
||||
|
||||
export interface CommandBarOptions {
|
||||
@@ -200,6 +409,25 @@ export interface CommandBarOptions {
|
||||
* sidebar's click event — which is what the contract requires; tests pass
|
||||
* their own. */
|
||||
onOpenSession?: (sessionId: string) => void;
|
||||
/** The command registry to list under `>`. Defaults to every bound command,
|
||||
* in registry order. */
|
||||
commands?: readonly Command[];
|
||||
/** The directory list behind `~`, fetched once on first use. */
|
||||
loadDirectories?: () => Promise<string[]>;
|
||||
/** One directory's subdirectories, for filling the list in as a path is
|
||||
* typed. Defaults to `GET /api/dirs`. */
|
||||
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||
/** Spawn a session in the highlighted directory. Defaults to raising
|
||||
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
||||
* spawn. */
|
||||
onSpawnDirectory?: (path: string) => void;
|
||||
/** The action page's three actions (#151). Each defaults to raising the
|
||||
* event the flow that already implements it listens on — the sidebar's
|
||||
* confirm-and-blockers kill, its inline rename prompt, the spawn form
|
||||
* seeded from the session. The bar picks the action and nothing more. */
|
||||
onKillSession?: (session: Session) => void;
|
||||
onRenameSession?: (session: Session) => void;
|
||||
onSpawnSimilar?: (session: Session) => void;
|
||||
}
|
||||
|
||||
interface OpenBar {
|
||||
@@ -228,8 +456,29 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
const openSession =
|
||||
opts.onOpenSession ??
|
||||
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
||||
const spawnDirectory =
|
||||
opts.onSpawnDirectory ??
|
||||
((path: string) =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
||||
const sessionEvent =
|
||||
(name: string) =>
|
||||
(session: Session): void => {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: session }));
|
||||
};
|
||||
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
|
||||
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
|
||||
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
|
||||
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
||||
const listEntries =
|
||||
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
||||
|
||||
const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir);
|
||||
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
|
||||
opts.sessions,
|
||||
opts.homeDir,
|
||||
);
|
||||
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
|
||||
opts.commands ?? publishedCommands(),
|
||||
);
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "cmdbar-overlay";
|
||||
@@ -246,10 +495,10 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
input.setAttribute("aria-controls", "cmdbar-results");
|
||||
input.setAttribute("aria-label", "Search sessions");
|
||||
input.setAttribute("aria-label", "Search sessions, commands and directories");
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.spellcheck = false;
|
||||
input.placeholder = "Go to a session…";
|
||||
input.placeholder = PLACEHOLDERS.mixed;
|
||||
|
||||
const results = document.createElement("div");
|
||||
results.className = "cmdbar-results";
|
||||
@@ -263,6 +512,98 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
let rows: BarResult[] = [];
|
||||
let highlighted = 0;
|
||||
|
||||
// The action page (#151). `page` is the session being acted on, `stashed`
|
||||
// the result-list query it was drilled into from — backing out restores it,
|
||||
// so the round trip costs the operator nothing.
|
||||
let page: Session | null = null;
|
||||
let stashed = "";
|
||||
let header: HTMLElement | null = null;
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
let directories: DirectoryBarCandidate[] = [];
|
||||
let directoriesRequested = false;
|
||||
let probed: DirectoryBarCandidate[] = [];
|
||||
let probedDir: string | null = null;
|
||||
const listings = new Map<string, string[]>();
|
||||
|
||||
function alive(): boolean {
|
||||
return current?.el === el;
|
||||
}
|
||||
|
||||
/** Fetch the recents-and-projects list the first time `~` is used. */
|
||||
function ensureDirectories(): void {
|
||||
if (directoriesRequested) return;
|
||||
directoriesRequested = true;
|
||||
loadDirectories()
|
||||
.then((paths) => {
|
||||
directories = directoryBarCandidates(paths, opts.homeDir);
|
||||
if (alive()) render();
|
||||
})
|
||||
.catch(() => {
|
||||
/* no directories is a shorter list, not an error state */
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
|
||||
* the directory it names is listed and its children join the candidates, so
|
||||
* `~` reaches a directory that was never spawned in. */
|
||||
function probeDirectories(query: string): void {
|
||||
const { dir } = splitPath(query);
|
||||
if (dir === "") {
|
||||
probed = [];
|
||||
probedDir = null;
|
||||
return;
|
||||
}
|
||||
if (dir === probedDir) return;
|
||||
probedDir = dir;
|
||||
const cached = listings.get(dir);
|
||||
if (cached) {
|
||||
probed = directoryBarCandidates(
|
||||
cached.map((entry) => `${dir}${entry}`),
|
||||
opts.homeDir,
|
||||
);
|
||||
return;
|
||||
}
|
||||
probed = [];
|
||||
listEntries(dir)
|
||||
.then((entries) => {
|
||||
listings.set(dir, entries);
|
||||
if (!alive() || probedDir !== dir) return;
|
||||
probed = directoryBarCandidates(
|
||||
entries.map((entry) => `${dir}${entry}`),
|
||||
opts.homeDir,
|
||||
);
|
||||
render();
|
||||
})
|
||||
.catch(() => {
|
||||
/* an unreadable directory contributes nothing */
|
||||
});
|
||||
}
|
||||
|
||||
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
||||
if (page) return sessionActionCandidates();
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
case "~": {
|
||||
ensureDirectories();
|
||||
probeDirectories(parsed.query);
|
||||
const seen = new Set<string>();
|
||||
return [...directories, ...probed].filter((c) =>
|
||||
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return parsed.query === ""
|
||||
? [...sessionCandidates]
|
||||
: [...sessionCandidates, ...commandCandidates];
|
||||
}
|
||||
}
|
||||
|
||||
function paintHighlight(): void {
|
||||
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
||||
const active = Number(row.dataset.index) === highlighted;
|
||||
@@ -276,8 +617,28 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
/** The line naming the session being acted on, so the target is never
|
||||
* ambiguous. Present only while the action page is up. */
|
||||
function paintHeader(): void {
|
||||
header?.remove();
|
||||
header = null;
|
||||
if (!page) return;
|
||||
header = document.createElement("div");
|
||||
header.className = "cmdbar-page-header";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = `Actions for "${sessionName(page)}"`;
|
||||
panel.insertBefore(header, results);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
rows = barRows(input.value, candidates);
|
||||
// On the action page the query line is over the three actions, so there is
|
||||
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
|
||||
input.placeholder = page
|
||||
? `Act on ${sessionName(page)}…`
|
||||
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||
paintHeader();
|
||||
rows = barRows(parsed.query, candidatesFor(parsed));
|
||||
// The highlight starts on the globally best row every time the list
|
||||
// changes: an old index would point at a different result.
|
||||
highlighted = 0;
|
||||
@@ -294,11 +655,15 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
|
||||
let index = 0;
|
||||
for (const group of barGroups(rows)) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = group.title;
|
||||
results.appendChild(header);
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped — and the action page is one kind too.
|
||||
if (parsed.sigil === null && !page) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = group.title;
|
||||
results.appendChild(header);
|
||||
}
|
||||
for (const result of group.results) {
|
||||
const at = index;
|
||||
const row = renderRow(result, at);
|
||||
@@ -317,19 +682,82 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||
* other kind, and none on the page itself, which has no session rows. */
|
||||
function drillIn(): void {
|
||||
const candidate = rows[highlighted]?.candidate;
|
||||
if (page || candidate?.kind !== "session") return;
|
||||
page = candidate.session;
|
||||
stashed = input.value;
|
||||
input.value = "";
|
||||
render();
|
||||
}
|
||||
|
||||
/** Escape, or backspace at an empty query: back to the result list with the
|
||||
* query the drill-in was made from. */
|
||||
function leavePage(): void {
|
||||
page = null;
|
||||
input.value = stashed;
|
||||
stashed = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function runAction(action: SessionAction, session: Session): void {
|
||||
close();
|
||||
switch (action.id) {
|
||||
case "kill":
|
||||
killSession(session);
|
||||
return;
|
||||
case "rename":
|
||||
renameSession(session);
|
||||
return;
|
||||
case "spawn-similar":
|
||||
spawnSimilar(session);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function activate(index: number): void {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const sessionId = result.candidate.session.id;
|
||||
const candidate = result.candidate;
|
||||
if (isActionCandidate(candidate)) {
|
||||
// The page cannot be up without a session to act on.
|
||||
if (page) runAction(candidate.action, page);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
openSession(sessionId);
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
openSession(candidate.session.id);
|
||||
return;
|
||||
case "command":
|
||||
candidate.command.run();
|
||||
return;
|
||||
case "directory":
|
||||
spawnDirectory(candidate.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
close();
|
||||
// On an action page Escape is one level up, not the way out.
|
||||
if (page) leavePage();
|
||||
else close();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
drillIn();
|
||||
return;
|
||||
case "Backspace":
|
||||
// Ordinary editing, except at an empty query on the action page.
|
||||
if (page && input.value === "") {
|
||||
e.preventDefault();
|
||||
leavePage();
|
||||
}
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
|
||||
+178
-14
@@ -4,12 +4,17 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
COMMAND_SPECS,
|
||||
CommandSet,
|
||||
commandSpecs,
|
||||
DEFAULT_LEADER,
|
||||
canonicalizeKey,
|
||||
controlByte,
|
||||
defineCommands,
|
||||
isBarTypingTarget,
|
||||
isTerminalTextarea,
|
||||
isTypingTarget,
|
||||
keyCaps,
|
||||
publishedCommands,
|
||||
type Command,
|
||||
} from "./commands.js";
|
||||
|
||||
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
@@ -128,31 +133,28 @@ describe("CommandSet", () => {
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
|
||||
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
|
||||
const plain = vi.fn();
|
||||
const chorded = vi.fn();
|
||||
const set = new CommandSet(
|
||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
const input = el("<input>");
|
||||
expect(pressOn(set, input, "n")).toBe(false);
|
||||
expect(plain).not.toHaveBeenCalled();
|
||||
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
|
||||
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
|
||||
// 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);
|
||||
});
|
||||
|
||||
it("keeps pane commands live inside the terminal's textarea", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
||||
preventDefault: false,
|
||||
});
|
||||
it("leaves a plain Escape for the pane instead of binding it", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
||||
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||
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);
|
||||
expect(pressOn(set, textarea, "Escape")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,6 +163,38 @@ describe("the leader", () => {
|
||||
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
||||
});
|
||||
|
||||
// #159: the leader is the operator's, served on `/api/info`. Every chord
|
||||
// behind it is built from it, so a rebound leader carries its escape hatches
|
||||
// with it instead of leaving them on a stale ctrl-a.
|
||||
it("builds the chords behind it from the configured leader", () => {
|
||||
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.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.
|
||||
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||
});
|
||||
|
||||
it("dispatches a configured leader's chord and not the default's", () => {
|
||||
const literalCtrlK = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
|
||||
leader: "ctrl-b",
|
||||
});
|
||||
set.handle(key("b", { ctrlKey: true }));
|
||||
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
|
||||
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The old leader is an ordinary key again: it holds nothing, and the chord
|
||||
// that used to follow it runs nothing.
|
||||
set.handle(key("a", { ctrlKey: true }));
|
||||
set.handle(key("k", { ctrlKey: true }));
|
||||
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends the configured leader's own byte for the doubled chord", () => {
|
||||
expect(controlByte("ctrl-b")).toBe("\x02");
|
||||
});
|
||||
|
||||
it("holds the leader as a partial chord instead of selecting the page", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
@@ -234,3 +268,133 @@ describe("isTypingTarget", () => {
|
||||
expect(isTypingTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTerminalTextarea", () => {
|
||||
it("recognises xterm's offscreen textarea, and nothing else", () => {
|
||||
expect(isTerminalTextarea(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(true);
|
||||
expect(isTerminalTextarea(el("<textarea></textarea>"))).toBe(false);
|
||||
expect(isTerminalTextarea(el("<input>"))).toBe(false);
|
||||
expect(isTerminalTextarea(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBarTypingTarget", () => {
|
||||
it("holds the bar's keys out of real form fields", () => {
|
||||
expect(isBarTypingTarget(el("<input>"))).toBe(true);
|
||||
expect(isBarTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => {
|
||||
expect(isBarTypingTarget(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(false);
|
||||
expect(isBarTypingTarget(el("<button></button>"))).toBe(false);
|
||||
expect(isBarTypingTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctrl-k over a focused pane (#152)", () => {
|
||||
const terminal = () => el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||
|
||||
/** The global listener's routing: the bar's set only sees a key that is not
|
||||
* aimed at a real form field. */
|
||||
function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) {
|
||||
if (isBarTypingTarget(target)) return false;
|
||||
return pressOn(set, target, k, init);
|
||||
}
|
||||
|
||||
it("opens the bar when the terminal has the keyboard", () => {
|
||||
const open = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
||||
expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves the spawn form's inputs their own Ctrl-K", () => {
|
||||
const open = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
||||
expect(route(set, el("<input>"), "k", { ctrlKey: true })).toBe(false);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the literal 0x0B after the leader, without opening the bar", () => {
|
||||
const open = vi.fn();
|
||||
const literal = vi.fn();
|
||||
const bar = new CommandSet(defineCommands({ "bar.open": open }));
|
||||
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
});
|
||||
const target = terminal();
|
||||
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||
const e = key("k", { ctrlKey: true });
|
||||
Object.defineProperty(e, "target", { value: target });
|
||||
expect(set.handle(e)).toBe(true);
|
||||
expect(literal).toHaveBeenCalledTimes(1);
|
||||
// The leader's set consumed the key, so the bar's set never sees it.
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("binds the escape hatch behind the default leader", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK");
|
||||
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leader pressed twice (#158)", () => {
|
||||
it("sends one literal leader byte and clears the pending chord", () => {
|
||||
const literal = vi.fn();
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(
|
||||
defineCommands({ "pane.literalLeader": literal, "spawn.open": run }),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||
expect(literal).toHaveBeenCalledTimes(1);
|
||||
// The chord is cleared, not still held: the next key is ordinary again.
|
||||
expect(set.handle(key("n"))).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("binds the escape hatch behind the default leader", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalLeader");
|
||||
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ${DEFAULT_LEADER}`]);
|
||||
});
|
||||
|
||||
// Not just the default: the chord is resolved from whatever leader this
|
||||
// set was constructed with, so a rebound leader carries the escape hatch
|
||||
// with it — the same proof #156 wrote for the TUI. The web has no config
|
||||
// for this yet (#159), but the mechanism must not be hardcoded to ctrl-a.
|
||||
it("follows a rebound leader, not just the default", () => {
|
||||
const literal = vi.fn();
|
||||
const rebound: Command = {
|
||||
id: "pane.literalLeader",
|
||||
label: "Send a literal leader key to the pane",
|
||||
group: "Pane",
|
||||
scope: "global",
|
||||
keys: ["ctrl-b ctrl-b"],
|
||||
run: literal,
|
||||
};
|
||||
const set = new CommandSet([rebound], { leader: "ctrl-b" });
|
||||
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
|
||||
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
|
||||
expect(literal).toHaveBeenCalledTimes(1);
|
||||
// The old leader no longer does anything special.
|
||||
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("controlByte", () => {
|
||||
it("computes the raw control byte from a plain ctrl chord", () => {
|
||||
expect(controlByte("ctrl-a")).toBe("\x01");
|
||||
expect(controlByte("ctrl-k")).toBe("\x0b");
|
||||
});
|
||||
|
||||
it("rejects anything that isn't a single plain ctrl chord", () => {
|
||||
expect(() => controlByte("ctrl-shift-a")).toThrow();
|
||||
expect(() => controlByte("a")).toThrow();
|
||||
expect(() => controlByte("ctrl-a ctrl-a")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+67
-20
@@ -32,9 +32,26 @@ export interface Command extends CommandSpec {
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/** Every command the web client has. The single list this issue exists to
|
||||
* create: labels, chords and grouping live here and nowhere else. */
|
||||
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
/** Leader used when the daemon does not name one — an older daemon, or an
|
||||
* unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a`
|
||||
* is select-all in a browser, so a set that owns the leader
|
||||
* `preventDefault`s it. */
|
||||
export const DEFAULT_LEADER = "ctrl-a";
|
||||
|
||||
/** Every command the web client has, for one leader. The single list: labels,
|
||||
* chords and grouping live here and nowhere else. Chords behind the leader are
|
||||
* built from it (#159), so the configured leader — served on `/api/info` as
|
||||
* `leader` — is what the overlay renders and the sets dispatch. */
|
||||
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
|
||||
return SPECS.map(({ leaderKeys, ...spec }) =>
|
||||
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
|
||||
);
|
||||
}
|
||||
|
||||
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
|
||||
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
|
||||
|
||||
const SPECS: readonly SpecSource[] = [
|
||||
{
|
||||
id: "spawn.open",
|
||||
label: "Open the spawn form",
|
||||
@@ -56,11 +73,28 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
keys: ["ctrl-k"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "pane.literalCtrlK",
|
||||
label: "Send a literal Ctrl-K to the pane",
|
||||
group: "Pane",
|
||||
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
||||
leaderKeys: (leader) => [`${leader} ctrl-k`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "pane.literalLeader",
|
||||
label: "Send a literal leader key to the pane",
|
||||
group: "Pane",
|
||||
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
||||
leaderKeys: (leader) => [`${leader} ${leader}`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "focus.toggle",
|
||||
label: "Move the keyboard between the session list and the pane",
|
||||
group: "Session list",
|
||||
keys: ["ctrl-shift-h"],
|
||||
keys: [`${DEFAULT_LEADER} h`],
|
||||
leaderKeys: (leader) => [`${leader} h`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
@@ -112,13 +146,6 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
keys: ["tab"],
|
||||
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",
|
||||
label: "Show or hide this help",
|
||||
@@ -142,13 +169,20 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s]));
|
||||
/** The command list under the default leader. Iteration order everywhere else
|
||||
* (the help overlay, the bar) comes from here. */
|
||||
export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs();
|
||||
|
||||
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
||||
* test failure rather than a key that silently does nothing. */
|
||||
export function defineCommands(handlers: Record<string, () => void>): Command[] {
|
||||
* test failure rather than a key that silently does nothing. `leader` picks
|
||||
* the chords behind the leader; omit it for a set that has none. */
|
||||
export function defineCommands(
|
||||
handlers: Record<string, () => void>,
|
||||
leader: string = DEFAULT_LEADER,
|
||||
): Command[] {
|
||||
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
|
||||
return Object.entries(handlers).map(([id, run]) => {
|
||||
const spec = SPEC_BY_ID.get(id);
|
||||
const spec = byId.get(id);
|
||||
if (!spec) throw new Error(`unknown command id: ${id}`);
|
||||
return { ...spec, run };
|
||||
});
|
||||
@@ -216,6 +250,15 @@ export function canonicalizeKey(e: KeyboardEvent): string | null {
|
||||
return out + e.key.toLowerCase();
|
||||
}
|
||||
|
||||
/** The raw control byte for a canonical `ctrl-<letter>` chord — `ctrl-a` is
|
||||
* `0x01` — mirroring the TUI's `encode_key`. Built from the chord string
|
||||
* itself so a different leader sends its own byte, not a hardcoded one. */
|
||||
export function controlByte(chord: string): string {
|
||||
const m = /^ctrl-([a-z])$/.exec(chord);
|
||||
if (!m) throw new Error(`not a plain ctrl chord: ${chord}`);
|
||||
return String.fromCharCode(m[1].charCodeAt(0) - 96);
|
||||
}
|
||||
|
||||
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
|
||||
|
||||
/** True when a chord carries a modifier or more than one key. Plain single-key
|
||||
@@ -241,11 +284,19 @@ export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
||||
* also where the leader has to work — a pane is exactly where the operator
|
||||
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
||||
function isTerminalTextarea(target: EventTarget | null): boolean {
|
||||
export function isTerminalTextarea(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
||||
}
|
||||
|
||||
/** True where the command bar's own keys must stay out of the way: a real form
|
||||
* field, and not the pane. `ctrl-k` is intercepted ahead of xterm
|
||||
* (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while
|
||||
* the spawn form's inputs keep their own Ctrl-K. */
|
||||
export function isBarTypingTarget(target: EventTarget | null): boolean {
|
||||
return isTypingTarget(target) && !isTerminalTextarea(target);
|
||||
}
|
||||
|
||||
// ---- display ----
|
||||
|
||||
const CAPS: Record<string, string> = {
|
||||
@@ -292,10 +343,6 @@ export function keyCaps(chord: string): string[] {
|
||||
|
||||
// ---- dispatch ----
|
||||
|
||||
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
||||
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
||||
export const DEFAULT_LEADER = "ctrl-a";
|
||||
|
||||
/** How long a partial chord waits for its next key — the TUI's
|
||||
* `chord_timeout_ms` default. */
|
||||
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
||||
|
||||
@@ -2,11 +2,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ConnectionPool } from "./connections.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
||||
// the pool reaches the focused connection with them.
|
||||
const sent: { id: string; data: string }[] = [];
|
||||
const disposed: string[] = [];
|
||||
const mounts: { id: string; focus: boolean | undefined }[] = [];
|
||||
function fakeConn(kind: string, id: string) {
|
||||
const el = document.createElement("div");
|
||||
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
|
||||
el.dataset.kind = kind;
|
||||
el.dataset.session = id;
|
||||
return {
|
||||
sessionId: id,
|
||||
kind,
|
||||
el,
|
||||
onMount(opts?: { focus?: boolean }) {
|
||||
mounts.push({ id, focus: opts?.focus });
|
||||
},
|
||||
onUnmount() {},
|
||||
focusTerminal() {},
|
||||
sendInput(data: string) {
|
||||
sent.push({ id, data });
|
||||
},
|
||||
updateInfo() {},
|
||||
dispose() {
|
||||
disposed.push(`${kind}:${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
vi.mock("./attach.js", () => ({
|
||||
createAttachConnection: (id: string) => fakeConn("attach", id),
|
||||
}));
|
||||
vi.mock("./structured.js", () => ({
|
||||
createStructuredConnection: (id: string) => fakeConn("structured", id),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
sent.length = 0;
|
||||
disposed.length = 0;
|
||||
mounts.length = 0;
|
||||
});
|
||||
|
||||
/** Minimal fixture for the `CatalogState` a `CatalogStore` broadcasts. */
|
||||
function session(id: string, view?: "Terminal" | "Structured"): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/repo",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
view,
|
||||
};
|
||||
}
|
||||
|
||||
/** A `CatalogStore` an id can be pushed into on demand, mirroring
|
||||
* events.test.ts's own injectable-socket pattern. */
|
||||
class FakeCatalogSocket {
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
close(): void {}
|
||||
push(sessions: Session[]): void {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
|
||||
}
|
||||
}
|
||||
function fakeStore(): { store: CatalogStore; socket: FakeCatalogSocket } {
|
||||
const socket = new FakeCatalogSocket();
|
||||
const store = new CatalogStore({ socket: () => socket as unknown as WebSocket });
|
||||
store.start();
|
||||
return { store, socket };
|
||||
}
|
||||
|
||||
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
|
||||
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
|
||||
|
||||
@@ -85,3 +158,226 @@ describe("ConnectionPool.setFocus", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionPool.sendToFocused", () => {
|
||||
it("types the bytes into the focused session's PTY", () => {
|
||||
mockFetch();
|
||||
sent.length = 0;
|
||||
const pool = new ConnectionPool();
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
|
||||
expect(pool.sendToFocused("\x0b")).toBe(true);
|
||||
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
|
||||
});
|
||||
|
||||
it("is a no-op with nothing focused", () => {
|
||||
mockFetch();
|
||||
sent.length = 0;
|
||||
const pool = new ConnectionPool();
|
||||
|
||||
expect(pool.sendToFocused("\x0b")).toBe(false);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// A deep link or restored layout can `mount()` a session before the catalog
|
||||
// snapshot names its view — the daemon defaults an absent `view` to
|
||||
// `"Terminal"`, but so does an *unknown* session, and only one of those is
|
||||
// actually resolved (#140 review).
|
||||
describe("ConnectionPool: view resolution races", () => {
|
||||
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
|
||||
pool.mount(A, pane); // no info: deep-link-before-snapshot race
|
||||
expect(disposed).toEqual([]);
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
// The fresh connection landed in the same pane nothing re-mounted it into.
|
||||
expect(pane.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
|
||||
pool.mount(A, document.createElement("div"));
|
||||
pool.mount(B, document.createElement("div"));
|
||||
pool.unmount(A);
|
||||
pool.unmount(B);
|
||||
disposed.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
// B's own element is still parked — A's repair didn't wipe its siblings
|
||||
// out of the shared hidden cache.
|
||||
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
|
||||
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
|
||||
});
|
||||
|
||||
it("does not replace a connection whose resolved view already matches", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
|
||||
mockFetch();
|
||||
const pool = new ConnectionPool();
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
pool.mount(A, pane); // e.g. a re-render before the catalog answers again
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
// `A` is absent from this snapshot — main.ts's own subscription handles
|
||||
// closing the pane; the pool must not race it by "fixing" the view first.
|
||||
socket.push([session(B, "Terminal")]);
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
(pane.firstElementChild as HTMLElement).focus();
|
||||
expect(document.activeElement).toBe(pane.firstElementChild);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([{ id: A, focus: true }]);
|
||||
pane.remove();
|
||||
});
|
||||
|
||||
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
|
||||
// docs/PREVIEW.md: previewing a session already open in the current tab
|
||||
// moves the keyboard into the sidebar without ever calling setFocus, so
|
||||
// `focusedId` keeps naming the pane the operator was in before — but the
|
||||
// DOM disagrees, and the DOM is what must decide.
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
(pane.firstElementChild as HTMLElement).focus();
|
||||
|
||||
const sidebarInput = document.createElement("input");
|
||||
document.body.appendChild(sidebarInput);
|
||||
sidebarInput.focus();
|
||||
expect(document.activeElement).toBe(sidebarInput);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||
pane.remove();
|
||||
sidebarInput.remove();
|
||||
});
|
||||
|
||||
it("counts focus on a descendant of the pane's element, not just the element itself", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
const textarea = document.createElement("textarea");
|
||||
(pane.firstElementChild as HTMLElement).appendChild(textarea);
|
||||
textarea.focus();
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(mounts).toEqual([{ id: A, focus: true }]);
|
||||
pane.remove();
|
||||
});
|
||||
|
||||
it("treats document.body (nothing meaningfully focused) as no active descendant", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||
pane.remove();
|
||||
});
|
||||
|
||||
it("does not steal focus for a merely-visible pane that was never actually focused", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const paneA = document.createElement("div");
|
||||
const paneB = document.createElement("div");
|
||||
pool.mount(A, paneA);
|
||||
pool.mount(B, paneB);
|
||||
pool.setFocus(B); // B is the real keyboard focus; A is only visible
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||
});
|
||||
|
||||
it("does not focus a parked (unmounted) connection it repairs", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
pool.unmount(A); // parked — no longer visible, `focusedId` still names it
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
|
||||
});
|
||||
});
|
||||
|
||||
+123
-18
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
createAttachConnection,
|
||||
type AttachConnection,
|
||||
type AttachInfo,
|
||||
} from "./attach.js";
|
||||
import { createAttachConnection } from "./attach.js";
|
||||
import type { AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
import { createStructuredConnection } from "./structured.js";
|
||||
import { setSessionFocus } from "./api.js";
|
||||
import type { Uuid } from "./types.js";
|
||||
import type { CatalogState, CatalogStore } from "./events.js";
|
||||
import { sessionName, type SessionView, type Uuid } from "./types.js";
|
||||
|
||||
/**
|
||||
* How many *parked* (off-screen but still-connected) sessions to keep warm
|
||||
@@ -16,6 +16,15 @@ import type { Uuid } from "./types.js";
|
||||
*/
|
||||
const MAX_PARKED = 6;
|
||||
|
||||
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
|
||||
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
|
||||
* meaningful once the session itself is known; an altogether-unknown
|
||||
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
|
||||
* state callers must check for separately, not "Terminal". */
|
||||
function resolveView(view: SessionView | undefined): SessionView {
|
||||
return view ?? "Terminal";
|
||||
}
|
||||
|
||||
export interface PoolOptions {
|
||||
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
|
||||
onFocusChange?: (id: Uuid | null) => void;
|
||||
@@ -27,6 +36,9 @@ export interface PoolOptions {
|
||||
onClosed?: (id: Uuid) => void;
|
||||
/** Forwarded to each connection: should a dropped socket be retried? */
|
||||
shouldReconnect?: (id: Uuid, code: number) => boolean;
|
||||
/** Threaded through to a structured connection so its rail can read live
|
||||
* worktree/git-status data (#140). Terminal connections don't need it. */
|
||||
store?: CatalogStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,12 +52,19 @@ export interface PoolOptions {
|
||||
*/
|
||||
export class ConnectionPool {
|
||||
/** All live connections. Insertion order doubles as LRU: oldest first. */
|
||||
private readonly cache = new Map<Uuid, AttachConnection>();
|
||||
private readonly cache = new Map<Uuid, PaneConnection>();
|
||||
/** The view each cached connection was actually created for — a deep link
|
||||
* or a restored layout can `mount()` a session before the catalog
|
||||
* snapshot names its view, defaulting it to `"Terminal"`; this is what
|
||||
* lets a later reconciliation notice the resolved view disagrees (#140
|
||||
* review). */
|
||||
private readonly connViews = new Map<Uuid, SessionView>();
|
||||
/** Sessions currently mounted into a visible pane (never evicted). */
|
||||
private readonly mounted = new Set<Uuid>();
|
||||
private readonly hidden: HTMLElement;
|
||||
private focusedId: Uuid | null = null;
|
||||
private readonly opts: PoolOptions;
|
||||
private readonly unsubscribeStore?: () => void;
|
||||
|
||||
constructor(opts: PoolOptions = {}) {
|
||||
this.opts = opts;
|
||||
@@ -54,6 +73,11 @@ export class ConnectionPool {
|
||||
this.hidden.className = "conn-cache";
|
||||
this.hidden.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(this.hidden);
|
||||
// A session `mount()`ed before its catalog entry exists gets a
|
||||
// `"Terminal"` connection by default; once the snapshot actually names
|
||||
// the view, fix any connection that guessed wrong — nothing else
|
||||
// re-`mount()`s an already-open pane just because the catalog updated.
|
||||
this.unsubscribeStore = opts.store?.subscribe((state) => this.reconcileViews(state));
|
||||
}
|
||||
|
||||
/** The focused session, or null when nothing is focused (welcome pane). */
|
||||
@@ -77,18 +101,19 @@ export class ConnectionPool {
|
||||
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
|
||||
let conn = this.cache.get(id);
|
||||
if (conn) {
|
||||
this.cache.delete(id);
|
||||
this.cache.set(id, conn);
|
||||
// `info === undefined` means "session not known yet", not "Terminal" —
|
||||
// never downgrade a resolved connection just because a later caller
|
||||
// couldn't resolve the session. Only a *resolved* view that disagrees
|
||||
// with what this connection was built for forces a replace.
|
||||
if (info !== undefined && resolveView(info.view) !== this.connViews.get(id)) {
|
||||
conn = this.replace(id, info);
|
||||
} else {
|
||||
this.cache.delete(id);
|
||||
this.cache.set(id, conn); // LRU refresh
|
||||
if (info) conn.updateInfo(info);
|
||||
}
|
||||
} else {
|
||||
conn = createAttachConnection(
|
||||
id,
|
||||
{
|
||||
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
||||
onClosed: (sid) => this.opts.onClosed?.(sid),
|
||||
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
||||
},
|
||||
info,
|
||||
);
|
||||
conn = this.create(id, info);
|
||||
this.cache.set(id, conn);
|
||||
}
|
||||
if (conn.el.parentElement !== pane) {
|
||||
@@ -101,6 +126,71 @@ export class ConnectionPool {
|
||||
this.evict();
|
||||
}
|
||||
|
||||
private create(id: Uuid, info?: AttachInfo): PaneConnection {
|
||||
const view = resolveView(info?.view);
|
||||
this.connViews.set(id, view);
|
||||
const connOpts = {
|
||||
onRequestClose: (sid: Uuid) => this.opts.onRequestClose?.(sid),
|
||||
onClosed: (sid: Uuid) => this.opts.onClosed?.(sid),
|
||||
shouldReconnect: (sid: Uuid, code: number) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
||||
};
|
||||
return view === "Structured"
|
||||
? createStructuredConnection(id, connOpts, info, this.opts.store)
|
||||
: createAttachConnection(id, connOpts, info);
|
||||
}
|
||||
|
||||
/** Dispose whatever is cached for `id` and cache a freshly created
|
||||
* connection in its place. Does not touch the DOM or `mounted` — callers
|
||||
* place the new element themselves. */
|
||||
private replace(id: Uuid, info?: AttachInfo): PaneConnection {
|
||||
this.cache.get(id)?.dispose();
|
||||
const fresh = this.create(id, info);
|
||||
this.cache.set(id, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Proactively fix any cached connection whose view no longer matches the
|
||||
* catalog — the only path that repairs a pane nothing will `mount()`
|
||||
* again on its own (parked, or mounted but not currently being
|
||||
* re-rendered by anything else). A session absent from `state` (not yet
|
||||
* known, or removed) is left alone — that's `main.ts`'s
|
||||
* `closePane`/eviction concern, not a view mismatch. */
|
||||
private reconcileViews(state: CatalogState): void {
|
||||
for (const id of [...this.cache.keys()]) {
|
||||
const session = state.sessions.get(id);
|
||||
if (session === undefined || resolveView(session.view) === this.connViews.get(id)) continue;
|
||||
const existing = this.cache.get(id)!;
|
||||
const parent = existing.el.parentElement;
|
||||
const wasMounted = this.mounted.has(id);
|
||||
// Capture before anything below touches the DOM: dispose()/el.remove()
|
||||
// detach `existing.el`, and once a focused descendant is detached the
|
||||
// browser moves `document.activeElement` to `<body>` — too late to
|
||||
// tell whether the keyboard was actually in this pane.
|
||||
const hadDomFocus = existing.el.contains(document.activeElement);
|
||||
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
|
||||
const fresh = this.replace(id, info);
|
||||
existing.el.remove(); // don't rely on dispose() to clear the DOM
|
||||
if (parent && parent !== this.hidden) {
|
||||
parent.replaceChildren(fresh.el);
|
||||
} else {
|
||||
// Parked: `this.hidden` holds every parked connection's element as
|
||||
// siblings — append, never replaceChildren, or they'd all vanish.
|
||||
this.hidden.appendChild(fresh.el);
|
||||
}
|
||||
if (wasMounted) {
|
||||
// `focusedId` names the session the pool was last told to focus —
|
||||
// but a same-tab sidebar preview moves the keyboard into the
|
||||
// sidebar without clearing it (multiview.ts skips `setFocus` while
|
||||
// previewing), so `focusedId` alone can point at a pane the
|
||||
// keyboard already left. Only re-focus when the DOM agrees it
|
||||
// never did.
|
||||
fresh.onMount({ focus: this.focusedId === id && hadDomFocus });
|
||||
} else {
|
||||
fresh.onUnmount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
|
||||
unmount(id: Uuid): void {
|
||||
if (!this.mounted.delete(id)) return;
|
||||
@@ -159,19 +249,34 @@ export class ConnectionPool {
|
||||
this.cache.get(this.focusedId)?.focusTerminal();
|
||||
}
|
||||
|
||||
/** Type raw bytes into the focused session's PTY. Returns false when there is
|
||||
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
|
||||
* is a no-op over the welcome pane rather than an error
|
||||
* (docs/COMMAND-BAR.md). */
|
||||
sendToFocused(data: string): boolean {
|
||||
if (this.focusedId === null) return false;
|
||||
const conn = this.cache.get(this.focusedId);
|
||||
if (!conn) return false;
|
||||
conn.sendInput(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fully disconnect + forget a session. */
|
||||
close(id: Uuid): void {
|
||||
const conn = this.cache.get(id);
|
||||
if (!conn) return;
|
||||
conn.dispose();
|
||||
this.cache.delete(id);
|
||||
this.connViews.delete(id);
|
||||
this.mounted.delete(id);
|
||||
if (this.focusedId === id) this.setFocus(null);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unsubscribeStore?.();
|
||||
for (const conn of this.cache.values()) conn.dispose();
|
||||
this.cache.clear();
|
||||
this.connViews.clear();
|
||||
this.mounted.clear();
|
||||
this.hidden.remove();
|
||||
}
|
||||
|
||||
@@ -130,6 +130,31 @@ describe("DaemonLink", () => {
|
||||
expect(link.status().state).toBe("online");
|
||||
});
|
||||
|
||||
it("rechecks immediately when the browser returns from the background", async () => {
|
||||
const sched = manual();
|
||||
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||
const link = new DaemonLink({
|
||||
fetchInfo: async () => {
|
||||
if (!reply) throw new Error("down");
|
||||
return reply;
|
||||
},
|
||||
scheduler: sched,
|
||||
reload: () => {},
|
||||
});
|
||||
await link.start();
|
||||
|
||||
reply = null;
|
||||
link.noteDisconnected();
|
||||
sched.run();
|
||||
await Promise.resolve();
|
||||
expect(link.status().state).toBe("down");
|
||||
|
||||
reply = info("aaa", "i1");
|
||||
link.recover();
|
||||
await Promise.resolve();
|
||||
expect(link.status().state).toBe("online");
|
||||
});
|
||||
|
||||
it("reloads the page when the daemon comes back on a new commit", async () => {
|
||||
const sched = manual();
|
||||
const reload = vi.fn();
|
||||
|
||||
@@ -148,6 +148,17 @@ export class DaemonLink {
|
||||
void this.probe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recheck liveness after the browser resumes from the background. Mobile
|
||||
* browsers may suspend both WebSocket events and timers while a PWA is
|
||||
* hidden, leaving a stale `down` state after the daemon is already back.
|
||||
*/
|
||||
recover(): void {
|
||||
if (this.state === "updating") return;
|
||||
this.noteDisconnected();
|
||||
void this.probe();
|
||||
}
|
||||
|
||||
/**
|
||||
* One `/api/info` round-trip. Resolves `true` when the daemon answered.
|
||||
*
|
||||
|
||||
@@ -132,6 +132,21 @@ describe("CatalogStore reducer", () => {
|
||||
});
|
||||
|
||||
describe("CatalogStore reconnect", () => {
|
||||
it("replaces a suspended socket immediately", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const { store } = storeWith([first, second]);
|
||||
store.start();
|
||||
|
||||
store.reconnect();
|
||||
|
||||
expect(first.closed).toBe(true);
|
||||
second.open();
|
||||
// A late close from the old socket is deliberately ignored.
|
||||
first.drop();
|
||||
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
|
||||
});
|
||||
|
||||
it("doubles the delay per attempt and caps it", () => {
|
||||
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
|
||||
const { store, scheduler } = storeWith(sockets);
|
||||
|
||||
@@ -59,6 +59,23 @@ export class CatalogStore {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/** Replace a socket that may have gone stale while the browser was suspended. */
|
||||
reconnect(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
this.scheduler.clearTimer(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
// A close event from the superseded socket must not schedule a second
|
||||
// connection alongside the new one.
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/** Current reconnect delay, for tests asserting the backoff schedule. */
|
||||
backoff(): number {
|
||||
return this.backoffMs;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user