Compare commits
274
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35ae01db93 | ||
|
|
8a05ef2eb7 | ||
|
|
ad71147bd9 | ||
|
|
e9c3882cea | ||
|
|
c9562ab5eb | ||
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 | ||
|
|
e0edbc09d2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
1b7ec1f54c | ||
|
|
ee61cea64f | ||
|
|
7e21371e47 | ||
|
|
52ae636dbb | ||
|
|
9d93e6e60e | ||
|
|
babdc678e0 | ||
|
|
9ba1078386 | ||
|
|
f0a1774e4d | ||
|
|
ac720eb266 | ||
|
|
0ba83c3d8f | ||
|
|
e8e98baf4d | ||
|
|
b286323c9c | ||
|
|
71ec7e073c | ||
|
|
46bae13212 | ||
|
|
20d303278d | ||
|
|
589e0144e2 | ||
|
|
0061378193 | ||
|
|
1cf4825b1d | ||
|
|
f799aaf564 | ||
|
|
5af5ceb9a8 | ||
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
b9f8f02e11 | ||
|
|
f36b7808a7 | ||
|
|
c2d427691d | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 | ||
|
|
3c10a3a08a | ||
|
|
fd79c8116a | ||
|
|
cb8b93f178 | ||
|
|
60e57fde31 | ||
|
|
8d35f7f8c8 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
88afd0df3f | ||
|
|
df51aa7be9 | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
43bfda5a60 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 | ||
|
|
0543f66469 | ||
|
|
4a2f7e98b4 | ||
|
|
8c151a5fb2 | ||
|
|
8d0886f9b7 | ||
|
|
9f82400f0a | ||
|
|
059316cbac | ||
|
|
0b1c9e15cf | ||
|
|
e30084ea24 | ||
|
|
bb898a75f3 | ||
|
|
c8e90af91c | ||
|
|
d0df60ac87 | ||
|
|
25244803a9 | ||
|
|
92b41a8742 | ||
|
|
508262bbac | ||
|
|
002f1b466b | ||
|
|
bc27bffbae | ||
|
|
bbf622ed9b | ||
|
|
0c46fb993a | ||
|
|
be554d0ede | ||
|
|
4fda393378 | ||
|
|
a1f3690aaf | ||
|
|
4ce432c227 | ||
|
|
ba7fd68004 | ||
|
|
c3d008d07f | ||
|
|
c355f67133 | ||
|
|
b5c9970e28 | ||
|
|
22d740a36b | ||
|
|
ccdfec8257 | ||
|
|
30f91176ae | ||
|
|
a771d29210 | ||
|
|
913421e244 | ||
|
|
2e7c5f7f58 | ||
|
|
dba3d8c249 | ||
|
|
4c8217385a | ||
|
|
758bacf3fc | ||
|
|
0af91296df | ||
|
|
370edfa763 | ||
|
|
e7086512e7 | ||
|
|
aca8d39886 | ||
|
|
6ae7c3eb97 | ||
|
|
a73eb6284f | ||
|
|
57efb615f6 | ||
|
|
fe26dab6a8 | ||
|
|
8eecd9961e | ||
|
|
ac2eb01bda | ||
|
|
1576380080 | ||
|
|
85feb433ae | ||
|
|
a11933b7bf | ||
|
|
fa40eb7378 | ||
|
|
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 | ||
|
|
83741d7203 | ||
|
|
b618b7dd27 | ||
|
|
8dde63a2ab | ||
|
|
1fe2ae6575 | ||
|
|
5df23b8714 | ||
|
|
9d9aef119d | ||
|
|
950a899b3c | ||
|
|
328711cade |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git fetch:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(./node_modules/.bin/tsc:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -251,6 +343,8 @@ mod tests {
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,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();
|
||||
|
||||
@@ -80,13 +80,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_repo_dir_is_none() {
|
||||
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
||||
// the walk can't accidentally hit a real repo above the test's cwd.
|
||||
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
||||
// The fixture has no `.git`, so `find_root` must not return a path
|
||||
// inside it. On hosts where an ancestor of the temp dir *is* a repo
|
||||
// (e.g. `/tmp` lives inside a checkout), the function legitimately
|
||||
// returns that ancestor — the test only asserts the fixture itself
|
||||
// was not mistaken for a repo.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sub = tmp.path().join("plain/dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
assert_eq!(find_root(&sub), None);
|
||||
match find_root(&sub) {
|
||||
None => {} // expected on most hosts
|
||||
Some(root) => assert!(
|
||||
!root.starts_with(tmp.path()),
|
||||
"found .git inside the fixture at {root:?}, but none was created"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
+2436
-131
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."}}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -1091,3 +1137,115 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
||||
|
||||
fx.manager.kill(s.id, false).await.expect("kill");
|
||||
}
|
||||
|
||||
/// docs/COMMAND-BAR.md, empty-query ordering: a session that has never been
|
||||
/// focused carries no `last_focused_at` at all, which is what lets a client
|
||||
/// sort it after every session that has one (#145).
|
||||
#[tokio::test]
|
||||
async fn a_freshly_created_session_has_never_been_focused() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
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");
|
||||
|
||||
assert_eq!(s.last_focused_at, None);
|
||||
assert_eq!(fx.manager.snapshot().await[0].last_focused_at, None);
|
||||
}
|
||||
|
||||
/// #145: `POST /.../focus` reporting `focused: true` stamps
|
||||
/// `last_focused_at` and announces it on the event stream; reporting
|
||||
/// `focused: false` (losing focus) leaves the stamp alone, since the bar
|
||||
/// orders by when the operator last arrived, not when they left.
|
||||
#[tokio::test]
|
||||
async fn set_focused_true_stamps_last_focused_at() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
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");
|
||||
|
||||
let mut events = fx.manager.events();
|
||||
let before = chrono::Utc::now();
|
||||
fx.manager.set_focused(s.id, true).await.expect("focus");
|
||||
|
||||
let updated = fx.manager.snapshot().await[0].clone();
|
||||
let stamp = updated.last_focused_at.expect("stamped on focus");
|
||||
assert!(stamp >= before);
|
||||
|
||||
let mut saw_update = false;
|
||||
while let Ok(event) = tokio::time::timeout(Duration::from_millis(200), events.recv()).await {
|
||||
if let Ok(CatalogEvent::SessionUpdated { session }) = event {
|
||||
if session.id == s.id && session.last_focused_at == Some(stamp) {
|
||||
saw_update = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(saw_update, "expected a SessionUpdated carrying the stamp");
|
||||
|
||||
fx.manager.set_focused(s.id, false).await.expect("unfocus");
|
||||
let after_unfocus = fx.manager.snapshot().await[0].clone();
|
||||
assert_eq!(
|
||||
after_unfocus.last_focused_at,
|
||||
Some(stamp),
|
||||
"losing focus must not clear the last-focused stamp"
|
||||
);
|
||||
}
|
||||
|
||||
/// #145: opening `/api/attach/:id` is focusing too — it must stamp the same
|
||||
/// field `set_focused` does, so a client that only ever attaches sorts
|
||||
/// identically to one that reports focus explicitly.
|
||||
#[tokio::test]
|
||||
async fn attaching_stamps_last_focused_at() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
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");
|
||||
assert_eq!(s.last_focused_at, None);
|
||||
|
||||
let before = chrono::Utc::now();
|
||||
let (_replay, _rx) = fx
|
||||
.manager
|
||||
.subscribe_pty_with_replay(s.id)
|
||||
.await
|
||||
.expect("known session");
|
||||
|
||||
let updated = fx.manager.snapshot().await[0].clone();
|
||||
let stamp = updated.last_focused_at.expect("attach stamps focus");
|
||||
assert!(stamp >= before);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -31,6 +31,9 @@ fn sample(id: Uuid) -> Session {
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
view: SessionView::Terminal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,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();
|
||||
@@ -102,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();
|
||||
@@ -112,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(),
|
||||
@@ -248,6 +742,8 @@ mod tests {
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,41 @@ pub struct Session {
|
||||
/// from daemons that predate the field stay forward-compatible.
|
||||
#[serde(default)]
|
||||
pub blocked_work: Option<InTheWay>,
|
||||
/// Wall-clock time this session last became the pane a client was
|
||||
/// showing (#145) — stamped by both `POST /.../focus` and opening
|
||||
/// `/api/attach/:id`, since either one is the operator moving there.
|
||||
/// `None` until the session has been focused at least once, which is
|
||||
/// also how the command bar's empty-query ordering (`docs/COMMAND-BAR.md`)
|
||||
/// 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.
|
||||
@@ -121,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
|
||||
@@ -131,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 {
|
||||
@@ -153,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,
|
||||
})
|
||||
@@ -191,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"],
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -344,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.
|
||||
@@ -483,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
|
||||
@@ -546,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
|
||||
@@ -709,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -847,6 +1051,9 @@ mod tests {
|
||||
.with_timezone(&Utc),
|
||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
view: SessionView::Terminal,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,6 +1103,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_focused_at_defaults_to_none_on_legacy_payload() {
|
||||
// Pre-#145 payload omits the field; `None` means never-focused.
|
||||
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.last_focused_at, None);
|
||||
|
||||
let mut focused = sample_session();
|
||||
focused.last_focused_at = Some(
|
||||
DateTime::parse_from_rfc3339("2026-05-24T12:06:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
);
|
||||
assert_eq!(focused, round_trip(&focused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_since_defaults_to_epoch_on_legacy_payload() {
|
||||
// A daemon predating #102 omits `activity_since`; it must deserialize
|
||||
@@ -1036,6 +1265,8 @@ mod tests {
|
||||
branch: "feat/foo".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
view: SessionView::Terminal,
|
||||
auto_approve: false,
|
||||
};
|
||||
assert_eq!(req, round_trip(&req));
|
||||
}
|
||||
@@ -1050,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(),
|
||||
@@ -46,6 +48,8 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,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)]
|
||||
@@ -89,6 +95,7 @@ impl MockDaemon {
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
@@ -175,6 +182,16 @@ impl MockDaemon {
|
||||
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||
}
|
||||
|
||||
/// Every ACP prompt this daemon received, oldest first.
|
||||
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
|
||||
self.state
|
||||
.inner
|
||||
.lock()
|
||||
.expect("poisoned")
|
||||
.structured_prompts
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||
}
|
||||
@@ -263,6 +280,26 @@ async fn send_input(
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn structured_prompt(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<StructuredPromptRequest>,
|
||||
) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
}
|
||||
let mut guard = s.inner.lock().expect("poisoned");
|
||||
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
guard.structured_prompts.push((id, req));
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1384
-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"),
|
||||
@@ -222,6 +223,8 @@ mod tests {
|
||||
activity_since: chrono::Utc::now(),
|
||||
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;
|
||||
@@ -13,6 +14,7 @@ pub mod emulators;
|
||||
pub mod fast_forward;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod matcher;
|
||||
pub mod overlays;
|
||||
pub mod palette;
|
||||
pub mod pane;
|
||||
@@ -21,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;
|
||||
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
//! Pure match-and-rank function for the `ctrl-k` command bar.
|
||||
//!
|
||||
//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a
|
||||
//! candidate is", "Match and rank", "Fixtures"). Must produce the same order
|
||||
//! and scores as `web/src/matcher.ts` for the same input.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CandidateKind {
|
||||
Session,
|
||||
Command,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MatchFieldName {
|
||||
Name,
|
||||
Directory,
|
||||
Command,
|
||||
Tool,
|
||||
Label,
|
||||
Path,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionCandidate {
|
||||
pub name: String,
|
||||
pub directory: Option<String>,
|
||||
pub command: Option<String>,
|
||||
pub tool: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommandCandidate {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirectoryCandidate {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MatchCandidate {
|
||||
Session(SessionCandidate),
|
||||
Command(CommandCandidate),
|
||||
Directory(DirectoryCandidate),
|
||||
}
|
||||
|
||||
pub type MatchRange = (usize, usize);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct MatchResult<'c> {
|
||||
pub candidate: &'c MatchCandidate,
|
||||
pub score: i32,
|
||||
pub field: Option<MatchFieldName>,
|
||||
pub ranges: Vec<MatchRange>,
|
||||
}
|
||||
|
||||
struct Field<'c> {
|
||||
name: MatchFieldName,
|
||||
text: &'c str,
|
||||
weight: i32,
|
||||
}
|
||||
|
||||
impl MatchCandidate {
|
||||
fn kind(&self) -> CandidateKind {
|
||||
match self {
|
||||
MatchCandidate::Session(_) => CandidateKind::Session,
|
||||
MatchCandidate::Command(_) => CandidateKind::Command,
|
||||
MatchCandidate::Directory(_) => CandidateKind::Directory,
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<Field<'_>> {
|
||||
match self {
|
||||
MatchCandidate::Session(session) => {
|
||||
let mut fields = vec![Field {
|
||||
name: MatchFieldName::Name,
|
||||
text: &session.name,
|
||||
weight: 0,
|
||||
}];
|
||||
if let Some(directory) = &session.directory {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Directory,
|
||||
text: directory,
|
||||
weight: -24,
|
||||
});
|
||||
}
|
||||
if let Some(command) = &session.command {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Command,
|
||||
text: command,
|
||||
weight: -32,
|
||||
});
|
||||
}
|
||||
if let Some(tool) = &session.tool {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Tool,
|
||||
text: tool,
|
||||
weight: -40,
|
||||
});
|
||||
}
|
||||
fields
|
||||
}
|
||||
MatchCandidate::Command(command) => vec![Field {
|
||||
name: MatchFieldName::Label,
|
||||
text: &command.label,
|
||||
weight: 0,
|
||||
}],
|
||||
MatchCandidate::Directory(directory) => vec![Field {
|
||||
name: MatchFieldName::Path,
|
||||
text: &directory.path,
|
||||
weight: 0,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_order(kind: CandidateKind) -> u8 {
|
||||
match kind {
|
||||
CandidateKind::Session => 0,
|
||||
CandidateKind::Command => 1,
|
||||
CandidateKind::Directory => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_char(c: char) -> char {
|
||||
c.to_lowercase().next().unwrap_or(c)
|
||||
}
|
||||
|
||||
fn lower_chars(chars: &[char]) -> Vec<char> {
|
||||
chars.iter().copied().map(lower_char).collect()
|
||||
}
|
||||
|
||||
fn normalize_query(query: &str) -> Vec<char> {
|
||||
query
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.map(lower_char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':'];
|
||||
|
||||
fn boundary_bonus(text: &[char], position: usize) -> i32 {
|
||||
if position == 0 {
|
||||
return 18;
|
||||
}
|
||||
|
||||
let previous = text[position - 1];
|
||||
if previous == ' ' || SEPARATORS.contains(&previous) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
let current = text[position];
|
||||
if current.is_uppercase() && previous.is_lowercase() {
|
||||
return 12;
|
||||
}
|
||||
|
||||
let is_digit = current.is_ascii_digit();
|
||||
let previous_is_digit = previous.is_ascii_digit();
|
||||
if is_digit && !previous_is_digit {
|
||||
return 12;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 {
|
||||
let gap = position as isize - previous_position - 1;
|
||||
16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 }
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Embedding {
|
||||
score: i32,
|
||||
positions: Vec<usize>,
|
||||
}
|
||||
|
||||
fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool {
|
||||
let Some(incumbent) = incumbent else {
|
||||
return true;
|
||||
};
|
||||
if candidate.score != incumbent.score {
|
||||
return candidate.score > incumbent.score;
|
||||
}
|
||||
for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) {
|
||||
if c != i {
|
||||
return c < i;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The dynamic program over `(query index, text position)` described in
|
||||
/// "Which embedding": greedy leftmost-first matching is insufficient (e.g.
|
||||
/// `mtu` against `maestro-tui`), so every reachable position at each query
|
||||
/// index carries its best score and position vector forward.
|
||||
fn best_embedding(query: &[char], text: &[char]) -> Option<Embedding> {
|
||||
let lower_text = lower_chars(text);
|
||||
let mut previous_states: HashMap<usize, Embedding> = HashMap::new();
|
||||
|
||||
for (query_index, &qc) in query.iter().enumerate() {
|
||||
let mut states: HashMap<usize, Embedding> = HashMap::new();
|
||||
|
||||
for (position, &text_char) in lower_text.iter().enumerate() {
|
||||
if text_char != qc {
|
||||
continue;
|
||||
}
|
||||
|
||||
if query_index == 0 {
|
||||
states.insert(
|
||||
position,
|
||||
Embedding {
|
||||
score: contribution(text, position, -1),
|
||||
positions: vec![position],
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (&previous_position, previous) in previous_states.iter() {
|
||||
if previous_position >= position {
|
||||
continue;
|
||||
}
|
||||
let mut positions = previous.positions.clone();
|
||||
positions.push(position);
|
||||
let candidate = Embedding {
|
||||
score: previous.score
|
||||
+ contribution(text, position, previous_position as isize),
|
||||
positions,
|
||||
};
|
||||
if is_better_embedding(&candidate, states.get(&position)) {
|
||||
states.insert(position, candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if states.is_empty() {
|
||||
return None;
|
||||
}
|
||||
previous_states = states;
|
||||
}
|
||||
|
||||
let mut best: Option<Embedding> = None;
|
||||
for embedding in previous_states.into_values() {
|
||||
if is_better_embedding(&embedding, best.as_ref()) {
|
||||
best = Some(embedding);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn to_ranges(positions: &[usize]) -> Vec<MatchRange> {
|
||||
let mut ranges: Vec<MatchRange> = Vec::new();
|
||||
for &position in positions {
|
||||
if let Some(last) = ranges.last_mut() {
|
||||
if last.1 == position {
|
||||
last.1 = position + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ranges.push((position, position + 1));
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
fn compact(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.map(lower_char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Ranked<'c> {
|
||||
candidate: &'c MatchCandidate,
|
||||
score: i32,
|
||||
field: Option<MatchFieldName>,
|
||||
ranges: Vec<MatchRange>,
|
||||
matched_text: String,
|
||||
input_index: usize,
|
||||
}
|
||||
|
||||
/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing
|
||||
/// about sessions, panes or the network — see docs/COMMAND-BAR.md.
|
||||
pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec<MatchResult<'c>> {
|
||||
let normalized_query = normalize_query(query);
|
||||
|
||||
if normalized_query.is_empty() {
|
||||
return candidates
|
||||
.iter()
|
||||
.map(|candidate| MatchResult {
|
||||
candidate,
|
||||
score: 0,
|
||||
field: None,
|
||||
ranges: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<Ranked<'c>> = Vec::new();
|
||||
|
||||
for (input_index, candidate) in candidates.iter().enumerate() {
|
||||
let mut best: Option<(MatchFieldName, &str, i32, Vec<usize>)> = None;
|
||||
|
||||
for field in candidate.fields() {
|
||||
let text_chars: Vec<char> = field.text.chars().collect();
|
||||
let Some(embedding) = best_embedding(&normalized_query, &text_chars) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let compact_text = compact(field.text);
|
||||
let normalized_query_str: String = normalized_query.iter().collect();
|
||||
let adjustment = if compact_text == normalized_query_str {
|
||||
64
|
||||
} else if compact_text.starts_with(&normalized_query_str) {
|
||||
32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let score = embedding.score + adjustment + field.weight;
|
||||
|
||||
let is_better = match &best {
|
||||
None => true,
|
||||
Some((_, _, best_score, _)) => score > *best_score,
|
||||
};
|
||||
if is_better {
|
||||
best = Some((field.name, field.text, score, embedding.positions));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((field_name, field_text, score, positions)) = best {
|
||||
results.push(Ranked {
|
||||
candidate,
|
||||
score,
|
||||
field: Some(field_name),
|
||||
ranges: to_ranges(&positions),
|
||||
matched_text: field_text.to_string(),
|
||||
input_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.cmp(&a.score)
|
||||
.then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind())))
|
||||
.then_with(|| {
|
||||
a.matched_text
|
||||
.chars()
|
||||
.count()
|
||||
.cmp(&b.matched_text.chars().count())
|
||||
})
|
||||
.then_with(|| a.matched_text.cmp(&b.matched_text))
|
||||
.then_with(|| a.input_index.cmp(&b.input_index))
|
||||
});
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.map(|ranked| MatchResult {
|
||||
candidate: ranked.candidate,
|
||||
score: ranked.score,
|
||||
field: ranked.field,
|
||||
ranges: ranked.ranges,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session(
|
||||
name: &str,
|
||||
directory: Option<&str>,
|
||||
command: Option<&str>,
|
||||
tool: Option<&str>,
|
||||
) -> MatchCandidate {
|
||||
MatchCandidate::Session(SessionCandidate {
|
||||
name: name.to_string(),
|
||||
directory: directory.map(str::to_string),
|
||||
command: command.map(str::to_string),
|
||||
tool: tool.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
fn command(label: &str) -> MatchCandidate {
|
||||
MatchCandidate::Command(CommandCandidate {
|
||||
label: label.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn directory(path: &str) -> MatchCandidate {
|
||||
MatchCandidate::Directory(DirectoryCandidate {
|
||||
path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn label(candidate: &MatchCandidate) -> String {
|
||||
match candidate {
|
||||
MatchCandidate::Session(s) => format!("session:{}", s.name),
|
||||
MatchCandidate::Command(c) => format!("command:{}", c.label),
|
||||
MatchCandidate::Directory(d) => format!("directory:{}", d.path),
|
||||
}
|
||||
}
|
||||
|
||||
fn positions(result: &MatchResult) -> Vec<usize> {
|
||||
result
|
||||
.ranges
|
||||
.iter()
|
||||
.flat_map(|&(start, end)| start..end)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn expect_results(
|
||||
query: &str,
|
||||
candidates: &[MatchCandidate],
|
||||
expected: &[(&str, i32, Option<MatchFieldName>, &[usize])],
|
||||
) {
|
||||
let results = match_and_rank(query, candidates);
|
||||
|
||||
let actual: Vec<(String, i32)> = results
|
||||
.iter()
|
||||
.map(|r| (label(r.candidate), r.score))
|
||||
.collect();
|
||||
let expected_labels: Vec<(String, i32)> = expected
|
||||
.iter()
|
||||
.map(|(l, s, _, _)| (l.to_string(), *s))
|
||||
.collect();
|
||||
assert_eq!(actual, expected_labels, "order/scores for query {query:?}");
|
||||
|
||||
let actual_fields: Vec<Option<MatchFieldName>> = results.iter().map(|r| r.field).collect();
|
||||
let expected_fields: Vec<Option<MatchFieldName>> =
|
||||
expected.iter().map(|(_, _, f, _)| *f).collect();
|
||||
assert_eq!(actual_fields, expected_fields, "fields for query {query:?}");
|
||||
|
||||
let actual_positions: Vec<Vec<usize>> = results.iter().map(positions).collect();
|
||||
let expected_positions: Vec<Vec<usize>> =
|
||||
expected.iter().map(|(_, _, _, p)| p.to_vec()).collect();
|
||||
assert_eq!(
|
||||
actual_positions, expected_positions,
|
||||
"positions for query {query:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_empty_query_keeps_input_order() {
|
||||
expect_results(
|
||||
"",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
session(
|
||||
"killer-app",
|
||||
Some("~/dev/killer-app"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[
|
||||
("session:kill", 0, None, &[]),
|
||||
("session:killer-app", 0, None, &[]),
|
||||
("command:Kill selected session", 0, None, &[]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_exact_session_name_beats_prefix_kind_breaks_tie() {
|
||||
expect_results(
|
||||
"kill",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
session(
|
||||
"killer-app",
|
||||
Some("~/dev/killer-app"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:kill",
|
||||
178,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"session:killer-app",
|
||||
146,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"command:Kill selected session",
|
||||
146,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f3_path_segment_boundaries_beat_mid_segment() {
|
||||
expect_results(
|
||||
"foo",
|
||||
&[
|
||||
directory("~/dev/foobar"),
|
||||
directory("~/src/myfoo"),
|
||||
directory("~/foo"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"directory:~/foo",
|
||||
78,
|
||||
Some(MatchFieldName::Path),
|
||||
&[2, 3, 4],
|
||||
),
|
||||
(
|
||||
"directory:~/dev/foobar",
|
||||
74,
|
||||
Some(MatchFieldName::Path),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
(
|
||||
"directory:~/src/myfoo",
|
||||
56,
|
||||
Some(MatchFieldName::Path),
|
||||
&[8, 9, 10],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_no_match_returns_empty() {
|
||||
expect_results(
|
||||
"zzz",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f5_highest_scoring_embedding_beats_greedy() {
|
||||
expect_results(
|
||||
"mtu",
|
||||
&[
|
||||
session("maestro-tui", None, None, None),
|
||||
session("maestro-daemon", None, None, None),
|
||||
session("mtui-scratch", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:mtui-scratch",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
(
|
||||
"session:maestro-tui",
|
||||
91,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 8, 9],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f6_name_beats_weighted_directory() {
|
||||
expect_results(
|
||||
"api",
|
||||
&[
|
||||
session("api", Some("~/dev/web"), Some("bash"), None),
|
||||
session("worker", Some("~/dev/api"), Some("bash"), None),
|
||||
],
|
||||
&[
|
||||
("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]),
|
||||
(
|
||||
"session:worker",
|
||||
50,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f7_case_insensitive_length_breaks_tie() {
|
||||
expect_results(
|
||||
"api",
|
||||
&[
|
||||
session("API-Gateway", None, None, None),
|
||||
session("apiary", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:apiary",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
(
|
||||
"session:API-Gateway",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f8_exact_tool_beats_command_field() {
|
||||
expect_results(
|
||||
"claude",
|
||||
&[
|
||||
session(
|
||||
"nameless",
|
||||
Some("~/dev/x"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
session("clay", Some("~/dev/y"), Some("bash"), None),
|
||||
],
|
||||
&[(
|
||||
"session:nameless",
|
||||
186,
|
||||
Some(MatchFieldName::Tool),
|
||||
&[0, 1, 2, 3, 4, 5],
|
||||
)],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f9_separator_earns_word_boundary_bonus() {
|
||||
expect_results(
|
||||
"ws",
|
||||
&[
|
||||
session("websearch", None, None, None),
|
||||
session("web-search", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:web-search",
|
||||
71,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 4],
|
||||
),
|
||||
("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f10_command_labels_rank_by_boundary_and_gap() {
|
||||
expect_results(
|
||||
"se",
|
||||
&[
|
||||
command("Kill selected session"),
|
||||
command("Create a session"),
|
||||
command("Show help"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"command:Show help",
|
||||
53,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 6],
|
||||
),
|
||||
(
|
||||
"command:Kill selected session",
|
||||
51,
|
||||
Some(MatchFieldName::Label),
|
||||
&[5, 6],
|
||||
),
|
||||
(
|
||||
"command:Create a session",
|
||||
47,
|
||||
Some(MatchFieldName::Label),
|
||||
&[9, 10],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f11_directory_matches_separate_on_initial_gap() {
|
||||
expect_results(
|
||||
"dev",
|
||||
&[
|
||||
session("api", Some("~/dev/api"), Some("bash"), None),
|
||||
session("notes", Some("~/documents/dev-notes"), Some("nvim"), None),
|
||||
session("scratch", Some("~/srv/devops"), Some("bash"), None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:api",
|
||||
54,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[2, 3, 4],
|
||||
),
|
||||
(
|
||||
"session:scratch",
|
||||
50,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
(
|
||||
"session:notes",
|
||||
44,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[12, 13, 14],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f12_exact_beats_prefix() {
|
||||
expect_results(
|
||||
"quit",
|
||||
&[command("Quit Maestro"), command("Quit")],
|
||||
&[
|
||||
(
|
||||
"command:Quit",
|
||||
178,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"command:Quit Maestro",
|
||||
146,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
@@ -499,6 +500,8 @@ mod tests {
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
acp_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,6 +979,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
@@ -1006,6 +1010,7 @@ mod tests {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::Starting,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
@@ -1249,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"),
|
||||
@@ -1264,6 +1270,8 @@ mod tests {
|
||||
activity_since: Utc::now(),
|
||||
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
|
||||
|
||||
@@ -47,6 +47,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
| 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 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
|
||||
|
||||
+47
-9
@@ -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` |
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@
|
||||
<meta name="apple-mobile-web-app-title" content="maestro" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+14
-1
@@ -11,7 +11,8 @@
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"marked": "^18.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.11.6",
|
||||
@@ -1222,6 +1223,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.11",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
|
||||
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
+14
-3
@@ -6,7 +6,7 @@
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b tsconfig.build.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -16,12 +16,23 @@
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"marked": "^18.0.11",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"happy-dom": "^20.11.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.9"
|
||||
"vitest": "^2.1.9",
|
||||
"zustand": "^5.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+994
-11
File diff suppressed because it is too large
Load Diff
+448
@@ -0,0 +1,448 @@
|
||||
// The app shell (#253): everything main.ts used to wire by hand, as one React
|
||||
// tree. The stores are the state — a catalog store fed by the events socket, a
|
||||
// layout store holding the tab/split tree — and the hash route is a deep link
|
||||
// into the latter.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/500.css";
|
||||
import "@fontsource/jetbrains-mono/600.css";
|
||||
import "@fontsource/jetbrains-mono/700.css";
|
||||
|
||||
import { createSession, fetchNames, getDaemonInfo, gitRoot, projects as fetchProjects } from "./api.js";
|
||||
import {
|
||||
CommandSet,
|
||||
DEFAULT_LEADER,
|
||||
controlByte,
|
||||
defineCommands,
|
||||
isBarTypingTarget,
|
||||
} from "./commands.js";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext.js";
|
||||
import { CommandBar, SPAWN_DIRECTORY_EVENT, useCommandBar } from "./components/commandBar/index.js";
|
||||
import { DaemonLink, createDaemonLinkStore } from "./components/daemon/DaemonLink.js";
|
||||
import { RestartOverlay } from "./components/daemon/RestartOverlay.js";
|
||||
import { HelpOverlay } from "./components/dialogs/index.js";
|
||||
import { useViewport } from "./components/hooks/useViewport.js";
|
||||
import { Drawer } from "./components/layout/Drawer.js";
|
||||
import { MultiView, type MultiViewHandle } from "./components/layout/MultiView.js";
|
||||
import { Sidebar } from "./components/sidebar/index.js";
|
||||
import {
|
||||
SpawnModal,
|
||||
spawnSimilarProjectSeed,
|
||||
spawnSimilarSeed,
|
||||
useFastForward,
|
||||
type SpawnFormProps,
|
||||
} from "./components/spawn/index.js";
|
||||
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "./fastForward.js";
|
||||
import { useHashRouter } from "./hooks/useHashRouter.js";
|
||||
import { usePreview } from "./hooks/usePreview.js";
|
||||
import { activeTab, findLeaf, openSessions } from "./layout.js";
|
||||
import { createCatalogStore, type CatalogStore } from "./stores/catalogStore.js";
|
||||
import { createLayoutStore, type LayoutStore } from "./stores/layoutStore.js";
|
||||
import type { Project, Session, SpawnPreset, Uuid } from "./types.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
OPEN_SPAWN_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
requestOpenSpawn,
|
||||
requestToggleDrawer,
|
||||
} from "./uiEvents.js";
|
||||
|
||||
type SpawnSeed = SpawnFormProps["seed"];
|
||||
|
||||
/** Open modal state: `seed` is what the form starts from (undefined for a
|
||||
* plain spawn), `newTab` where the created session lands. */
|
||||
interface SpawnState {
|
||||
seed: SpawnSeed;
|
||||
newTab: boolean;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Shell />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
useViewport();
|
||||
|
||||
// One instance each, for the life of the page.
|
||||
const catalogStore = useMemo(() => createCatalogStore(), []);
|
||||
const layoutStore = useMemo(() => createLayoutStore(), []);
|
||||
const linkStore = useMemo(() => createDaemonLinkStore(), []);
|
||||
|
||||
const sessions = useStore(catalogStore, (s: CatalogStore) => s.sessions);
|
||||
const connected = useStore(catalogStore, (s: CatalogStore) => s.connected);
|
||||
const loaded = useStore(catalogStore, (s: CatalogStore) => s.loaded);
|
||||
const layout = useStore(layoutStore, (s: LayoutStore) => s.layout);
|
||||
|
||||
const [homeDir, setHomeDir] = useState<string | undefined>(undefined);
|
||||
const [leader, setLeader] = useState(DEFAULT_LEADER);
|
||||
const [spawn, setSpawn] = useState<SpawnState | null>(null);
|
||||
|
||||
const { route, navigate } = useHashRouter();
|
||||
const { preview, previewedSessionId, commit: commitPreview, end: endPreview } = usePreview({
|
||||
store: layoutStore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
catalogStore.getState().start();
|
||||
return () => catalogStore.getState().stop();
|
||||
}, [catalogStore]);
|
||||
|
||||
// The daemon's home dir abbreviates cwds to `~/…`; its leader rebinds the
|
||||
// chords hanging off it (#159), so the `?` overlay and the command bar show
|
||||
// the configured chord rather than the default. Absolute paths and the
|
||||
// default leader are fine fallbacks.
|
||||
useEffect(() => {
|
||||
void getDaemonInfo()
|
||||
.then((info) => {
|
||||
setHomeDir(info.home_dir);
|
||||
if (info.leader) setLeader(info.leader);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Installed mobile browsers suspend timers and WebSockets in the background.
|
||||
// Replacing the socket on return avoids leaving the restart overlay up after
|
||||
// a transient lost connection has already recovered.
|
||||
useEffect(() => {
|
||||
const recover = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
void linkStore.getState().probe();
|
||||
catalogStore.getState().reconnect();
|
||||
};
|
||||
document.addEventListener("visibilitychange", recover);
|
||||
window.addEventListener("pageshow", recover);
|
||||
window.addEventListener("online", recover);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", recover);
|
||||
window.removeEventListener("pageshow", recover);
|
||||
window.removeEventListener("online", recover);
|
||||
};
|
||||
}, [catalogStore, linkStore]);
|
||||
|
||||
// The catalog is the authority on which sessions exist: a pane for a session
|
||||
// that is no longer listed gets closed here, rather than waiting on its own
|
||||
// socket to notice. Only once a list has actually landed — before that,
|
||||
// "not listed" means "not known yet" and would close every restored pane.
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
for (const id of openSessions(layout)) {
|
||||
if (!sessions.has(id)) layoutStore.getState().closeSession(id);
|
||||
}
|
||||
}, [loaded, sessions, layout, layoutStore]);
|
||||
|
||||
const tab = activeTab(layout);
|
||||
const focusedSessionId =
|
||||
(tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)?.sessionId) || null;
|
||||
const focusedSession = focusedSessionId ? sessions.get(focusedSessionId) : undefined;
|
||||
|
||||
/** Open/focus a session, settling any preview of it in place. Shared by the
|
||||
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||
* changing, leaving a click back onto that row silently ignored. */
|
||||
const focusSession = useCallback(
|
||||
(sessionId: Uuid) => {
|
||||
// `commit` cancels a pending preview and opens the session; the layout
|
||||
// store settles a matching preview in place rather than moving it twice.
|
||||
commitPreview(sessionId);
|
||||
navigate({ kind: "attach", sessionId });
|
||||
},
|
||||
[commitPreview, navigate],
|
||||
);
|
||||
|
||||
// A deep link (`#/attach/:id`) opens the session it names. Only the route
|
||||
// *changing* does this: focusing a pane rewrites the hash, and re-opening
|
||||
// from that would fight the layout the operator just arranged.
|
||||
const lastRouteRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const key = route.kind === "attach" ? route.sessionId : "catalog";
|
||||
if (lastRouteRef.current === key) return;
|
||||
lastRouteRef.current = key;
|
||||
if (route.kind === "attach") layoutStore.getState().openSession(route.sessionId);
|
||||
}, [route, layoutStore]);
|
||||
|
||||
/** Show a freshly spawned session: it gets its own pane rather than evicting
|
||||
* whatever the operator was reading. */
|
||||
const mountSpawned = useCallback(
|
||||
(session: Session, opts?: { newTab?: boolean }) => {
|
||||
layoutStore.getState().openSessionBeside(session.id, opts);
|
||||
navigate({ kind: "attach", sessionId: session.id });
|
||||
},
|
||||
[layoutStore, navigate],
|
||||
);
|
||||
|
||||
const openSpawn = useCallback(
|
||||
(seed: SpawnSeed, newTab: boolean) => {
|
||||
endPreview();
|
||||
setSpawn({ seed, newTab });
|
||||
},
|
||||
[endPreview],
|
||||
);
|
||||
|
||||
/** The plain spawn: the session you were last on preselects its project, so
|
||||
* `n` then Enter spawns another session there without touching the picker.
|
||||
* It lands in a new tab, not a split of the pane you were reading (#100). */
|
||||
const openPlainSpawn = useCallback(() => {
|
||||
openSpawn(focusedSession ? spawnSimilarProjectSeed(focusedSession) : undefined, true);
|
||||
}, [focusedSession, openSpawn]);
|
||||
|
||||
/** The focused session's project — its base repo for a worktree session, its
|
||||
* cwd otherwise (the rule spawnSimilarProjectSeed uses). */
|
||||
const focusedProjectCwd = focusedSession
|
||||
? (focusedSession.worktree?.base_repo ?? focusedSession.cwd)
|
||||
: undefined;
|
||||
|
||||
const fastForward = useFastForward({
|
||||
focusedCwd: focusedProjectCwd,
|
||||
onSpawned: (session) => mountSpawned(session),
|
||||
onFallback: (seed) => openSpawn(seed, false),
|
||||
});
|
||||
|
||||
const bar = useCommandBar({ onOpen: endPreview });
|
||||
|
||||
useEffect(() => {
|
||||
const onOpenSpawnEvent = () => openPlainSpawn();
|
||||
const onSpawnSimilar = (e: Event) => {
|
||||
const s = (e as CustomEvent<Session>).detail;
|
||||
if (s) openSpawn(spawnSimilarSeed(s), false);
|
||||
};
|
||||
const onSessionSelect = (e: Event) => focusSession((e as CustomEvent<Uuid>).detail);
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
|
||||
document.addEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
|
||||
document.addEventListener(SESSION_SELECT_EVENT, onSessionSelect);
|
||||
return () => {
|
||||
document.removeEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
|
||||
document.removeEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, onSessionSelect);
|
||||
};
|
||||
}, [openPlainSpawn, openSpawn, focusSession]);
|
||||
|
||||
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
||||
// fast-forward spawn, aimed at the chosen directory instead of the most
|
||||
// recent project.
|
||||
useEffect(() => {
|
||||
const onSpawnDirectory = (e: Event) => {
|
||||
void spawnInDirectory((e as CustomEvent<string>).detail, mountSpawned, (seed) =>
|
||||
openSpawn(seed, false),
|
||||
);
|
||||
};
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
|
||||
return () => document.removeEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
|
||||
}, [mountSpawned, openSpawn]);
|
||||
|
||||
// Tab in the session list hands the keyboard to the pane; leader-h hands it
|
||||
// back the other way. Handing it to a previewed pane is choosing it: the
|
||||
// preview settles rather than snapping back when the list blurs.
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MultiViewHandle>(null);
|
||||
const focusPane = useCallback(() => {
|
||||
if (previewedSessionId) commitPreview(previewedSessionId);
|
||||
viewRef.current?.focusPane();
|
||||
}, [previewedSessionId, commitPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener(FOCUS_PANE_EVENT, focusPane);
|
||||
return () => document.removeEventListener(FOCUS_PANE_EVENT, focusPane);
|
||||
}, [focusPane]);
|
||||
|
||||
// The global half of the command registry (#148). `n` opens the spawn modal
|
||||
// and `N` fast-forwards a spawn (#67); both are plain keys, so the set's own
|
||||
// `isTypingTarget` keeps them out of form fields and the terminal's hidden
|
||||
// textarea. Leader-h moves the keyboard between the list and the pane — a
|
||||
// chord, because an attached terminal owns every plain keystroke
|
||||
// (docs/COMMAND-BAR.md).
|
||||
useEffect(() => {
|
||||
if (bar.open || spawn) return;
|
||||
const spawnKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"spawn.open": openPlainSpawn,
|
||||
"spawn.fastForward": () => void fastForward(),
|
||||
}),
|
||||
);
|
||||
const focusKeys = new CommandSet(
|
||||
defineCommands(
|
||||
{
|
||||
"focus.toggle": () => {
|
||||
if (sidebarRef.current?.contains(document.activeElement)) {
|
||||
focusPane();
|
||||
} else {
|
||||
// No-op on desktop; on mobile the list is off-canvas until the
|
||||
// drawer opens, so the focus has somewhere to land.
|
||||
requestToggleDrawer();
|
||||
sidebarRef.current?.querySelector<HTMLElement>("[tabindex]")?.focus();
|
||||
}
|
||||
},
|
||||
// The escape hatch: binding ctrl-k takes readline's
|
||||
// kill-to-end-of-line away from every pane, and leader ctrl-k gives
|
||||
// it back as the literal 0x0B byte (docs/COMMAND-BAR.md).
|
||||
"pane.literalCtrlK": () => viewRef.current?.sendInput("\x0b"),
|
||||
// The leader pressed twice, tmux-style: one literal leader byte,
|
||||
// built from the leader itself so a rebound one sends its own (#158).
|
||||
"pane.literalLeader": () => viewRef.current?.sendInput(controlByte(leader)),
|
||||
},
|
||||
leader,
|
||||
),
|
||||
{ leader },
|
||||
);
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (focusKeys.handle(e)) return;
|
||||
if (isBarTypingTarget(e.target)) return;
|
||||
spawnKeys.handle(e);
|
||||
};
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
return () => window.removeEventListener("keydown", onKeydown, true);
|
||||
}, [bar.open, spawn, openPlainSpawn, fastForward, focusPane, leader]);
|
||||
|
||||
const openIds = useMemo(() => openSessions(layout), [layout]);
|
||||
const barSessions = useMemo(() => [...sessions.values()], [sessions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
sidebar={
|
||||
<div ref={sidebarRef} className="contents">
|
||||
<Sidebar
|
||||
store={catalogStore}
|
||||
homeDir={homeDir}
|
||||
activeId={focusedSessionId}
|
||||
openIds={openIds}
|
||||
previewId={previewedSessionId}
|
||||
onSelect={(id) => focusSession(id)}
|
||||
onPreview={preview}
|
||||
onSpawn={openPlainSpawn}
|
||||
onSpawnDefaults={() => void fastForward()}
|
||||
onSpawnSimilar={(s) => openSpawn(spawnSimilarSeed(s), false)}
|
||||
onFocusPane={focusPane}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MultiView
|
||||
ref={viewRef}
|
||||
store={layoutStore}
|
||||
// Undefined until the first list lands: MultiView's boot
|
||||
// reconciliation prunes against it, and an empty map would take
|
||||
// every restored pane with it.
|
||||
sessions={loaded ? sessions : undefined}
|
||||
onDisconnect={(id) => layoutStore.getState().closeSession(id)}
|
||||
emptyState={<Welcome onSpawn={requestOpenSpawn} />}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<DaemonLink store={linkStore} connected={connected} />
|
||||
<RestartOverlay store={linkStore} />
|
||||
<HelpOverlay />
|
||||
|
||||
{bar.open && (
|
||||
<CommandBar
|
||||
sessions={barSessions}
|
||||
homeDir={homeDir}
|
||||
onOpenSession={(id) => {
|
||||
bar.closeBar();
|
||||
focusSession(id);
|
||||
}}
|
||||
onClose={bar.closeBar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{spawn && (
|
||||
<SpawnModal
|
||||
seed={spawn.seed}
|
||||
onSuccess={(session) => mountSpawned(session, { newTab: spawn.newTab })}
|
||||
onClose={() => setSpawn(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||
try {
|
||||
const info = await getDaemonInfo();
|
||||
if (info.presets?.length) return info.presets;
|
||||
} catch {
|
||||
// keep fallbacks
|
||||
}
|
||||
const { FALLBACK_PRESETS } = await import("./components/spawn/model.js");
|
||||
return FALLBACK_PRESETS;
|
||||
}
|
||||
|
||||
/** A directory the project scan never saw stands in as its own project; one
|
||||
* outside any repo spawns without a worktree, since there is no repo to cut a
|
||||
* branch from. Anything still unresolved opens the pre-filled form. */
|
||||
async function spawnInDirectory(
|
||||
path: string,
|
||||
onSpawned: (session: Session) => void,
|
||||
onFallback: (seed: SpawnSeed) => void,
|
||||
): Promise<void> {
|
||||
const [discovered, handle] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const presets = await spawnPresets();
|
||||
const known = discovered.find((p) => p.path === path);
|
||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||
const resolved = resolveFastForward([project], presets, handle, path);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
const inRepo =
|
||||
known !== undefined ||
|
||||
(await gitRoot(path)
|
||||
.then((r) => !!r.root)
|
||||
.catch(() => false));
|
||||
if (!inRepo) delete req.worktree;
|
||||
try {
|
||||
onSpawned(await createSession(req));
|
||||
return;
|
||||
} catch {
|
||||
// A failed POST degrades to the pre-filled form, never a dead row.
|
||||
}
|
||||
}
|
||||
onFallback(fastForwardSeed(resolved));
|
||||
}
|
||||
|
||||
/** The content pane with nothing open. The ☰ is mobile-only: with no pane
|
||||
* there is no pane header to carry it, so the drawer would be swipe-only. */
|
||||
function Welcome({ onSpawn }: { onSpawn(): void }) {
|
||||
return (
|
||||
<div className="empty content-welcome">
|
||||
<div className="empty-glyph" aria-hidden="true">
|
||||
⌘
|
||||
</div>
|
||||
<div className="empty-title">No session open</div>
|
||||
<p className="empty-hint">
|
||||
Pick a session from the list, or start a new one. Open sessions keep streaming in the
|
||||
background — switching back is instant.
|
||||
</p>
|
||||
<p className="empty-hint">
|
||||
Press <kbd>?</kbd> for keyboard shortcuts.
|
||||
</p>
|
||||
<div className="empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost welcome-sessions"
|
||||
onClick={() => requestToggleDrawer()}
|
||||
>
|
||||
☰ Sessions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
title="Draft pane: n — fast-forward spawn: N"
|
||||
onClick={onSpawn}
|
||||
>
|
||||
+ Spawn session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+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";
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { attachWsUrl } from "./routes.js";
|
||||
import { FOCUS_SIDEBAR_EVENT } from "./catalog.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 type { ClientControl, Uuid } from "./types.js";
|
||||
import { WebglPolicy } from "./webglRenderer.js";
|
||||
|
||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||
* rationed rather than handed to every connection. */
|
||||
const webglPolicy = new WebglPolicy();
|
||||
|
||||
/**
|
||||
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
|
||||
*
|
||||
* The connection is **decoupled from the UI**: it owns a long-lived WS that
|
||||
* keeps streaming PTY output into the terminal buffer whether or not the view
|
||||
* is currently mounted. The shell moves `el` between the visible content pane
|
||||
* and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never
|
||||
* the connection. Only `dispose()` (eviction, an explicit disconnect, or the
|
||||
* session ending) tears the socket down — so switching away and back is
|
||||
* instant, with scrollback already in place.
|
||||
*/
|
||||
export interface AttachConnection {
|
||||
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 AttachOptions {
|
||||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
||||
onRequestClose?: (sessionId: Uuid) => void;
|
||||
/** Invoked when the socket closes on its own — the session ended or the link
|
||||
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
||||
* shell uses this to auto-close the pane rather than leave a dead one. */
|
||||
onClosed?: (sessionId: Uuid) => void;
|
||||
/** Asked on every close: should the socket be retried? The shell answers
|
||||
* from the catalog — see `shouldRetryClose`. */
|
||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 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 = {},
|
||||
info?: AttachInfo,
|
||||
): AttachConnection {
|
||||
const el = document.createElement("div");
|
||||
el.className = "attach";
|
||||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
||||
// tooltip for when you really need it.
|
||||
const titleHtml = info
|
||||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||||
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||||
el.innerHTML = `
|
||||
<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-state" data-state="connecting">connecting…</span>
|
||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||
</header>
|
||||
<div class="attach-body"></div>
|
||||
`;
|
||||
|
||||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
|
||||
const keybar = buildKeyBar((data) => sendInput(data));
|
||||
el.appendChild(keybar.el);
|
||||
|
||||
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
|
||||
stateEl.textContent = s;
|
||||
stateEl.dataset.state = kind;
|
||||
};
|
||||
|
||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
|
||||
// the session list is an off-canvas drawer and this is what opens it.
|
||||
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
||||
requestToggleDrawer(),
|
||||
);
|
||||
|
||||
disconnectBtn.addEventListener("click", () => {
|
||||
opts.onRequestClose?.(sessionId);
|
||||
});
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 13,
|
||||
convertEol: false,
|
||||
theme: {
|
||||
// The terminal stays dark in both app themes (convention); colors are
|
||||
// tuned to the maestro palette — signal-green cursor, slate background.
|
||||
background: "#121419",
|
||||
foreground: "#e6e8ec",
|
||||
cursor: "#5fe3a1",
|
||||
cursorAccent: "#121419",
|
||||
selectionBackground: "#2b6b4f",
|
||||
},
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(body);
|
||||
|
||||
// GPU rendering is scoped to the mounted view, not the connection: acquired
|
||||
// in onMount, released in onUnmount. See webglRenderer.ts.
|
||||
let webgl: WebglAddon | null = null;
|
||||
let dprMedia: MediaQueryList | null = null;
|
||||
|
||||
// The glyph atlas is rasterised for one device pixel ratio. Dragging the
|
||||
// window to a display with a different one (or an Android browser-zoom
|
||||
// change) leaves every cell blurry until the atlas is rebuilt.
|
||||
const onDprChange = () => {
|
||||
try {
|
||||
term.clearTextureAtlas();
|
||||
} catch {
|
||||
// ignore — nothing to rebuild if the renderer already went away
|
||||
}
|
||||
watchDpr();
|
||||
};
|
||||
|
||||
function watchDpr(): void {
|
||||
dprMedia?.removeEventListener("change", onDprChange);
|
||||
dprMedia = null;
|
||||
try {
|
||||
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
|
||||
dprMedia?.addEventListener("change", onDprChange);
|
||||
} catch {
|
||||
// ignore — a browser without `resolution` media queries just keeps the
|
||||
// atlas it has, which is the pre-WebGL behaviour anyway
|
||||
}
|
||||
}
|
||||
|
||||
function releaseWebgl(): void {
|
||||
dprMedia?.removeEventListener("change", onDprChange);
|
||||
dprMedia = null;
|
||||
if (!webgl) return;
|
||||
const addon = webgl;
|
||||
webgl = null;
|
||||
try {
|
||||
addon.dispose(); // xterm falls back to the DOM renderer
|
||||
} catch {
|
||||
// ignore — a lost context can throw on the way out
|
||||
}
|
||||
}
|
||||
|
||||
function acquireWebgl(): void {
|
||||
if (webgl || disposed || !webglPolicy.enabled()) return;
|
||||
try {
|
||||
const addon = new WebglAddon();
|
||||
// Context loss is routine on Android — backgrounding the tab is enough.
|
||||
// Drop back to the DOM renderer, then retry only while the shared budget
|
||||
// allows and this view is still the one on screen.
|
||||
addon.onContextLoss(() => {
|
||||
webglPolicy.noteContextLoss();
|
||||
releaseWebgl();
|
||||
if (mounted) acquireWebgl();
|
||||
});
|
||||
term.loadAddon(addon);
|
||||
webgl = addon;
|
||||
watchDpr();
|
||||
} catch {
|
||||
// Context creation failed outright (no GPU, or the browser is already at
|
||||
// its context cap). Count it like a loss so we stop asking, and stay on
|
||||
// the DOM renderer.
|
||||
webglPolicy.noteContextLoss();
|
||||
webgl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== "keydown" || e.key !== "Escape") return true;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
|
||||
document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT));
|
||||
return false;
|
||||
});
|
||||
|
||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||
// • normal buffer → term.scrollLines walks the scrollback.
|
||||
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
|
||||
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
||||
// scrolling *its* content (the only scroll that exists on the alt screen).
|
||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||||
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||||
// gesture stream; only the terminal surface opts out of native page panning.
|
||||
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
|
||||
// later sibling of the viewport, so it paints over it and takes every touch.
|
||||
{
|
||||
const root = term.element as HTMLElement | undefined;
|
||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||||
const cellH = () => {
|
||||
const core = (term as unknown as {
|
||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
|
||||
})._core;
|
||||
return core?._renderService?.dimensions?.css?.cell?.height || 16;
|
||||
};
|
||||
if (root) {
|
||||
let lastY = 0;
|
||||
let lastX = 0;
|
||||
let remainder = 0;
|
||||
root.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
lastY = e.touches[0].clientY;
|
||||
lastX = e.touches[0].clientX;
|
||||
remainder = 0;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
root.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const ch = cellH();
|
||||
const y = e.touches[0].clientY;
|
||||
const px = lastY - y + remainder; // finger up → scroll toward newer
|
||||
lastY = y;
|
||||
const lines = Math.trunc(px / ch);
|
||||
remainder = px - lines * ch;
|
||||
if (lines === 0) return;
|
||||
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
|
||||
// reporting on — that's the signal to forward instead of scrollback.
|
||||
const mouseTracked = term.modes.mouseTrackingMode !== "none";
|
||||
if (mouseTracked && screen) {
|
||||
// One wheel event per crossed cell, dispatched at the touch point so
|
||||
// xterm computes valid cell coords for the mouse report.
|
||||
const dir = lines > 0 ? 1 : -1;
|
||||
for (let i = 0; i < Math.abs(lines); i++) {
|
||||
screen.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: dir * ch,
|
||||
deltaMode: 0,
|
||||
clientX: lastX,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (term.buffer.active.type !== "alternate") {
|
||||
term.scrollLines(lines);
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile: the special-key bar is only useful while the soft keyboard is up,
|
||||
// so reveal it only when the terminal's hidden textarea is focused (CSS still
|
||||
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
|
||||
// preventDefault, so the bar stays put during use. Re-fit on the toggle since
|
||||
// showing/hiding the bar changes the terminal's available height.
|
||||
const ta = term.textarea;
|
||||
if (ta) {
|
||||
ta.addEventListener("focus", () => {
|
||||
keybar.el.classList.add("keybar-active");
|
||||
requestAnimationFrame(tryFit);
|
||||
});
|
||||
ta.addEventListener("blur", () => {
|
||||
keybar.el.classList.remove("keybar-active");
|
||||
requestAnimationFrame(tryFit);
|
||||
});
|
||||
}
|
||||
|
||||
let mounted = false;
|
||||
// A previewed pane is visible but must not steal the keyboard from the
|
||||
// sidebar, so mounting can opt out of the focus grab (and out of the one
|
||||
// the socket does when it finishes connecting).
|
||||
let focusOnMount = true;
|
||||
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
||||
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
||||
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
||||
// stops ~1–2 columns short of the edge. Same math, minus that subtraction.
|
||||
const tryFit = () => {
|
||||
if (!mounted) return; // parked views have no meaningful size to fit to
|
||||
try {
|
||||
const el2 = term.element;
|
||||
const parent = el2?.parentElement;
|
||||
const core = (term as unknown as {
|
||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
|
||||
})._core;
|
||||
const cell = core?._renderService?.dimensions?.css?.cell;
|
||||
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
|
||||
fit.fit(); // fall back if internals aren't ready yet
|
||||
return;
|
||||
}
|
||||
// Measure the viewport's real inner width (clientWidth excludes any
|
||||
// scrollbar), not the container's — so columns always fit inside the
|
||||
// viewport whether or not xterm reserves a scrollbar. Falls back to the
|
||||
// padded container box before the viewport exists.
|
||||
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
|
||||
let availW: number;
|
||||
let availH: number;
|
||||
if (vp && vp.clientWidth > 0) {
|
||||
availW = vp.clientWidth;
|
||||
availH = vp.clientHeight;
|
||||
} else {
|
||||
const ps = getComputedStyle(parent);
|
||||
const ts = getComputedStyle(el2);
|
||||
availW =
|
||||
parseInt(ps.width) -
|
||||
(parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
|
||||
availH =
|
||||
parseInt(ps.height) -
|
||||
(parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
|
||||
}
|
||||
const cols = Math.max(2, Math.floor(availW / cell.width));
|
||||
const rows = Math.max(1, Math.floor(availH / cell.height));
|
||||
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
|
||||
} catch {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore — fit fails if the container is detached or zero-sized
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let ws: WebSocket;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify(msg));
|
||||
};
|
||||
|
||||
/** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the
|
||||
* mobile key bar so both flow through the same path. */
|
||||
const sendInput = (data: string) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
tryFit();
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
ws = new WebSocket(attachWsUrl(sessionId));
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setState("connected", "open");
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
if (mounted && focusOnMount) term.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (typeof ev.data === "string") {
|
||||
try {
|
||||
JSON.parse(ev.data) as ClientControl;
|
||||
} catch {
|
||||
// ignore unknown control frames
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(ev.data));
|
||||
} else if (ev.data instanceof Blob) {
|
||||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
||||
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setState("error", "error");
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
// The web proxy relays a daemon rejection (unknown session) as an
|
||||
// application close code (4000+) carrying a human reason. Show it in the
|
||||
// pane — both the header and the terminal body — and KEEP the pane up so
|
||||
// the user can read why, instead of letting it flash and vanish (#39).
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason && !disposed) {
|
||||
setState(reason, "error");
|
||||
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
// An intentional dispose has already set `disposed`; eviction and user
|
||||
// close handle their own pane teardown.
|
||||
if (disposed) return;
|
||||
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setState("reconnecting…", "connecting");
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(`closed (${ev.code})`, "closed");
|
||||
opts.onClosed?.(sessionId);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
|
||||
// Ctrl modifier (mobile) folds into the next typed character here.
|
||||
const dataDisp = term.onData((data) => {
|
||||
sendInput(keybar.consume(data));
|
||||
});
|
||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||
sendControl({ type: "Resize", cols, rows });
|
||||
});
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
const visual = window.visualViewport ?? null;
|
||||
visual?.addEventListener("resize", onResize);
|
||||
|
||||
// FitAddon only recomputes on the events we call it from — a window resize
|
||||
// misses container-driven changes (sidebar drag, mobile layout settling,
|
||||
// orientation, late font load). Observe the body directly so columns always
|
||||
// match the real rendered width and the terminal fills edge to edge.
|
||||
const ro = new ResizeObserver(() => tryFit());
|
||||
ro.observe(body);
|
||||
// Re-fit once the monospace font is actually loaded — cell width changes when
|
||||
// the fallback is swapped out, which otherwise leaves stale columns.
|
||||
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
|
||||
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
el,
|
||||
onMount(mountOpts?: { focus?: boolean }) {
|
||||
mounted = true;
|
||||
focusOnMount = mountOpts?.focus !== false;
|
||||
acquireWebgl();
|
||||
// Defer a tick so the element has its content-pane dimensions before fit.
|
||||
requestAnimationFrame(() => {
|
||||
tryFit();
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
|
||||
});
|
||||
},
|
||||
onUnmount() {
|
||||
mounted = false;
|
||||
// Parked views hand their GPU context back: the browser's cap is small
|
||||
// (smaller still on Android) and MAX_PARKED connections outlive the pane.
|
||||
releaseWebgl();
|
||||
},
|
||||
focusTerminal() {
|
||||
term.focus();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", onResize);
|
||||
visual?.removeEventListener("resize", onResize);
|
||||
dataDisp.dispose();
|
||||
resizeDisp.dispose();
|
||||
releaseWebgl();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
term.dispose();
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One key in the mobile key bar.
|
||||
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
|
||||
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
|
||||
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
|
||||
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
|
||||
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
|
||||
* - `char` is a literal character (the symbols the soft keyboard buries behind
|
||||
* a page switch), folded through a pending modifier like typed input.
|
||||
* - `seq` is a plain key (Esc, Tab) sent verbatim.
|
||||
*/
|
||||
type KeyDef =
|
||||
| { label: string; mod: Modifier }
|
||||
| { label: string; cursor: CursorFinal }
|
||||
| { label: string; tilde: number }
|
||||
| { label: string; char: string }
|
||||
| { label: string; seq: string };
|
||||
|
||||
type Modifier = "ctrl" | "alt";
|
||||
/** CSI final bytes: arrows A–D, Home `H`, End `F`. */
|
||||
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
||||
|
||||
/**
|
||||
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
|
||||
* 8-column / 2-row grid (#7):
|
||||
*
|
||||
* Esc Tab Home ↑ End PgUp - /
|
||||
* Ctrl Alt ← ↓ → PgDn | ~
|
||||
*
|
||||
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a
|
||||
* real-keyboard inverted-T in the middle with Home/End filling its top corners,
|
||||
* then the paging pair and the shell symbols that cost a soft-keyboard page
|
||||
* switch to reach. Narrower screens scroll the bar sideways rather than
|
||||
* shrinking the caps below a thumb's width.
|
||||
*/
|
||||
const KEY_DEFS: KeyDef[] = [
|
||||
{ label: "Esc", seq: "\x1b" },
|
||||
{ label: "Tab", seq: "\t" },
|
||||
{ label: "Home", cursor: "H" },
|
||||
{ label: "↑", cursor: "A" },
|
||||
{ label: "End", cursor: "F" },
|
||||
{ label: "PgUp", tilde: 5 },
|
||||
{ label: "-", char: "-" },
|
||||
{ label: "/", char: "/" },
|
||||
{ label: "Ctrl", mod: "ctrl" },
|
||||
{ label: "Alt", mod: "alt" },
|
||||
{ label: "←", cursor: "D" },
|
||||
{ label: "↓", cursor: "B" },
|
||||
{ label: "→", cursor: "C" },
|
||||
{ label: "PgDn", tilde: 6 },
|
||||
{ label: "|", char: "|" },
|
||||
{ label: "~", char: "~" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fold a single character into its control code: letters and `@[\]^_` map to
|
||||
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
|
||||
*/
|
||||
interface KeyBar {
|
||||
readonly el: HTMLElement;
|
||||
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
|
||||
consume(data: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Termux-style special-key bar. Hidden by default; CSS reveals it on
|
||||
* coarse-pointer (touch) devices. `send` delivers bytes to the PTY.
|
||||
*
|
||||
* Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key —
|
||||
* tapped here or typed on the soft keyboard — carries the modifier, then they
|
||||
* disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and
|
||||
* the keyboard stays open.
|
||||
*/
|
||||
function buildKeyBar(send: (data: string) => void): KeyBar {
|
||||
const el = document.createElement("div");
|
||||
el.className = "attach-keybar";
|
||||
el.setAttribute("role", "toolbar");
|
||||
el.setAttribute("aria-label", "Special keys");
|
||||
|
||||
const armed: Record<Modifier, boolean> = { ctrl: false, alt: false };
|
||||
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
|
||||
|
||||
const setMod = (mod: Modifier, on: boolean) => {
|
||||
armed[mod] = on;
|
||||
modBtns[mod]?.setAttribute("aria-pressed", String(on));
|
||||
};
|
||||
const clearMods = () => {
|
||||
setMod("ctrl", false);
|
||||
setMod("alt", false);
|
||||
};
|
||||
|
||||
for (const def of KEY_DEFS) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "keycap";
|
||||
btn.textContent = def.label;
|
||||
|
||||
if ("mod" in def) {
|
||||
btn.classList.add("keycap-mod");
|
||||
btn.setAttribute("aria-pressed", "false");
|
||||
btn.setAttribute("aria-label", `${def.label} modifier`);
|
||||
modBtns[def.mod] = btn;
|
||||
btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod]));
|
||||
} else if ("cursor" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else if ("tilde" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(tildeSeq(def.tilde, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else if ("char" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(applyModsToChar(def.char, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else {
|
||||
btn.addEventListener("click", () => {
|
||||
send(def.seq);
|
||||
clearMods();
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
|
||||
// preventDefault suppresses the native `:active` style, so flash an explicit
|
||||
// press animation here for tactile feedback.
|
||||
btn.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
btn.classList.remove("is-pressed");
|
||||
void btn.offsetWidth; // restart the animation if re-tapped mid-flash
|
||||
btn.classList.add("is-pressed");
|
||||
});
|
||||
btn.addEventListener("animationend", () => btn.classList.remove("is-pressed"));
|
||||
el.appendChild(btn);
|
||||
}
|
||||
|
||||
return {
|
||||
el,
|
||||
consume(data: string): string {
|
||||
if ((armed.ctrl || armed.alt) && data.length === 1) {
|
||||
const out = applyModsToChar(data, armed.ctrl, armed.alt);
|
||||
clearMods();
|
||||
return out;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
|
||||
// happy-dom runs without a backing store; renderSidebar reads the persisted
|
||||
// sidebar width on mount.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
// No daemon here; renderSidebar treats a failed info fetch as "absolute
|
||||
// paths are a fine fallback".
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector(".kill-dialog")?.remove();
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
// Like the real store, deliver an immediate snapshot so the sidebar
|
||||
// renders its rows during construction.
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
||||
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||
}
|
||||
|
||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("blocked-work indicator (#83)", () => {
|
||||
it("marks the row of a blocked session in diff notation", () => {
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
blocked_work: {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
insertions: 124,
|
||||
deletions: 18,
|
||||
},
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||
expect(badge?.textContent).toBe("+124-18↑2");
|
||||
// The spelled-out breakdown survives in the tooltip.
|
||||
expect(badge?.getAttribute("title")).toContain(
|
||||
"3 uncommitted files, 2 unpushed commits",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the file count when tracked changes carry no lines", () => {
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001a",
|
||||
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||
expect(badge?.textContent).toBe("~2?1");
|
||||
});
|
||||
|
||||
it("does not mark clear or unprobed sessions", () => {
|
||||
const clear = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000002",
|
||||
blocked_work: {} as InTheWay,
|
||||
});
|
||||
const unprobed = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000003",
|
||||
});
|
||||
const el = renderSidebar(storeWith([clear, unprobed])).el;
|
||||
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
});
|
||||
|
||||
it("kills straight away, without a first confirmation", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "kill_guarded",
|
||||
message: "worktree has unsaved work",
|
||||
unsaved_work: { untracked_files: 1 },
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
||||
command: "bash",
|
||||
blocked_work: { untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the override prompt without killing on cancel", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { code: "kill_guarded", message: "worktree has unsaved work" },
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000006",
|
||||
blocked_work: { unpushed_commits: 2 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent-reported detail (#112)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("renders the reported detail right after the duration on line 2", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000020",
|
||||
status: "Dead",
|
||||
activity: "Error",
|
||||
activity_since: ago(90_000),
|
||||
activity_detail: "cargo build failed",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
|
||||
const order = [...line2!.children].map((c) => c.className);
|
||||
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
|
||||
expect(order.indexOf("activity-detail")).toBeLessThan(
|
||||
order.findIndex((c) => c.startsWith("card-flag")),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the detail when no report is in force", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000021",
|
||||
activity: "Active",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session cards (#107)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
|
||||
expect(line1).not.toBeNull();
|
||||
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
|
||||
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
|
||||
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
||||
});
|
||||
|
||||
it("always renders line 2 for a freshly spawned session", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
||||
activity: "Active",
|
||||
activity_since: ago(5_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id);
|
||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2).not.toBeNull();
|
||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000012",
|
||||
activity: "Active",
|
||||
activity_since: ago(4 * 60_000 + 30_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id);
|
||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.dataset.activity).toBe("active");
|
||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("dims a name the daemon generated, until it settles (#131)", () => {
|
||||
const generated = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000a1",
|
||||
metadata: { name: "amber-falcon", name_source: "Generated" },
|
||||
});
|
||||
const haiku = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000a2",
|
||||
metadata: { name: "sidebar-rail", name_source: "Haiku" },
|
||||
});
|
||||
const chosen = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000a3",
|
||||
metadata: { name: "my-session", name_source: "User" },
|
||||
});
|
||||
// Named before `name_source` existed: settled, never dimmed.
|
||||
const legacy = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000a4",
|
||||
metadata: { name: "older-session" },
|
||||
});
|
||||
|
||||
const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
|
||||
const provisional = (s: Session) =>
|
||||
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
|
||||
expect(provisional(generated)).toBe(true);
|
||||
expect(provisional(haiku)).toBe(false);
|
||||
expect(provisional(chosen)).toBe(false);
|
||||
expect(provisional(legacy)).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the command as its tool icon when the tool is known", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001b",
|
||||
command: "claude",
|
||||
metadata: { name: "claude" },
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||
expect(cmd?.getAttribute("title")).toBe("claude");
|
||||
// The icon said it all: no text left over.
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the command's arguments, keeping them in the tooltip", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
||||
command: "claude --dangerously-skip-permissions",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
|
||||
});
|
||||
|
||||
it("keeps an unknown command as plain text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001d",
|
||||
command: "hx src/main.rs",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
|
||||
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
const mk = (id: string, activity: string) =>
|
||||
session({
|
||||
id,
|
||||
activity: activity as Session["activity"],
|
||||
status: "Running",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
|
||||
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
|
||||
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
|
||||
const el = renderSidebar(storeWith([idle, wait, error])).el;
|
||||
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"idle",
|
||||
);
|
||||
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an unknown activity variant as idle instead of failing to render", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000016",
|
||||
activity: "Teleported" as Session["activity"],
|
||||
activity_since: ago(120_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
|
||||
expect(line2?.getAttribute("data-activity")).toBe("idle");
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
|
||||
});
|
||||
|
||||
it("moves blocked work onto line 2 as diff notation", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000017",
|
||||
blocked_work: {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
insertions: 124,
|
||||
deletions: 18,
|
||||
},
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("+124");
|
||||
expect(line2?.textContent).toContain("-18");
|
||||
// Line 1 carries no badges.
|
||||
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows exited on line 2 for dead sessions", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000018",
|
||||
status: "Dead",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("exited");
|
||||
});
|
||||
|
||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
command: "claude",
|
||||
metadata: { name: "worker" },
|
||||
activity: "AwaitingInput",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
const rail = row.querySelector<HTMLElement>(".activity-rail");
|
||||
expect(rail?.getAttribute("title")).toContain("Awaiting input");
|
||||
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
expect(row.textContent).not.toMatch(/awaiting.?input/i);
|
||||
});
|
||||
|
||||
it("keeps the attach navigation and depth indentation", () => {
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLAnchorElement;
|
||||
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
|
||||
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
|
||||
});
|
||||
});
|
||||
@@ -1,793 +0,0 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||
import { openForceKillDialog } from "./killDialog.js";
|
||||
import { openContextMenu } from "./contextMenu.js";
|
||||
import { isTypingTarget, toggleHelp } from "./help.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
import {
|
||||
abbreviateFolderName,
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
formatInTheWay,
|
||||
MAX_SESSION_NAME_LEN,
|
||||
normalizeSessionName,
|
||||
sessionBlockers,
|
||||
isProvisionalName,
|
||||
sessionName,
|
||||
} from "./types.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
META_LINE_MIN_HOLD_MS,
|
||||
} from "./activity.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createPaletteSelect, createThemeToggle } from "./theme.js";
|
||||
import type { DaemonLink } from "./daemonLink.js";
|
||||
import { createStatusIndicator } from "./statusIndicator.js";
|
||||
|
||||
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
|
||||
* multi-view controller lives; dispatched from every Spawn affordance. */
|
||||
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
|
||||
export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to 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";
|
||||
|
||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||
* hash navigation, but a split/drag can move focus without ever touching
|
||||
* `location.hash` — so a click back onto a session whose hash still
|
||||
* matches fires no `hashchange` and the app never hears about it. This
|
||||
* event is the click-driven path that doesn't depend on the hash actually
|
||||
* changing. */
|
||||
export const SESSION_SELECT_EVENT = "catalog-session-select";
|
||||
|
||||
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
|
||||
* folder headers are skipped: a directory is not something to focus, and the
|
||||
* cards under it are one row away anyway. A *collapsed* folder still counts —
|
||||
* its header is the only row it has, so skipping it would leave the folder
|
||||
* unreachable. Same rule as the TUI sidebar. */
|
||||
const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]';
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
/** Session whose card is currently being renamed inline (#125). The tree
|
||||
* re-renders on every catalog delta, and an activity update arriving mid-edit
|
||||
* would otherwise replace the field the user is typing into. */
|
||||
let renamingId: string | null = null;
|
||||
|
||||
// docs/SIDEBAR.md: the state word is never rendered — the rail's colour
|
||||
// carries it. It survives only in tooltips/aria labels so meaning is not
|
||||
// colour-only.
|
||||
const STATE_WORDS: Record<string, string> = {
|
||||
active: "Active",
|
||||
idle: "Idle",
|
||||
"awaiting-input": "Awaiting input",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
||||
* full height. The row's own padding (`renderNode`) shifts outward with
|
||||
* nesting depth, carrying the rail with it. */
|
||||
function activityRailMarkup(slug: string, stateWord: string): string {
|
||||
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
|
||||
}
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const dead = s.status === "Dead";
|
||||
const blockers = sessionBlockers(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration =
|
||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||
|
||||
const fragments = [
|
||||
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
||||
s.activity_detail
|
||||
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
||||
: "",
|
||||
commandFragment(s.command),
|
||||
blockers ? blockedFragment(blockers) : "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
|
||||
* — with the spelled-out breakdown kept in the tooltip. */
|
||||
function blockedFragment(blockers: InTheWay): string {
|
||||
const stats = blockerStats(blockers)
|
||||
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
|
||||
.join("");
|
||||
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
|
||||
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
|
||||
* the card. The program becomes its tool icon when we have a mark for it, and
|
||||
* stays as its bare name when we do not. The full command line, arguments and
|
||||
* all, stays in the tooltip either way. */
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
||||
}
|
||||
|
||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
if (node.kind === "folder") {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "tree-folder";
|
||||
|
||||
if (node.name) {
|
||||
const header = document.createElement("button");
|
||||
header.type = "button";
|
||||
header.className = "tree-folder-header";
|
||||
header.style.paddingLeft = `${depth * 16}px`;
|
||||
const isCollapsed = collapsed.has(node.path);
|
||||
header.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
header.dataset.navKey = `folder:${node.path}`;
|
||||
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
|
||||
// glyph and the rotation never fight each other.
|
||||
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
|
||||
// segments to initials (`~/g/n/maestro`) so the dir name stays readable in
|
||||
// a narrow sidebar; the full path lives in the tooltip.
|
||||
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span>`;
|
||||
header.addEventListener("click", () => {
|
||||
if (collapsed.has(node.path)) collapsed.delete(node.path);
|
||||
else collapsed.add(node.path);
|
||||
// re-render handled by caller observing the store; trigger a refresh:
|
||||
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
if (isCollapsed) return wrap;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// The row outlives the snapshot it was built from: a refresh that changes
|
||||
// nothing structural patches it in place rather than replacing it, so the
|
||||
// handlers below have to read the current session, not the one captured here.
|
||||
let cur = node.session;
|
||||
const s = node.session;
|
||||
const now = Date.now();
|
||||
const row = document.createElement("a");
|
||||
row.className = "tree-leaf session-card";
|
||||
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
row.dataset.sessionId = s.id;
|
||||
row.dataset.navKey = `session:${s.id}`;
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
const name = sessionName(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||
row.innerHTML = `
|
||||
${activityRailMarkup(slug, stateWord)}
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${metaLine(s, now)}
|
||||
`;
|
||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void requestKill(cur, sessionName(cur));
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
openContextMenu(ev.clientX, ev.clientY, [
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
||||
},
|
||||
]);
|
||||
});
|
||||
leafPatches.set(row, (next) => {
|
||||
cur = next;
|
||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||
);
|
||||
const rail = row.querySelector(".activity-rail");
|
||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||
const meta = row.querySelector(".card-line2");
|
||||
if (meta) meta.outerHTML = metaLine(next, Date.now());
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Refresh a standing card from a newer snapshot of its session, keeping the
|
||||
* element (and so the pointer's `:hover`) exactly where it is. Registered by
|
||||
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
|
||||
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
|
||||
|
||||
/**
|
||||
* Everything about the tree that a patch cannot fix up.
|
||||
*
|
||||
* The daemon re-sends a session on every activity, foreground or cwd shift —
|
||||
* a couple of times a minute per live session — and rebuilding the sidebar for
|
||||
* each one drops `:hover` from the row under the pointer. Refreshes whose
|
||||
* signature is unchanged are patched into the standing rows instead; anything
|
||||
* that moves, renames, appears or dies changes the signature and rebuilds.
|
||||
*/
|
||||
function treeSignature(node: TreeNode): string {
|
||||
if (node.kind === "folder") {
|
||||
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
|
||||
if (node.name && collapsed.has(node.path)) return head;
|
||||
return `${head}(${node.children.map(treeSignature).join(",")})`;
|
||||
}
|
||||
const s = node.session;
|
||||
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill 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.
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the card's name for a text field (#125). Enter commits, Escape and
|
||||
* blur cancel. An emptied field clears the name, so the card falls back to
|
||||
* the command — the same rule the spawn form's blank name field follows.
|
||||
*/
|
||||
function beginRename(row: HTMLElement, s: Session): void {
|
||||
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
|
||||
if (!nameEl) return;
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "leaf-name leaf-rename";
|
||||
input.value = customSessionName(s) ?? "";
|
||||
input.maxLength = MAX_SESSION_NAME_LEN;
|
||||
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
|
||||
// The card is an <a>, and the tree starts a drag on pointerdown: without
|
||||
// these, clicking into the field navigates away or begins dragging the row.
|
||||
for (const type of ["click", "pointerdown", "dblclick"]) {
|
||||
input.addEventListener(type, (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
renamingId = s.id;
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
renamingId = null;
|
||||
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
||||
return true;
|
||||
};
|
||||
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
// Normalised here so what is sent is what the daemon will store.
|
||||
const name = normalizeSessionName(input.value);
|
||||
if (!finish()) return;
|
||||
void renameSession(s.id, name).catch((e: Error) => {
|
||||
alert(`Failed to rename: ${e.message}`);
|
||||
});
|
||||
} else if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
input.addEventListener("blur", () => finish());
|
||||
|
||||
nameEl.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function renderEmpty(filtering: boolean, 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>
|
||||
<p class="empty-hint">Start your first session — it'll show up here.</p>
|
||||
<div class="empty-actions"></div>`;
|
||||
const spawn = document.createElement("button");
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.addEventListener("click", onSpawn);
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
||||
* pane (quiet), and which one is focused (loud). */
|
||||
export interface SidebarHighlights {
|
||||
open: Set<string>;
|
||||
focused: string | null;
|
||||
}
|
||||
|
||||
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
||||
export interface SidebarHandle {
|
||||
/** The `<aside>` element to mount into the shell. */
|
||||
el: HTMLElement;
|
||||
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
||||
* Back-compat shim over {@link setHighlights}. */
|
||||
setActive(id: string | null): void;
|
||||
/** Apply the full open/focused highlight state. */
|
||||
setHighlights(h: SidebarHighlights): void;
|
||||
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
|
||||
* with `null`. A preview is a view, not state, so this only paints. */
|
||||
setPreview(sessionId: string | null): void;
|
||||
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
||||
focusList(): void;
|
||||
/** Whether the session list currently holds DOM focus. */
|
||||
hasListFocus(): boolean;
|
||||
/** Tear down the store subscription + listeners. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
|
||||
* live session status line, and the folder-grouped session tree. Unlike
|
||||
* the old full-screen catalog this is mounted once by the shell and stays put
|
||||
* while the content pane swaps between sessions — so the store subscription and
|
||||
* scroll position survive navigation. The active session is highlighted.
|
||||
*/
|
||||
export interface SidebarOptions {
|
||||
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
||||
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
||||
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
||||
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
||||
* design kit, which render the sidebar without a daemon. */
|
||||
link?: DaemonLink;
|
||||
/** The highlight moved onto a session, or off every session (`null`): the
|
||||
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
||||
* `source` is there so a mouse sweep can be debounced differently from a
|
||||
* keyboard step. */
|
||||
onPreview?: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||
const el = document.createElement("aside");
|
||||
el.className = "sidebar";
|
||||
|
||||
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
|
||||
// renders with absolute paths until it lands.
|
||||
let homeDir: string | undefined;
|
||||
|
||||
const searchHeader = document.createElement("header");
|
||||
searchHeader.className = "sidebar-search";
|
||||
searchHeader.innerHTML = `
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
`;
|
||||
el.appendChild(searchHeader);
|
||||
|
||||
const treeHost = document.createElement("div");
|
||||
treeHost.className = "tree";
|
||||
el.appendChild(treeHost);
|
||||
|
||||
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
|
||||
// rather than the sidebar's own scrolling content, so they stay reachable
|
||||
// regardless of tree scroll position.
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "sidebar-footer";
|
||||
const helpBtn = document.createElement("button");
|
||||
helpBtn.type = "button";
|
||||
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
||||
helpBtn.textContent = "?";
|
||||
helpBtn.title = "Keyboard shortcuts (?)";
|
||||
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
||||
helpBtn.addEventListener("click", () => toggleHelp());
|
||||
footer.appendChild(helpBtn);
|
||||
footer.appendChild(createThemeToggle());
|
||||
footer.appendChild(createPaletteSelect());
|
||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||
if (import.meta.env.DEV) {
|
||||
const kit = document.createElement("a");
|
||||
kit.className = "btn btn-ghost";
|
||||
kit.href = "?kit";
|
||||
kit.textContent = "kit";
|
||||
kit.title = "Open the design kit (DEV)";
|
||||
footer.appendChild(kit);
|
||||
}
|
||||
// Daemon status sits beside the spawn button: colour for the link state,
|
||||
// hover for the release the daemon is running.
|
||||
const status = opts.link ? createStatusIndicator(opts.link) : null;
|
||||
if (status) footer.appendChild(status.el);
|
||||
const spawnBtn = document.createElement("button");
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
// The keyboard pair sits right here in the tooltip (#67).
|
||||
spawnBtn.title = "Spawn form: n — fast-forward spawn: N";
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
footer.appendChild(spawnBtn);
|
||||
el.appendChild(footer);
|
||||
|
||||
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
||||
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
||||
treeHost.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0 || !opts.onLeafDrag) return;
|
||||
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
|
||||
const id = leaf?.dataset.sessionId;
|
||||
if (id) opts.onLeafDrag(id, e);
|
||||
});
|
||||
// The rows are <a> elements, so the browser's native link drag-and-drop would
|
||||
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
|
||||
// until release. Suppress it so the pointer-based drag runs from the start.
|
||||
treeHost.addEventListener("dragstart", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
|
||||
});
|
||||
|
||||
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
|
||||
// width as a :root CSS var the shell grid reads; clamped to a sane range.
|
||||
const SIDEBAR_W_KEY = "maestro-sidebar-w";
|
||||
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
|
||||
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "sidebar-resize";
|
||||
handle.setAttribute("aria-hidden", "true");
|
||||
el.appendChild(handle);
|
||||
let dragStartX = 0;
|
||||
let dragStartW = 0;
|
||||
let dragging = false;
|
||||
handle.addEventListener("pointerdown", (e) => {
|
||||
dragging = true;
|
||||
dragStartX = e.clientX;
|
||||
dragStartW = el.getBoundingClientRect().width;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
document.body.style.userSelect = "none";
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
|
||||
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
|
||||
});
|
||||
const endDrag = (e: PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
try {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// pointer may already be released
|
||||
}
|
||||
document.body.style.userSelect = "";
|
||||
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
|
||||
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
|
||||
};
|
||||
handle.addEventListener("pointerup", endDrag);
|
||||
handle.addEventListener("pointercancel", endDrag);
|
||||
|
||||
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
||||
let filter = "";
|
||||
search.addEventListener("input", () => {
|
||||
filter = search.value;
|
||||
refresh();
|
||||
});
|
||||
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||
// The previewed row is painted alongside the open/focused tiers so a
|
||||
// re-render can't drop it, but it is never part of `highlights`: a preview
|
||||
// is a view, and nothing about it is durable (docs/PREVIEW.md).
|
||||
let previewing: string | null = null;
|
||||
const applyHighlights = () => {
|
||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const id = n.dataset.sessionId;
|
||||
if (!id) continue;
|
||||
const focused = highlights.focused === id;
|
||||
const open = highlights.open.has(id);
|
||||
n.classList.toggle("is-active", focused);
|
||||
n.classList.toggle("is-open", open && !focused);
|
||||
n.classList.toggle("is-previewing", previewing === id);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- keyboard navigation ----
|
||||
// The walk reads the rendered DOM rather than a parallel model, so it can
|
||||
// never disagree with what is on screen. Rows carry a `data-nav-key`
|
||||
// (`session:<id>` / `folder:<path>`) that survives a re-render.
|
||||
let navKey: string | null = null;
|
||||
|
||||
const navRows = (): HTMLElement[] => [
|
||||
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
|
||||
];
|
||||
|
||||
const rowFor = (key: string | null): HTMLElement | undefined =>
|
||||
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
|
||||
|
||||
const applyNav = () => {
|
||||
const rows = navRows();
|
||||
// A row that went away (session killed, folder collapsed) drops the
|
||||
// highlight rather than leaving it pointing at nothing.
|
||||
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
|
||||
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
||||
};
|
||||
|
||||
const sessionOf = (key: string | null): string | null =>
|
||||
key?.startsWith("session:") ? key.slice("session:".length) : null;
|
||||
|
||||
const moveNav = (delta: number) => {
|
||||
const rows = navRows();
|
||||
if (rows.length === 0) return;
|
||||
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
|
||||
// Nothing highlighted: start one off the end we are walking from, so the
|
||||
// first step lands on the first (or last) row.
|
||||
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
|
||||
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
|
||||
navKey = next.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
next.scrollIntoView({ block: "nearest" });
|
||||
opts.onPreview?.(sessionOf(navKey));
|
||||
};
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||
const activateNav = () => {
|
||||
const row = rowFor(navKey);
|
||||
if (!row) return;
|
||||
if (row.classList.contains("tree-leaf")) {
|
||||
row.click();
|
||||
return;
|
||||
}
|
||||
// Expanding replaces the folder's header in the walk with the rows it was
|
||||
// hiding, so land on the first of them instead of losing the highlight.
|
||||
const key = navKey;
|
||||
row.click();
|
||||
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
|
||||
(h) => h.dataset.navKey === key,
|
||||
);
|
||||
const revealed = header?.parentElement
|
||||
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
|
||||
: undefined;
|
||||
navKey = revealed?.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
};
|
||||
|
||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
||||
// out: there is no hover there, and a tap already opens the session.
|
||||
let hovered: string | null = null;
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
hovered = id;
|
||||
opts.onPreview?.(id);
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||
setHovered(row?.dataset.sessionId ?? null);
|
||||
});
|
||||
treeHost.addEventListener("pointerleave", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setHovered(null);
|
||||
});
|
||||
|
||||
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
||||
// keyboard stays here while j/k runs.
|
||||
treeHost.tabIndex = -1;
|
||||
// The keyboard walk's preview belongs to the list having the keyboard: once
|
||||
// it doesn't, the content pane goes back to what the operator actually opened.
|
||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
|
||||
|
||||
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||
const row = rowFor(navKey);
|
||||
const id = row?.dataset.sessionId;
|
||||
if (!row || !id) return undefined;
|
||||
const session = store.snapshot().sessions.get(id);
|
||||
return session ? { row, session } : undefined;
|
||||
};
|
||||
|
||||
/** `c`: collapse or expand the highlighted folder, the TUI's
|
||||
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
|
||||
* the key does nothing rather than toggling some other folder. */
|
||||
const toggleHighlightedFolder = () => {
|
||||
const row = rowFor(navKey);
|
||||
if (!row || !row.classList.contains("tree-folder-header")) return;
|
||||
row.click();
|
||||
applyNav();
|
||||
};
|
||||
|
||||
treeHost.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
// The inline rename field lives inside a row and owns its own keys.
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
||||
else if (e.key === "Enter") activateNav();
|
||||
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||
else if (e.key === "c") toggleHighlightedFolder();
|
||||
else if (e.key === "x") {
|
||||
const hit = highlightedSession();
|
||||
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
||||
} else if (e.key === "r") {
|
||||
const hit = highlightedSession();
|
||||
if (hit) beginRename(hit.row, hit.session);
|
||||
} else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
/** Signature of what is currently on screen — see `treeSignature`. */
|
||||
let renderedSignature: string | null = null;
|
||||
|
||||
const refresh = () => {
|
||||
// Rebuilding the tree now would blow away the field being typed into.
|
||||
if (renamingId !== null) return;
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir, filter);
|
||||
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
||||
if (signature === renderedSignature) {
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||
if (s) leafPatches.get(row)?.(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (tree.kind === "folder") {
|
||||
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
||||
}
|
||||
applyHighlights();
|
||||
applyNav();
|
||||
};
|
||||
|
||||
void getDaemonInfo()
|
||||
.then((info) => {
|
||||
homeDir = info.home_dir;
|
||||
refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
/* absolute paths are a fine fallback */
|
||||
});
|
||||
|
||||
// Dispatched when something outside the store changed what the tree should
|
||||
// look like — a folder collapsed, a rename ended — none of which the store
|
||||
// snapshot can show, so the signature check has to be bypassed.
|
||||
const rerenderHandler = () => {
|
||||
renderedSignature = null;
|
||||
refresh();
|
||||
};
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||
|
||||
const unsubscribe = store.subscribe(refresh);
|
||||
|
||||
// Contract granularity: re-render at least every 30s while visible so the
|
||||
// duration cutover points land within one tick.
|
||||
const clock = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
refresh();
|
||||
}, 30_000);
|
||||
|
||||
return {
|
||||
el,
|
||||
setActive(id: string | null) {
|
||||
highlights = {
|
||||
open: id ? new Set([id]) : new Set(),
|
||||
focused: id,
|
||||
};
|
||||
applyHighlights();
|
||||
},
|
||||
setHighlights(h: SidebarHighlights) {
|
||||
highlights = h;
|
||||
applyHighlights();
|
||||
},
|
||||
setPreview(sessionId: string | null) {
|
||||
previewing = sessionId;
|
||||
applyHighlights();
|
||||
},
|
||||
focusList() {
|
||||
// Start from the session on screen — that is where the user's attention
|
||||
// already is — falling back to the standing highlight, then the top row.
|
||||
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
|
||||
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
|
||||
navKey = start?.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
treeHost.focus();
|
||||
start?.scrollIntoView({ block: "nearest" });
|
||||
},
|
||||
hasListFocus() {
|
||||
return treeHost.contains(document.activeElement);
|
||||
},
|
||||
dispose() {
|
||||
clearInterval(clock);
|
||||
status?.dispose();
|
||||
// An unfinished rename would otherwise leave every later sidebar
|
||||
// refusing to refresh.
|
||||
renamingId = null;
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
const ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: ID,
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A store whose updates actually reach subscribers, unlike the one-shot
|
||||
* harness in catalog.test.ts. */
|
||||
function liveStore(initial: Session[]) {
|
||||
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
|
||||
const listeners = new Set<CatalogListener>();
|
||||
const store = {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
listeners.add(fn);
|
||||
fn(state);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
const push = (sessions: Session[]) => {
|
||||
state.sessions = new Map(sessions.map((s) => [s.id, s]));
|
||||
for (const fn of listeners) fn(state);
|
||||
};
|
||||
return { store, push };
|
||||
}
|
||||
|
||||
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
|
||||
|
||||
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
|
||||
it("patches an activity change into the standing row instead of rebuilding it", () => {
|
||||
const { store, push } = liveStore([session({ activity: "Idle" })]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ activity: "AwaitingInput" })]);
|
||||
|
||||
// Same element: a rebuild under the pointer would drop `:hover` and restart
|
||||
// the row's transitions, which is the flicker this avoids.
|
||||
expect(rowOf(el)).toBe(before);
|
||||
// …and the card still tells the truth about the session.
|
||||
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
});
|
||||
|
||||
it("rebuilds when the row set changes", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
|
||||
|
||||
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
});
|
||||
|
||||
it("rebuilds when a session dies", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ status: "Dead" })]);
|
||||
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
expect(rowOf(el).dataset.status).toBe("dead");
|
||||
});
|
||||
|
||||
it("rebuilds when a session is renamed", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ metadata: { name: "harmony" } })]);
|
||||
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
// @vitest-environment happy-dom
|
||||
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 {
|
||||
return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init });
|
||||
}
|
||||
|
||||
function el(html: string): HTMLElement {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = html;
|
||||
return d.firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
/** Dispatch on a target, so `isTypingTarget` sees something other than null. */
|
||||
function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean {
|
||||
const e = key(k, init);
|
||||
Object.defineProperty(e, "target", { value: target });
|
||||
return set.handle(e);
|
||||
}
|
||||
|
||||
describe("command specs", () => {
|
||||
it("has a unique id and at least one chord per command", () => {
|
||||
const ids = COMMAND_SPECS.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const spec of COMMAND_SPECS) {
|
||||
expect(spec.keys.length).toBeGreaterThan(0);
|
||||
expect(spec.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("binds each chord once within a scope", () => {
|
||||
const seen = new Set<string>();
|
||||
for (const spec of COMMAND_SPECS) {
|
||||
for (const k of spec.keys) {
|
||||
const slot = `${spec.scope}:${k}`;
|
||||
expect(seen.has(slot)).toBe(false);
|
||||
seen.add(slot);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a handler for an id that isn't in the registry", () => {
|
||||
expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/);
|
||||
});
|
||||
|
||||
it("carries every spec field onto the defined command", () => {
|
||||
const [cmd] = defineCommands({ "spawn.open": () => {} });
|
||||
expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalizeKey", () => {
|
||||
it("spells keys the way the TUI's InputKey::canonical does", () => {
|
||||
expect(canonicalizeKey(key("n"))).toBe("n");
|
||||
expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N");
|
||||
expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?");
|
||||
expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a");
|
||||
expect(canonicalizeKey(key("Escape"))).toBe("esc");
|
||||
expect(canonicalizeKey(key("Enter"))).toBe("enter");
|
||||
expect(canonicalizeKey(key("Tab"))).toBe("tab");
|
||||
expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab");
|
||||
expect(canonicalizeKey(key("ArrowDown"))).toBe("down");
|
||||
});
|
||||
|
||||
it("folds a modified letter to lower case with an explicit shift-", () => {
|
||||
expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
||||
expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
||||
});
|
||||
|
||||
it("has no canonical form for a modifier pressed alone", () => {
|
||||
expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull();
|
||||
expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyCaps", () => {
|
||||
it("renders one cap per modifier and key", () => {
|
||||
expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]);
|
||||
expect(keyCaps("enter")).toEqual(["Enter"]);
|
||||
expect(keyCaps("j")).toEqual(["j"]);
|
||||
expect(keyCaps("N")).toEqual(["N"]);
|
||||
expect(keyCaps("?")).toEqual(["?"]);
|
||||
expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandSet", () => {
|
||||
it("runs the command bound to a chord, and swallows the browser default", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
||||
const e = key("n");
|
||||
expect(set.handle(e)).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves an unbound key alone", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
||||
const e = key("q");
|
||||
expect(set.handle(e)).toBe(false);
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a modified key as a different binding", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
||||
expect(set.handle(key("n", { ctrlKey: true }))).toBe(false);
|
||||
expect(set.handle(key("n", { metaKey: true }))).toBe(false);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs every alias of a command", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "list.next": run }));
|
||||
set.handle(key("j"));
|
||||
set.handle(key("ArrowDown"));
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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 }, DEFAULT_LEADER),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
const input = el("<input>");
|
||||
expect(pressOn(set, input, "n")).toBe(false);
|
||||
expect(plain).not.toHaveBeenCalled();
|
||||
// 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("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>');
|
||||
expect(pressOn(set, textarea, "Escape")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the leader", () => {
|
||||
it("defaults to the TUI's ctrl-a", () => {
|
||||
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,
|
||||
});
|
||||
const e = key("a", { ctrlKey: true });
|
||||
expect(set.handle(e)).toBe(true);
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows the key after the leader rather than running its plain binding", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
});
|
||||
set.handle(key("a", { ctrlKey: true }));
|
||||
expect(set.handle(key("n"))).toBe(true);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
// The next key is an ordinary one again.
|
||||
set.handle(key("n"));
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets a held leader once the chord timeout passes", () => {
|
||||
const run = vi.fn();
|
||||
let now = 0;
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
chordTimeoutMs: 1000,
|
||||
now: () => now,
|
||||
});
|
||||
set.handle(key("a", { ctrlKey: true }));
|
||||
now = 1500;
|
||||
set.handle(key("n"));
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves select-all alone in a real form field", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
});
|
||||
const input = el("<input>");
|
||||
const e = key("a", { ctrlKey: true });
|
||||
Object.defineProperty(e, "target", { value: input });
|
||||
expect(set.handle(e)).toBe(false);
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishing", () => {
|
||||
it("lists a constructed set's commands, replacing an id rather than doubling it", () => {
|
||||
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
||||
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
||||
const ids = publishedCommands().map((c) => c.id);
|
||||
expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]);
|
||||
});
|
||||
|
||||
it("keeps published commands in registry order", () => {
|
||||
const order = COMMAND_SPECS.map((s) => s.id);
|
||||
const ids = publishedCommands().map((c) => c.id);
|
||||
expect(ids).toEqual(order.filter((id) => ids.includes(id)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTypingTarget", () => {
|
||||
it("is true for form fields and editable nodes, false otherwise", () => {
|
||||
expect(isTypingTarget(el("<input>"))).toBe(true);
|
||||
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
||||
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
||||
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
// The web client's command registry (#148). One entry per command — a stable
|
||||
// id, a plain-language label, the key chord it answers to, and a `run()` — so
|
||||
// there is exactly one list of what the client can do. The `?` overlay renders
|
||||
// from it (`help.ts`), and every keybinding is dispatched through it; a command
|
||||
// added here is bound and documented in the same edit.
|
||||
//
|
||||
// Keys are canonical strings, the same spelling the TUI's `InputKey::canonical`
|
||||
// produces (`ctrl-shift-h`, `enter`, `esc`, `n`, `N`) and chords are those
|
||||
// joined by a space (`ctrl-a ctrl-k`). Matching the TUI's spelling is what lets
|
||||
// docs/COMMAND-BAR.md describe one key model for both clients.
|
||||
|
||||
/** Where a command's keys are listened for. `global` is the window, `list` the
|
||||
* sidebar's session tree, `pane` the attached terminal, `overlay` the help
|
||||
* dialog while it is up. */
|
||||
export type CommandScope = "global" | "list" | "pane" | "overlay";
|
||||
|
||||
export interface CommandSpec {
|
||||
/** Stable identifier. Never rendered; safe to bind config and tests to. */
|
||||
id: string;
|
||||
/** Plain-language label — what the `?` overlay and (later) the command bar
|
||||
* show. */
|
||||
label: string;
|
||||
/** Help-overlay section. Groups render in the order they first appear here. */
|
||||
group: string;
|
||||
/** Canonical chords that run it. The first is the one shown to the operator;
|
||||
* the rest are aliases (`j` and `down`). */
|
||||
keys: string[];
|
||||
scope: CommandScope;
|
||||
}
|
||||
|
||||
export interface Command extends CommandSpec {
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/** 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",
|
||||
group: "Sessions",
|
||||
keys: ["n"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "spawn.fastForward",
|
||||
label: "Spawn a session with every default resolved",
|
||||
group: "Sessions",
|
||||
keys: ["N"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "bar.open",
|
||||
label: "Open the command bar",
|
||||
group: "Sessions",
|
||||
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: [`${DEFAULT_LEADER} h`],
|
||||
leaderKeys: (leader) => [`${leader} h`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "list.next",
|
||||
label: "Highlight the next session",
|
||||
group: "Session list",
|
||||
keys: ["j", "down"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.prev",
|
||||
label: "Highlight the previous session",
|
||||
group: "Session list",
|
||||
keys: ["k", "up"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.activate",
|
||||
label: "Open the highlighted session, or expand a folder",
|
||||
group: "Session list",
|
||||
keys: ["enter"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.toggleFolder",
|
||||
label: "Collapse or expand the highlighted folder",
|
||||
group: "Session list",
|
||||
keys: ["c"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.rename",
|
||||
label: "Rename the highlighted session",
|
||||
group: "Session list",
|
||||
keys: ["r"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.kill",
|
||||
label: "Kill the highlighted session",
|
||||
group: "Session list",
|
||||
keys: ["x"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "list.focusPane",
|
||||
label: "Send the keyboard to the pane",
|
||||
group: "Session list",
|
||||
keys: ["tab"],
|
||||
scope: "list",
|
||||
},
|
||||
{
|
||||
id: "help.toggle",
|
||||
label: "Show or hide this help",
|
||||
group: "Help",
|
||||
keys: ["?"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "help.close",
|
||||
label: "Close the help overlay or an open menu",
|
||||
group: "Help",
|
||||
keys: ["esc"],
|
||||
scope: "overlay",
|
||||
},
|
||||
{
|
||||
id: "debug.toggleHud",
|
||||
label: "Toggle the debug HUD",
|
||||
group: "Debug",
|
||||
keys: ["ctrl-shift-d"],
|
||||
scope: "global",
|
||||
},
|
||||
];
|
||||
|
||||
/** 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. `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 = byId.get(id);
|
||||
if (!spec) throw new Error(`unknown command id: ${id}`);
|
||||
return { ...spec, run };
|
||||
});
|
||||
}
|
||||
|
||||
// ---- what the help overlay renders ----
|
||||
|
||||
const published = new Map<string, Command>();
|
||||
|
||||
/** Record commands as bound. Re-registering an id replaces it, so a second
|
||||
* sidebar (tests, hot reload) doesn't duplicate rows in the overlay. */
|
||||
export function publishCommands(cmds: Command[]): void {
|
||||
for (const c of cmds) published.set(c.id, c);
|
||||
}
|
||||
|
||||
/** Every bound command, in `COMMAND_SPECS` order. */
|
||||
export function publishedCommands(): Command[] {
|
||||
return COMMAND_SPECS.map((s) => published.get(s.id)).filter((c): c is Command => c !== undefined);
|
||||
}
|
||||
|
||||
// ---- key canonicalisation ----
|
||||
|
||||
const NAMED: Record<string, string> = {
|
||||
Escape: "esc",
|
||||
Enter: "enter",
|
||||
Tab: "tab",
|
||||
Backspace: "backspace",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
Home: "home",
|
||||
End: "end",
|
||||
PageUp: "pageup",
|
||||
PageDown: "pagedown",
|
||||
Delete: "delete",
|
||||
" ": "space",
|
||||
};
|
||||
|
||||
/** The canonical string for one keydown. Mirrors the TUI's
|
||||
* `InputKey::canonical`: modifier prefixes in ctrl/alt/shift order, then the
|
||||
* key. A shifted character already spells itself (`N`, `?`), so `shift-` is
|
||||
* only written when another modifier is present and the char was folded to
|
||||
* lower case. */
|
||||
export function canonicalizeKey(e: KeyboardEvent): string | null {
|
||||
if (!e.key) return null;
|
||||
// Modifier keys pressed alone are never a binding.
|
||||
if (e.key === "Control" || e.key === "Alt" || e.key === "Shift" || e.key === "Meta") return null;
|
||||
const named = NAMED[e.key];
|
||||
if (named === "tab" && e.shiftKey) return "backtab";
|
||||
let out = "";
|
||||
if (e.ctrlKey) out += "ctrl-";
|
||||
if (e.altKey) out += "alt-";
|
||||
if (e.metaKey) out += "meta-";
|
||||
if (named) {
|
||||
if (e.shiftKey) out += "shift-";
|
||||
return out + named;
|
||||
}
|
||||
if (e.key.length === 1) {
|
||||
if (out === "") return e.key;
|
||||
if (e.shiftKey) out += "shift-";
|
||||
return out + e.key.toLowerCase();
|
||||
}
|
||||
if (e.shiftKey) out += "shift-";
|
||||
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
|
||||
* chords are the ones that stay deaf inside form fields. */
|
||||
function isChorded(chord: string): boolean {
|
||||
return chord.includes(" ") || MOD_PREFIX.test(chord);
|
||||
}
|
||||
|
||||
/** True when the event target swallows plain keystrokes — a form field, or the
|
||||
* hidden textarea xterm types into. Every plain single-key binding is deaf in
|
||||
* the same places. */
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el || typeof el.tagName !== "string") return false;
|
||||
return (
|
||||
el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT" ||
|
||||
el.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
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> = {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
meta: "Meta",
|
||||
shift: "Shift",
|
||||
esc: "Esc",
|
||||
enter: "Enter",
|
||||
tab: "Tab",
|
||||
backtab: "Shift Tab",
|
||||
backspace: "Backspace",
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
left: "Left",
|
||||
right: "Right",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
pageup: "PgUp",
|
||||
pagedown: "PgDn",
|
||||
delete: "Del",
|
||||
space: "Space",
|
||||
};
|
||||
|
||||
/** Keycaps for a chord, left to right: `ctrl-shift-h` → Ctrl, Shift, H. A
|
||||
* multi-key chord separates its keys with a `then` cap. */
|
||||
export function keyCaps(chord: string): string[] {
|
||||
const caps: string[] = [];
|
||||
for (const [i, key] of chord.split(" ").entries()) {
|
||||
if (i > 0) caps.push("then");
|
||||
const parts = key.split("-");
|
||||
// A bare `-` key splits into two empties; treat it as the key itself.
|
||||
if (parts.every((p) => p === "")) {
|
||||
caps.push("-");
|
||||
continue;
|
||||
}
|
||||
const last = parts.pop() as string;
|
||||
const mods = parts.filter((p) => p !== "");
|
||||
for (const m of mods) caps.push(CAPS[m] ?? m);
|
||||
caps.push(CAPS[last] ?? (mods.length > 0 && last.length === 1 ? last.toUpperCase() : last));
|
||||
}
|
||||
return caps;
|
||||
}
|
||||
|
||||
// ---- dispatch ----
|
||||
|
||||
/** How long a partial chord waits for its next key — the TUI's
|
||||
* `chord_timeout_ms` default. */
|
||||
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
||||
|
||||
export interface CommandSetOptions {
|
||||
/** Canonical leader key. A set given one holds every leader press as a
|
||||
* pending chord, whether or not anything is bound behind it yet. */
|
||||
leader?: string | null;
|
||||
chordTimeoutMs?: number;
|
||||
/** Call `preventDefault()` on a handled key. Off for the terminal's own key
|
||||
* handler, which swallows by returning `false` to xterm instead. */
|
||||
preventDefault?: boolean;
|
||||
/** Clock, for tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** A group of commands listening on one surface. Resolves a keydown to a
|
||||
* command the way the TUI's `ChordState` does: hold while the sequence is a
|
||||
* strict prefix of some binding, otherwise resolve and dispatch. */
|
||||
export class CommandSet {
|
||||
private readonly byChord = new Map<string, Command>();
|
||||
private readonly leader: string | null;
|
||||
private readonly timeout: number;
|
||||
private readonly preventDefault: boolean;
|
||||
private readonly now: () => number;
|
||||
private held: string[] = [];
|
||||
private lastPress = 0;
|
||||
|
||||
constructor(
|
||||
readonly commands: Command[],
|
||||
opts: CommandSetOptions = {},
|
||||
) {
|
||||
for (const c of commands) for (const k of c.keys) this.byChord.set(k, c);
|
||||
this.leader = opts.leader ?? null;
|
||||
this.timeout = opts.chordTimeoutMs ?? DEFAULT_CHORD_TIMEOUT_MS;
|
||||
this.preventDefault = opts.preventDefault ?? true;
|
||||
this.now = opts.now ?? (() => Date.now());
|
||||
publishCommands(commands);
|
||||
}
|
||||
|
||||
/** Whether any binding starts with `candidate` and continues. */
|
||||
private isPrefix(candidate: string): boolean {
|
||||
const marker = `${candidate} `;
|
||||
for (const chord of this.byChord.keys()) if (chord.startsWith(marker)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Feed one keydown. Returns true when the key was consumed — dispatched, or
|
||||
* held as a partial chord. */
|
||||
handle(e: KeyboardEvent): boolean {
|
||||
const key = canonicalizeKey(e);
|
||||
if (key === null) return false;
|
||||
if (this.held.length > 0 && this.now() - this.lastPress > this.timeout) this.held = [];
|
||||
const candidate = this.held.length > 0 ? `${this.held.join(" ")} ${key}` : key;
|
||||
this.lastPress = this.now();
|
||||
|
||||
// The leader is a prefix by construction, so it stops being select-all
|
||||
// before anything is bound behind it (#149 binds the first chord).
|
||||
if (this.leader !== null && this.held.length === 0 && key === this.leader) {
|
||||
if (isTypingTarget(e.target) && !isTerminalTextarea(e.target)) return false;
|
||||
this.held = [key];
|
||||
if (this.preventDefault) e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isPrefix(candidate)) {
|
||||
this.held.push(key);
|
||||
if (this.preventDefault) e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
const wasChord = this.held.length > 0;
|
||||
this.held = [];
|
||||
const cmd = this.byChord.get(candidate);
|
||||
if (!cmd) {
|
||||
// A chord that resolved to nothing is still consumed: the leader took
|
||||
// the key, so it must not also fall through to a plain binding.
|
||||
return wasChord;
|
||||
}
|
||||
// Plain single-key bindings are deaf where typing happens; chorded ones are
|
||||
// not, and neither are the pane's and the overlay's, which are only fed
|
||||
// from the surface that owns them.
|
||||
if (
|
||||
!isChorded(candidate) &&
|
||||
cmd.scope !== "pane" &&
|
||||
cmd.scope !== "overlay" &&
|
||||
isTypingTarget(e.target)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.preventDefault) e.preventDefault();
|
||||
cmd.run();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// A session's action page (docs/COMMAND-BAR.md, "Drill-in"): the same overlay
|
||||
// over kill, rename and spawn similar, still fuzzy searchable. The page names
|
||||
// the session it acts on, so the target is never ambiguous.
|
||||
|
||||
import { sessionName, type Session } from "../../types.js";
|
||||
import { ResultList } from "./ResultList.js";
|
||||
import type { BarGroup } from "./model.js";
|
||||
|
||||
export interface ActionPageProps {
|
||||
session: Session;
|
||||
/** The three actions, ranked by the query line — one kind, so no headers. */
|
||||
groups: BarGroup[];
|
||||
highlighted: number;
|
||||
onActivate(index: number): void;
|
||||
}
|
||||
|
||||
export function ActionPage({ session, groups, highlighted, onActivate }: ActionPageProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="cmdbar-page-header border-b border-border px-sp-3 py-sp-1 text-fs-11 text-muted"
|
||||
>
|
||||
{`Actions for "${sessionName(session)}"`}
|
||||
</div>
|
||||
<ResultList
|
||||
groups={groups}
|
||||
showHeaders={false}
|
||||
highlighted={highlighted}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "../../uiEvents.js";
|
||||
import { COMMAND_SPECS, type Command } from "../../commands.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||
|
||||
vi.mock("../../api.js", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listDir: vi.fn(async () => ({ path: "", entries: [] })),
|
||||
projects: vi.fn(async () => []),
|
||||
recentDirs: vi.fn(async () => ({ dirs: [] })),
|
||||
}));
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function command(over: Partial<Command> = {}): Command {
|
||||
return {
|
||||
id: "cmd",
|
||||
label: "A command",
|
||||
group: "Test",
|
||||
keys: ["a"],
|
||||
scope: "global",
|
||||
run: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }),
|
||||
session("worker", {
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
session("notes", { current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
function open(over: Partial<CommandBarProps> = {}) {
|
||||
return render(
|
||||
<CommandBar
|
||||
sessions={sessions}
|
||||
homeDir="/home/me"
|
||||
commands={[]}
|
||||
onOpenSession={() => {}}
|
||||
onClose={() => {}}
|
||||
{...over}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return screen.getByRole("combobox") as HTMLInputElement;
|
||||
}
|
||||
|
||||
function type(value: string): void {
|
||||
fireEvent.change(input(), { target: { value } });
|
||||
}
|
||||
|
||||
function press(key: string, init: Record<string, unknown> = {}): void {
|
||||
fireEvent.keyDown(input(), { key, ...init });
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
function activeName(): string | undefined {
|
||||
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("the overlay", () => {
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
||||
expect(spec?.keys).toEqual(["ctrl-k"]);
|
||||
expect(spec?.scope).toBe("global");
|
||||
});
|
||||
|
||||
it("lists sessions by recency under a kind header on an empty query", () => {
|
||||
open();
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
]);
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
expect(activeName()).toBe("api");
|
||||
});
|
||||
|
||||
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
||||
open();
|
||||
type("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual(
|
||||
["wor"],
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights inside the directory when that is the field that matched", () => {
|
||||
open();
|
||||
type("dev/wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect(
|
||||
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
||||
).toBe("dev/wor");
|
||||
});
|
||||
|
||||
it("renders a no-results state rather than an empty list", () => {
|
||||
open();
|
||||
type("zzz");
|
||||
expect(rowNames()).toEqual([]);
|
||||
expect(screen.getByText("No matches")).toBeDefined();
|
||||
});
|
||||
|
||||
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
||||
open();
|
||||
press("ArrowUp");
|
||||
expect(activeName()).toBe("api");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
press("n", { ctrlKey: true });
|
||||
expect(activeName()).toBe("notes");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("notes");
|
||||
press("p", { ctrlKey: true });
|
||||
expect(activeName()).toBe("worker");
|
||||
});
|
||||
|
||||
it("puts the highlight back on the best row whenever the query changes", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
type("o");
|
||||
expect(activeName()).toBe(rowNames()[0]);
|
||||
});
|
||||
|
||||
it("opens the highlighted session through the caller's open path on Enter", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
open({ onOpenSession, onClose });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises the shared select event when the caller supplies no handler", () => {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
open({ onOpenSession: undefined });
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api");
|
||||
});
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
open({ onOpenSession, onClose });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes on Escape and on a press outside the panel", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stays open for a press inside the panel", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the keyboard to whatever held it when it opened", () => {
|
||||
const before = document.createElement("input");
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
const view = open();
|
||||
expect(document.activeElement).toBe(input());
|
||||
view.unmount();
|
||||
expect(document.activeElement).toBe(before);
|
||||
before.remove();
|
||||
});
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ onOpenSession });
|
||||
fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]);
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sigils", () => {
|
||||
const commands = [
|
||||
command({ id: "help.toggle", label: "Show help", keys: ["?"] }),
|
||||
command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }),
|
||||
];
|
||||
|
||||
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||
open({ commands });
|
||||
type(">");
|
||||
expect(rowNames()).toEqual(["Show help", "Open the spawn form"]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual(
|
||||
["?", "n"],
|
||||
);
|
||||
});
|
||||
|
||||
it("matches a command on its label and never on its chord", () => {
|
||||
open({ commands });
|
||||
type(">help");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
type(">?");
|
||||
expect(rowNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the highlighted command on Enter", () => {
|
||||
const run = vi.fn();
|
||||
open({ commands: [command({ label: "Run me", run })] });
|
||||
type(">run");
|
||||
press("Enter");
|
||||
expect(run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||
open({ commands });
|
||||
type(">show");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
type("");
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
});
|
||||
|
||||
it("lists recent dirs then projects under `~`", async () => {
|
||||
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"]));
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||
const listDirEntries = vi.fn(async () => ["maestro", "api"]);
|
||||
open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries });
|
||||
// The second `~` is a literal query character and reaches the matcher.
|
||||
type("~~/dev/mae");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
|
||||
expect(listDirEntries).toHaveBeenCalledWith("~/dev/");
|
||||
});
|
||||
|
||||
it("spawns in the highlighted directory on Enter", async () => {
|
||||
const onSpawnDirectory = vi.fn();
|
||||
open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Enter");
|
||||
expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x");
|
||||
});
|
||||
|
||||
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
open({ loadDirectories: async () => ["/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Enter");
|
||||
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page", () => {
|
||||
function drill(over: Partial<CommandBarProps> = {}) {
|
||||
const view = open(over);
|
||||
press("Tab");
|
||||
return view;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
drill();
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
drill();
|
||||
expect(screen.getByText('Actions for "api"')).toBeDefined();
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
drill();
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
drill();
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
open({ commands: [command({ label: "Show help" })] });
|
||||
type(">show");
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: async () => ["/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["/srv/x"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
press("Escape");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(input().value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
drill();
|
||||
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
drill();
|
||||
type("ren");
|
||||
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
const onClose = vi.fn();
|
||||
drill({ onClose });
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
for (const [query, key] of [
|
||||
["kill", "onKillSession"],
|
||||
["rename", "onRenameSession"],
|
||||
["spawn", "onSpawnSimilar"],
|
||||
] as const) {
|
||||
const handler = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
drill({ [key]: handler, onClose });
|
||||
type(query);
|
||||
press("Enter");
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
for (const [query, event] of [
|
||||
["kill", KILL_SESSION_EVENT],
|
||||
["rename", RENAME_SESSION_EVENT],
|
||||
["spawn", SPAWN_SIMILAR_EVENT],
|
||||
] as const) {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(event, listener);
|
||||
drill();
|
||||
type(query);
|
||||
press("Enter");
|
||||
document.removeEventListener(event, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api");
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list. Sessions and commands rank together by default; `>` narrows to
|
||||
// commands, `~` to directories. `tab` on a session drills into its action page.
|
||||
//
|
||||
// The bar picks an action and hands off — it never reimplements one. Each
|
||||
// handler defaults to the event the flow that already implements it listens
|
||||
// on, so the React bar and the legacy shell raise the same flows.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "../../uiEvents.js";
|
||||
import { publishedCommands, type Command } from "../../commands.js";
|
||||
import { sessionName, type Session } from "../../types.js";
|
||||
import { ActionPage } from "./ActionPage.js";
|
||||
import { ResultList } from "./ResultList.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
commandBarCandidates,
|
||||
dirEntryPaths,
|
||||
directoryBarCandidates,
|
||||
isActionCandidate,
|
||||
listDirEntries as defaultListDirEntries,
|
||||
loadBarDirectories,
|
||||
parseBarQuery,
|
||||
probeDir,
|
||||
sessionActionCandidates,
|
||||
sessionBarCandidates,
|
||||
PLACEHOLDERS,
|
||||
type BarCandidate,
|
||||
type BarResult,
|
||||
type DirectoryBarCandidate,
|
||||
type SessionAction,
|
||||
} from "./model.js";
|
||||
|
||||
/** Raised when Enter lands on a directory row and no handler was given. The
|
||||
* bar picks the directory; the shell owns the spawn, which is the same
|
||||
* fast-forward path `N` takes. */
|
||||
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||
|
||||
export interface CommandBarProps {
|
||||
/** Sessions to build candidates from, as the store has them right now. */
|
||||
sessions: readonly Session[];
|
||||
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
||||
homeDir?: string;
|
||||
/** The command registry to list under `>`. Defaults to every bound command,
|
||||
* in registry order. */
|
||||
commands?: readonly Command[];
|
||||
/** The directory list behind `~`, fetched once on first use. */
|
||||
loadDirectories?: () => Promise<string[]>;
|
||||
/** One directory's subdirectories, for filling the list in as a path is
|
||||
* typed. Defaults to `GET /api/dirs`. */
|
||||
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||
onOpenSession?(sessionId: string): void;
|
||||
onSpawnDirectory?(path: string): void;
|
||||
onKillSession?(session: Session): void;
|
||||
onRenameSession?(session: Session): void;
|
||||
onSpawnSimilar?(session: Session): void;
|
||||
/** Closing returns the keyboard to whatever held it when the bar opened. */
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
function raise(name: string, detail: unknown): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
}
|
||||
|
||||
export function CommandBar(props: CommandBarProps) {
|
||||
const {
|
||||
sessions,
|
||||
homeDir,
|
||||
commands,
|
||||
loadDirectories = loadBarDirectories,
|
||||
listDirEntries = defaultListDirEntries,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [raw, setRaw] = useState("");
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
// The action page: `page` is the session being acted on, `stashed` the
|
||||
// result-list query it was drilled into from — backing out restores it, so
|
||||
// the round trip costs the operator nothing.
|
||||
const [page, setPage] = useState<Session | null>(null);
|
||||
const [stashed, setStashed] = useState("");
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
const [directories, setDirectories] = useState<DirectoryBarCandidate[]>([]);
|
||||
const [probed, setProbed] = useState<DirectoryBarCandidate[]>([]);
|
||||
const requestedRef = useRef(false);
|
||||
const listingsRef = useRef(new Map<string, string[]>());
|
||||
|
||||
// On the action page the query line is over the three actions, so there is
|
||||
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||
const parsed = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]);
|
||||
|
||||
const sessionCandidates = useMemo(
|
||||
() => sessionBarCandidates(sessions, homeDir),
|
||||
[sessions, homeDir],
|
||||
);
|
||||
const commandCandidates = useMemo(
|
||||
() => commandBarCandidates(commands ?? publishedCommands()),
|
||||
[commands],
|
||||
);
|
||||
|
||||
// Closing returns the keyboard to whatever held it when the bar opened: a
|
||||
// round trip through the bar never silently moves focus.
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
inputRef.current?.focus();
|
||||
return () => {
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The recents-and-projects list, the first time `~` is used. No directories
|
||||
// is a shorter list, not an error state.
|
||||
useEffect(() => {
|
||||
if (parsed.sigil !== "~" || requestedRef.current) return;
|
||||
requestedRef.current = true;
|
||||
let live = true;
|
||||
loadDirectories()
|
||||
.then((paths) => {
|
||||
if (live) setDirectories(directoryBarCandidates(paths, homeDir));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [parsed.sigil, loadDirectories, homeDir]);
|
||||
|
||||
// Fill the list in from `GET /api/dirs` once the query looks like a path: the
|
||||
// directory it names is listed and its children join the candidates, so `~`
|
||||
// reaches a directory that was never spawned in.
|
||||
const dir = parsed.sigil === "~" ? probeDir(parsed.query) : "";
|
||||
useEffect(() => {
|
||||
if (dir === "") {
|
||||
setProbed([]);
|
||||
return;
|
||||
}
|
||||
const cached = listingsRef.current.get(dir);
|
||||
if (cached) {
|
||||
setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir));
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
setProbed([]);
|
||||
listDirEntries(dir)
|
||||
.then((entries) => {
|
||||
listingsRef.current.set(dir, entries);
|
||||
if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir));
|
||||
})
|
||||
.catch(() => {
|
||||
/* an unreadable directory contributes nothing */
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [dir, listDirEntries, homeDir]);
|
||||
|
||||
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
const candidates: BarCandidate[] = useMemo(() => {
|
||||
if (page) return sessionActionCandidates();
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
case "~": {
|
||||
const seen = new Set<string>();
|
||||
return [...directories, ...probed].filter((c) =>
|
||||
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return parsed.query === ""
|
||||
? [...sessionCandidates]
|
||||
: [...sessionCandidates, ...commandCandidates];
|
||||
}
|
||||
}, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => barGroups(barRows(parsed.query, candidates)),
|
||||
[parsed.query, candidates],
|
||||
);
|
||||
// Grouping is a rendering of the ranking, so the flattened groups are the
|
||||
// rows in the order they appear — which is the order the highlight walks and
|
||||
// Enter acts on.
|
||||
const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
// The highlight starts on the globally best row every time the list changes:
|
||||
// an old index would point at a different result.
|
||||
useEffect(() => {
|
||||
setHighlighted(0);
|
||||
}, [rows]);
|
||||
|
||||
/** The highlight does not wrap: it stops at the first and last row. */
|
||||
const move = useCallback(
|
||||
(delta: number) => {
|
||||
setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta)));
|
||||
},
|
||||
[rows.length],
|
||||
);
|
||||
|
||||
const runAction = useCallback(
|
||||
(action: SessionAction, session: Session) => {
|
||||
onClose();
|
||||
switch (action.id) {
|
||||
case "kill":
|
||||
(props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session);
|
||||
return;
|
||||
case "rename":
|
||||
(props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session);
|
||||
return;
|
||||
case "spawn-similar":
|
||||
(props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar],
|
||||
);
|
||||
|
||||
const activate = useCallback(
|
||||
(index: number) => {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const candidate = result.candidate;
|
||||
if (isActionCandidate(candidate)) {
|
||||
// The page cannot be up without a session to act on.
|
||||
if (page) runAction(candidate.action, page);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
(props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))(
|
||||
candidate.session.id,
|
||||
);
|
||||
return;
|
||||
case "command":
|
||||
candidate.command.run();
|
||||
return;
|
||||
case "directory":
|
||||
(props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))(
|
||||
candidate.path,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory],
|
||||
);
|
||||
|
||||
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||
* other kind, and none on the page itself, which has no session rows. */
|
||||
const drillIn = useCallback(() => {
|
||||
const candidate = rows[highlighted]?.candidate;
|
||||
if (page || candidate?.kind !== "session") return;
|
||||
setPage(candidate.session);
|
||||
setStashed(raw);
|
||||
setRaw("");
|
||||
}, [rows, highlighted, page, raw]);
|
||||
|
||||
/** Escape, or backspace at an empty query: back to the result list with the
|
||||
* query the drill-in was made from. */
|
||||
const leavePage = useCallback(() => {
|
||||
setPage(null);
|
||||
setRaw(stashed);
|
||||
setStashed("");
|
||||
}, [stashed]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
// On an action page Escape is one level up, not the way out.
|
||||
if (page) leavePage();
|
||||
else onClose();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
drillIn();
|
||||
return;
|
||||
case "Backspace":
|
||||
// Ordinary editing, except at an empty query on the action page.
|
||||
if (page && raw === "") {
|
||||
e.preventDefault();
|
||||
leavePage();
|
||||
}
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activate(highlighted);
|
||||
return;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
return;
|
||||
case "n":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
}
|
||||
return;
|
||||
case "p":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="cmdbar-overlay fixed inset-0 z-modal flex items-start justify-center bg-bg/70 pt-[12vh] backdrop-blur-sm max-[600px]:items-end max-[600px]:pt-0"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command bar"
|
||||
onMouseDown={(e) => {
|
||||
// A press on the scrim closes; one inside the panel is left alone.
|
||||
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="cmdbar-panel flex w-full max-w-[560px] flex-col overflow-hidden rounded-r-3 border border-border bg-surface shadow-2 max-[600px]:max-w-none max-[600px]:rounded-b-none"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="cmdbar-results"
|
||||
aria-label="Search sessions, commands and directories"
|
||||
aria-activedescendant={activeRowId}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="cmdbar-input w-full border-b border-border bg-transparent px-sp-3 py-sp-2 text-fs-15 text-ink outline-none"
|
||||
placeholder={page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]}
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{page ? (
|
||||
<ActionPage
|
||||
session={page}
|
||||
groups={groups}
|
||||
highlighted={highlighted}
|
||||
onActivate={activate}
|
||||
/>
|
||||
) : (
|
||||
<ResultList
|
||||
groups={groups}
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped.
|
||||
showHeaders={parsed.sigil === null}
|
||||
highlighted={highlighted}
|
||||
onActivate={activate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// The bar's result list: kind headers over rank-ordered rows
|
||||
// (docs/COMMAND-BAR.md, "Structure"). Headers are labels, never rows — the
|
||||
// highlight walks the rows alone, in the order they are rendered.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { keyCaps } from "../../commands.js";
|
||||
import {
|
||||
highlightSegments,
|
||||
isActionCandidate,
|
||||
metaText,
|
||||
rowName,
|
||||
type BarGroup,
|
||||
type BarResult,
|
||||
} from "./model.js";
|
||||
|
||||
export interface ResultListProps {
|
||||
groups: BarGroup[];
|
||||
/** Dropped while a sigil is in force or on the action page: one kind needs
|
||||
* no header. */
|
||||
showHeaders: boolean;
|
||||
/** Index into the flattened rows, in the order they render. */
|
||||
highlighted: number;
|
||||
onActivate(index: number): void;
|
||||
}
|
||||
|
||||
function Highlighted({ text, ranges }: { text: string; ranges: BarResult["ranges"] }) {
|
||||
return (
|
||||
<>
|
||||
{highlightSegments(text, ranges).map((segment, i) =>
|
||||
segment.hit ? (
|
||||
<mark key={i} className="cmdbar-hit bg-transparent font-semibold text-accent">
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{segment.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
result,
|
||||
index,
|
||||
active,
|
||||
onActivate,
|
||||
}: {
|
||||
result: BarResult;
|
||||
index: number;
|
||||
active: boolean;
|
||||
onActivate(index: number): void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const candidate = result.candidate;
|
||||
|
||||
useEffect(() => {
|
||||
if (active) ref.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const name = rowName(candidate);
|
||||
const nameMatched =
|
||||
result.field === "name" || result.field === "label" || result.field === "path";
|
||||
const meta = metaText(result);
|
||||
const dead = candidate.kind === "session" && candidate.session.status === "Dead";
|
||||
const chord =
|
||||
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||
? candidate.command.keys[0]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="option"
|
||||
id={`cmdbar-row-${index}`}
|
||||
aria-selected={active}
|
||||
data-index={index}
|
||||
data-kind={candidate.kind}
|
||||
data-session-id={candidate.kind === "session" ? candidate.session.id : undefined}
|
||||
data-action-id={isActionCandidate(candidate) ? candidate.action.id : undefined}
|
||||
data-command-id={
|
||||
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||
? candidate.command.id
|
||||
: undefined
|
||||
}
|
||||
data-path={candidate.kind === "directory" ? candidate.path : undefined}
|
||||
className={[
|
||||
"cmdbar-row flex cursor-pointer items-baseline gap-sp-2 rounded-r-1 px-sp-3 py-sp-1 text-fs-13",
|
||||
active ? "is-active bg-surface-2 text-ink" : "text-ink",
|
||||
dead ? "is-dead opacity-60" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => onActivate(index)}
|
||||
>
|
||||
<span className="cmdbar-row-name truncate">
|
||||
{nameMatched ? <Highlighted text={name} ranges={result.ranges} /> : name}
|
||||
</span>
|
||||
{chord !== undefined ? (
|
||||
<span className="cmdbar-row-keys ml-auto flex gap-1">
|
||||
{keyCaps(chord).map((cap, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="rounded-r-1 border border-border px-1 text-fs-11 text-muted"
|
||||
>
|
||||
{cap}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
) : meta ? (
|
||||
<span className="cmdbar-row-meta ml-auto truncate text-fs-11 text-muted">
|
||||
{result.field !== undefined && result.field !== "name" ? (
|
||||
<Highlighted text={meta} ranges={result.ranges} />
|
||||
) : (
|
||||
meta
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultList({ groups, showHeaders, highlighted, onActivate }: ResultListProps) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div
|
||||
id="cmdbar-results"
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||
>
|
||||
<div className="cmdbar-empty px-sp-3 py-sp-2 text-fs-12 text-muted">No matches</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
return (
|
||||
<div
|
||||
id="cmdbar-results"
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<div key={group.kind}>
|
||||
{showHeaders ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="cmdbar-group-title px-sp-3 pt-sp-2 pb-1 text-fs-11 uppercase tracking-wide text-muted"
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
) : null}
|
||||
{group.results.map((result) => {
|
||||
const at = index++;
|
||||
return (
|
||||
<Row
|
||||
key={at}
|
||||
result={result}
|
||||
index={at}
|
||||
active={at === highlighted}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user