Compare commits
25
Commits
v2.1.0
...
7764c896fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7764c896fe | ||
|
|
d0e324ee42 | ||
|
|
fad539ca63 | ||
|
|
c56d76a77f | ||
|
|
8b10130c87 | ||
|
|
eb1eb78dfe | ||
|
|
48d74b1ac5 | ||
|
|
e194f02802 | ||
|
|
f987cd3bb1 | ||
|
|
70c293b163 | ||
|
|
88ed4bf391 | ||
|
|
42955b6417 | ||
|
|
1147bfac0e | ||
|
|
d460e8e7d5 | ||
|
|
2aed695cd9 | ||
|
|
5428b5a2db | ||
|
|
c4bccd35a3 | ||
|
|
2b93bb1cb2 | ||
|
|
ae8d774529 | ||
|
|
74f9a45539 | ||
|
|
7c2eae42f1 | ||
|
|
68aa34e9dd | ||
|
|
8697f79a0a | ||
|
|
78b38e389d | ||
|
|
7046bf9c70 |
@@ -93,9 +93,10 @@ Cutting a release is its own commit. In a single commit:
|
||||
1. Move all entries from `## [Unreleased]` under a new heading `## [X.Y.Z] — YYYY-MM-DD`.
|
||||
2. Leave an empty `## [Unreleased]` section at the top with its subsection skeleton ready.
|
||||
3. Bump `pubspec.yaml` `version:` to `X.Y.Z` (drop the `-dev` suffix for the tag; re-add it on the next development commit if desired).
|
||||
4. Commit subject: `release vX.Y.Z`.
|
||||
4. Run `make gen-build-info` so `assets/licenses.yaml` `self.version:` re-syncs from pubspec (it's auto-rewritten by every build but commit the fresh state). Stage the resulting diff alongside step 3.
|
||||
5. Commit subject: `release vX.Y.Z`.
|
||||
|
||||
`pubspec.yaml` is the single source of truth for the version — the Makefile reads it for ldflag stamping of the sidecar binary, and the Flutter app reads it for build info. Bumping `pubspec.yaml` and the changelog out of sync is the mistake this rule prevents.
|
||||
`pubspec.yaml` is the single source of truth for the version. Every `make` build/run/test target regenerates `lib/src/build_info.g.dart` (gitignored) and rewrites `assets/licenses.yaml` `self.version:` from it — so the Flutter app sees the current version everywhere without manual sync. Bumping `pubspec.yaml` and the changelog out of sync is the mistake this rule prevents.
|
||||
|
||||
## Attribution trailer
|
||||
|
||||
|
||||
+16
@@ -37,6 +37,22 @@ tools/ui/.serve.pid
|
||||
# coverage/ is committed.
|
||||
/coverage/
|
||||
|
||||
# -- Tee'd flutter-test output (see Makefile `make t T=...`) ---------
|
||||
# Lives under test/ so the repo root stays uncluttered.
|
||||
/test/.test-output/
|
||||
|
||||
# -- Build-time stamps generated by `make gen-build-info` -----------
|
||||
# version (from pubspec.yaml), commit (git short SHA), date (UTC).
|
||||
# Regenerated on every make build/run/test target.
|
||||
/lib/src/build_info.g.dart
|
||||
|
||||
# -- Built C `clide` shell client (T-126). Source under ----------
|
||||
# native/clide-cli/ stays in git; the per-platform compiled binary
|
||||
# is built by `make clide-cli`.
|
||||
/native/linux-x64/clide
|
||||
/native/macos-arm64/clide
|
||||
/native/macos-x64/clide
|
||||
|
||||
# -- Test, coverage, profile output ------------------------------------
|
||||
*.test
|
||||
*.out
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'T-125', '2026-05-18 11:59:28', '2026-05-18 11:59:28', NULL, 'fbe1dfb1615023fa202e252cb739bd7a', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'T-126', '2026-05-18 11:59:31', '2026-05-18 11:59:31', NULL, '7b10042aaffdce1e47ab70a994383bf8', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-125', 'T-126', '2026-05-18 11:59:34', '2026-05-18 11:59:34', NULL, 'a0b6f14621e099766b5114019aa19387', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'T-127', '2026-05-18 11:59:37', '2026-05-18 11:59:37', NULL, 'ea019bca081078c5ffa8a8b7cac25c06', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-127', 'T-128', '2026-05-18 11:59:41', '2026-05-18 11:59:41', NULL, 'f427a991e08749b0af3f687e1a79662b', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'T-129', '2026-05-18 11:59:44', '2026-05-18 11:59:44', NULL, '817d449ac21409e538b265a149b2cbbf', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'T-129', '2026-05-18 11:59:47', '2026-05-18 11:59:47', NULL, '0c981e27934388b8318f93864ce2ed5a', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'T-130', '2026-05-18 11:59:51', '2026-05-18 11:59:51', NULL, 'bbae054d4114c63da5e9879645735731', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'T-131', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, '41a5ab9997a7fea9d18203b5b3a6dabe', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-127', 'T-131', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, '734748d235d679f688e6b2d698498c83', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-128', 'T-131', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, 'bd0ef38128e33691ec48eed74894a496', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-125', 'T-131', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, 'c2540c385f7868140669a6692d5c84ee', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'T-131', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, 'f73653ab142777d6070365c96b160fa4', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
@@ -1813,3 +1813,82 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:53:10', '2026-05-18 07:53:10', '2026-05-18 07:53:10', NULL, 'ce2b66658977edc3fddc78079d7852e5', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:02:06', '2026-05-18 08:02:06', '2026-05-18 08:02:06', NULL, 'ee75fea173ae492a6ad8a1d888a2142d', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:07:23', '2026-05-18 08:07:23', '2026-05-18 08:07:23', NULL, '9dd61b76946909aa44edaf9d1c1741a0', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'status', 'backlog', 'done', NULL, '2026-05-18 08:22:19', '2026-05-18 08:22:19', '2026-05-18 08:22:19', NULL, '861af9256207314d681e51abc77c386f', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:23:34', '2026-05-18 08:23:34', '2026-05-18 08:23:34', NULL, '94f4a894c1dddfa87ee8306a9758c808', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:26:05', '2026-05-18 08:26:05', '2026-05-18 08:26:05', NULL, 'b8978359fc1b83ea0bcfc6cd5d34d5de', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'done', 'done', NULL, '2026-05-18 09:30:59', '2026-05-18 09:30:59', '2026-05-18 09:30:59', NULL, '2039c525646caaf89a735454f7278162', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-107', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 10:27:25', '2026-05-18 10:27:25', '2026-05-18 10:27:25', NULL, 'e40d2c9ddd9915ad2e32302bd555c99f', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-107', 'status', 'in_progress', 'done', NULL, '2026-05-18 10:30:30', '2026-05-18 10:30:30', '2026-05-18 10:30:30', NULL, 'd5ecc2578b5b48991d07531eb2166529', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-116', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 10:35:47', '2026-05-18 10:35:47', '2026-05-18 10:35:47', NULL, '59b70e93c913952d4f13b9ad2320e060', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-116', 'status', 'in_progress', 'done', NULL, '2026-05-18 11:22:46', '2026-05-18 11:22:46', '2026-05-18 11:22:46', NULL, '16fd285587d7a37090d2a21f4307c8e8', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-74', 'status', 'ready', 'in_progress', NULL, '2026-05-18 11:49:28', '2026-05-18 11:49:28', '2026-05-18 11:49:28', NULL, 'e25e13a5afcbc376b3c57312c6f320ea', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-74', 'status', 'in_progress', 'done', NULL, '2026-05-18 11:51:40', '2026-05-18 11:51:40', '2026-05-18 11:51:40', NULL, '4343b4758e9ffbf3e046d11d65dfeb1f', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'description', 'First slice of T-99(a). Foundation for the rest.
|
||||
|
||||
Open a per-user unix-domain socket on Flutter app boot. Accept JSON-lines per the existing IpcRequest envelope. Route each request through the existing DaemonDispatcher (already wired in main.dart via daemonClientFactory). Tear down on app shutdown.
|
||||
|
||||
Acceptance:
|
||||
1. lib/src/ipc/server.dart exists; binds an AF_UNIX socket at a per-user path (resolve in this ticket; current Q-record references Q-1 / Q-3 for auth/persistence concerns but the bare path strategy belongs here).
|
||||
2. App boot starts the server; app shutdown closes the socket file cleanly.
|
||||
3. `socat - UNIX-CONNECT:$SOCK <<< ''{"command":"git.status"}''` returns a JSON-line response. Tests use a synthetic socket fixture.
|
||||
4. Multi-connection accept loop — concurrent socat invocations serialise through the dispatcher (single Flutter event loop) but don''t fail each other.
|
||||
5. No client yet — that lands in T-126.
|
||||
|
||||
Source: T-99 sketch. Coordinate with: T-127, T-130.', 'First slice of T-99(a). Foundation for the rest.
|
||||
|
||||
Open a per-workspace unix-domain socket on Flutter app boot. Accept JSON-lines per the existing IpcRequest envelope. Route each request through the existing DaemonDispatcher (already wired in main.dart via daemonClientFactory). Tear down on app shutdown.
|
||||
|
||||
**Architectural commitments (read these first):**
|
||||
- D-70 — socket path is `$XDG_RUNTIME_DIR/clide/<sha256(workspace-root)[:16]>.sock` on Linux, `$HOME/Library/Caches/clide/<sha256(workspace-root)[:16]>.sock` on macOS. No env override. Workspace root = git toplevel.
|
||||
- D-71 — socket file is `0600`, parent dir is `0700`. No token auth at this layer.
|
||||
- D-72 — multi-connection accept loop, serial dispatch on the main isolate. Per-handler isolate offload as needed; not the IPC layer''s concern.
|
||||
|
||||
**Acceptance:**
|
||||
1. lib/src/ipc/server.dart exists; binds an AF_UNIX socket at the D-70 path; creates the parent dir with the D-71 perms.
|
||||
2. App boot starts the server; app shutdown closes the socket file cleanly and removes it.
|
||||
3. `socat - UNIX-CONNECT:$SOCK <<< ''{"command":"git.status"}''` returns a JSON-line response. Tests use a synthetic socket fixture (tempdir + XDG_RUNTIME_DIR override at the env level).
|
||||
4. Multi-connection accept loop — concurrent socat invocations interleave at the I/O level but serialise through DaemonDispatcher (per D-72) without failing each other.
|
||||
5. Stale socket on boot (left over from a crashed clide) is detected and unlinked before binding — same `live-daemon probe` pattern already used elsewhere in the codebase.
|
||||
6. No client yet — that lands in T-126.
|
||||
|
||||
Source: T-99 sketch. Coordinates with: T-127 (InProcessClient swap), T-130 (MCP).', NULL, '2026-05-18 12:42:56', '2026-05-18 12:42:56', '2026-05-18 12:42:56', NULL, 'abb7a039f73d430cdba594b2a4ac381a', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 12:45:10', '2026-05-18 12:45:10', '2026-05-18 12:45:10', NULL, 'aa1b70849399f4caee043d40e48d1edf', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'status', 'in_progress', 'done', NULL, '2026-05-18 12:51:08', '2026-05-18 12:51:08', '2026-05-18 12:51:08', NULL, 'b928ce09ae0b7dbb77bf93f8c0593657', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-125', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 15:49:20', '2026-05-18 15:49:20', '2026-05-18 15:49:20', NULL, 'ac0838fac9e3c97f1728256910776bed', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-125', 'status', 'in_progress', 'done', NULL, '2026-05-18 15:51:09', '2026-05-18 15:51:09', '2026-05-18 15:51:09', NULL, '938624de487aa2f02e034424cae40c77', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 15:54:20', '2026-05-18 15:54:20', '2026-05-18 15:54:20', NULL, '3266e7ea943867f49ff20a90b7f12ae6', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'status', 'in_progress', 'done', NULL, '2026-05-18 16:04:29', '2026-05-18 16:04:29', '2026-05-18 16:04:29', NULL, 'b6c783f599585e55ecc36f69ccbcefb1', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-127', 'status', 'backlog', 'in_progress', NULL, '2026-05-19 09:57:18', '2026-05-19 09:57:18', '2026-05-19 09:57:18', NULL, '64a14cb5d8b9274fd9d72d1e7474040c', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-127', 'status', 'in_progress', 'done', NULL, '2026-05-19 10:03:26', '2026-05-19 10:03:26', '2026-05-19 10:03:26', NULL, 'b8bf877d2057f7baa5acf6af7a13d529', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-128', 'status', 'backlog', 'in_progress', NULL, '2026-05-19 10:05:09', '2026-05-19 10:05:09', '2026-05-19 10:05:09', NULL, '763a80ec25b5fe712e5ad44b75dbb22f', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-128', 'status', 'in_progress', 'done', NULL, '2026-05-19 10:06:46', '2026-05-19 10:06:46', '2026-05-19 10:06:46', NULL, 'cc53b46764f28515d735aa77c8eeef2c', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-129', 'status', 'backlog', 'in_progress', NULL, '2026-05-19 12:06:39', '2026-05-19 12:06:39', '2026-05-19 12:06:39', NULL, '575584f46d21b51bab3e69da1b940718', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-129', 'status', 'in_progress', 'done', NULL, '2026-05-19 12:19:51', '2026-05-19 12:19:51', '2026-05-19 12:19:51', NULL, 'e9bf50652357521c7ee74bd6577e8108', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-130', 'status', 'backlog', 'in_progress', NULL, '2026-05-19 12:24:44', '2026-05-19 12:24:44', '2026-05-19 12:24:44', NULL, '63dd7e0c4346a5d3cdd81c9c477339c3', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-130', 'status', 'in_progress', 'done', NULL, '2026-05-19 12:33:52', '2026-05-19 12:33:52', '2026-05-19 12:33:52', NULL, 'f3060e086423af96f99684e3718cac45', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-131', 'status', 'backlog', 'in_progress', NULL, '2026-05-19 13:10:27', '2026-05-19 13:10:27', '2026-05-19 13:10:27', NULL, 'c49e76c430de0cc42d7919ade9c5cfa5', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-119', 'status', 'backlog', 'ready', NULL, '2026-05-19 13:14:01', '2026-05-19 13:14:01', '2026-05-19 13:14:01', NULL, '726631190b62bd38fb34f7201d2f27ee', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-120', 'description', 'T-104 covered the spot-fixes (argv-injection rejection, size/count caps on specific commands). What remains is the framework piece: a typed schema per IPC command — branch/remote/path/etc. with regex/charset constraints — applied at DaemonDispatcher dispatch time rather than scattered through individual handlers.
|
||||
|
||||
This needs design before code:
|
||||
- Where the schema lives (per-handler? a central registry?)
|
||||
- How it composes with the IpcRequest envelope
|
||||
- Whether it generates the existing handler boilerplate or wraps it
|
||||
- Coordination with T-99''s IPC architecture decision (if we go socket-server, the schema becomes the wire contract)
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]" item 1.', 'T-104 covered the spot-fixes (argv-injection rejection, size/count caps on specific commands). What remains is the framework piece: a typed schema per IPC command — branch/remote/path/etc. with regex/charset constraints — applied at DaemonDispatcher dispatch time rather than scattered through individual handlers.
|
||||
|
||||
Re-scope (2026-05-19, post T-99): the wire contract is now real — IpcRequest envelopes flow over the unix socket (per D-70/71/72) and through the C clide client which serialises raw argv under the `_argv` sentinel (per D-72/D-6). The argv parser (lib/src/cli/argv_to_request.dart) is the de-facto schema today; T-120 should formalise it by:
|
||||
|
||||
- Lifting the per-cmd grammar from argv_to_request.dart into a typed registry keyed by cmd, sharing it with both the argv parser and the dispatcher.
|
||||
- Using the same registry to validate inbound IpcRequests at dispatch time (regex/charset constraints on branch/remote/path args).
|
||||
- Exposing the registry to the MCP server (T-130) so the `tools/list` payload is generated rather than hand-rolled.
|
||||
|
||||
Design still open:
|
||||
- Where the schema lives (per-handler? central registry?).
|
||||
- Whether it generates handler boilerplate or wraps existing handlers.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]" item 1.', NULL, '2026-05-19 13:14:12', '2026-05-19 13:14:12', '2026-05-19 13:14:12', NULL, '3511c5e7731f568de7af33c1970ad97e', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-99', 'status', 'backlog', 'done', NULL, '2026-05-19 13:14:41', '2026-05-19 13:14:41', '2026-05-19 13:14:41', NULL, '5ff47ab0fc7fb8cb8e9e039be48ada2a', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-131', 'status', 'in_progress', 'done', NULL, '2026-05-19 13:14:41', '2026-05-19 13:14:41', '2026-05-19 13:14:41', NULL, 'a57b3fbe4e6815fcbb28691a08130801', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-119', 'status', 'ready', 'in_progress', NULL, '2026-05-19 14:44:43', '2026-05-19 14:44:43', '2026-05-19 14:44:43', NULL, '1d5048cf9a031c504aa3df570a7b0cae', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -1895,3 +1895,327 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
||||
Order matters: 2 → 3 → 1 (clean changelog, release, then doc the workflow).
|
||||
|
||||
Source: consultants.md "Docs — Findings".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:03', '2026-05-18 08:07:23', NULL, '7c1f64a5aef6e3e92157691a2b192beb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'task', 'T-97', 'cut interim release to drain Unreleased (split from T-109)', 'Item 3 of T-109. With CONTRIBUTING.md landed and [Unreleased] consolidated, the remaining piece is cutting an actual release so the ~80-commit Unreleased backlog drains into a dated heading.
|
||||
|
||||
Per .claude/skills/git-commit/SKILL.md "Cutting a release":
|
||||
1. Move every [Unreleased] entry under a new `## [X.Y.Z] — YYYY-MM-DD` heading.
|
||||
2. Leave an empty [Unreleased] skeleton.
|
||||
3. Bump pubspec.yaml version: (drop the -dev suffix for the tag).
|
||||
4. Commit subject: `release vX.Y.Z`.
|
||||
|
||||
Version pick is the user''s call — likely 2.1.0 given the scope since 2.0.0 (D-66 95% coverage gate, T-117 keymap, contrast-gate split, IPC hardening, panel-keyboard parity, etc.).
|
||||
|
||||
Source: consultants.md "Docs — Findings" item 3.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 08:07:23', '2026-05-18 08:22:19', NULL, '6f9f48da5d95074af1abed3df64b109a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'story', NULL, 'startup project picker; "always open this project" override', 'Startup flow change: on launch, always show the project picker screen unless the user has explicitly checked an "always open this project" box for a specific project (sticky default).
|
||||
|
||||
**Acceptance:**
|
||||
1. Default startup: project picker is the first screen, regardless of last-opened state.
|
||||
2. Picker has an "always open this project on launch" checkbox per project entry.
|
||||
3. If exactly one project has that flag set, startup skips the picker and opens that project directly.
|
||||
4. The flag is per-project, persisted in app settings (probably under `app.startupProject`).
|
||||
5. The checkbox can be unticked from the welcome view to restore picker-first behavior.
|
||||
|
||||
**Notes:** today the app auto-opens `app.lastProject` on boot (kernel/src/project.dart `openLast`). The new flow inverts the default — picker is the steady state, sticky-open is opt-in per project.
|
||||
|
||||
**Out of scope:** multi-project workspaces, project groups, recent-project ordering changes.
|
||||
|
||||
**Source:** user request 2026-05-17.', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 19:10:21', '2026-05-18 08:26:05', NULL, 'dc53a7ac8809206c2824d316750ec28a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-122', 'task', 'T-115', 'T-115 follow-up: tap-driven widget test for sticky-startup toggle', 'T-115''s render-only widget test at test/builtin/welcome/widget_test.dart ''sticky-startup toggle renders on a seeded recent row'' verifies the toggle is constructed but does not exercise the tap → setStickyStartup path. Adding a tap-driven assertion hung the 10-minute Flutter test timeout consistently (find.byTooltip, find.bySemanticsLabel, and find.byKey + tap variants all reproduced).
|
||||
|
||||
Hypothesis: ClideTooltip wraps the toggle in a MouseRegion whose onEnter awaits Future.delayed(showDelay). Some path during tap simulation (or surrounding pump scheduling) keeps that timer pending, and the test runner waits the full 10-minute idle timeout before declaring it stuck.
|
||||
|
||||
Next steps:
|
||||
- Reproduce in isolation against a stripped-down harness pumping just the _StickyToggle widget.
|
||||
- Determine whether the hang is the MouseRegion timer or another future (e.g., the welcome-tab activation, toolchain check).
|
||||
- Either fix the underlying cause or use fakeAsync.run() to drain the timers explicitly.
|
||||
|
||||
Until then, ProjectManager unit tests in test/kernel/src/project_test.dart cover the sticky-startup logic (14 cases including round-trip, no-op, idempotent flip, ambiguity, and openStickyOrNothing).', 'backlog', 'low', NULL, NULL, NULL, '2026-05-18 09:06:59', '2026-05-18 09:06:59', NULL, '16b6c7735880fe331798c0164b84da00', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'story', NULL, 'startup project picker; "always open this project" override', 'Startup flow change: on launch, always show the project picker screen unless the user has explicitly checked an "always open this project" box for a specific project (sticky default).
|
||||
|
||||
**Acceptance:**
|
||||
1. Default startup: project picker is the first screen, regardless of last-opened state.
|
||||
2. Picker has an "always open this project on launch" checkbox per project entry.
|
||||
3. If exactly one project has that flag set, startup skips the picker and opens that project directly.
|
||||
4. The flag is per-project, persisted in app settings (probably under `app.startupProject`).
|
||||
5. The checkbox can be unticked from the welcome view to restore picker-first behavior.
|
||||
|
||||
**Notes:** today the app auto-opens `app.lastProject` on boot (kernel/src/project.dart `openLast`). The new flow inverts the default — picker is the steady state, sticky-open is opt-in per project.
|
||||
|
||||
**Out of scope:** multi-project workspaces, project groups, recent-project ordering changes.
|
||||
|
||||
**Source:** user request 2026-05-17.', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 19:10:21', '2026-05-18 09:30:59', NULL, 'c88ab1b158beddb3cd74ad9a287bd224', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-107', 'task', 'T-97', 'decide lib/src/terminal/ status: formally vendor or clean to project bar', '~7k LOC forked from xterm.dart in an undeclared middle state. Carries commented-out `print()` debugging (`custom_text_edit.dart:244-275`), dangling TODOs (`parser.dart:110-113`, `keytab.dart:91`), a 1137-line `parser.dart`, and the only `// ignore: invalid_use_of_protected_member` in the repo (`terminal_view.dart:363`). Memory says "code under `lib/` is owned, not vendored."
|
||||
|
||||
**Decide one path and execute:**
|
||||
- **(a)** Formally vendor: relocate to `native/` or document as frozen in a D-record + `licenses.yaml`. Coverage carve-out documented.
|
||||
- **(b)** Clean to the project bar: remove debug prints, resolve TODOs, justify or remove the protected-member suppression, consider splitting `parser.dart`.
|
||||
|
||||
Source: consultants.md "Code quality — Findings — [Major]".', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:55', '2026-05-18 10:27:25', NULL, 'c6716898300053ff1a611a5e186c03fa', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-123', 'task', 'T-91', 'split lib/src/terminal/.../escape/parser.dart (1139 LOC)', 'parser.dart is a single 1139-line file containing the full ESC/CSI/OSC/DCS handler tree for the terminal emulator. Functional but unwieldy; the consultant flagged it as ''consider splitting'' in the T-107 review.
|
||||
|
||||
Suggested split (sequenced with the T-91 coverage sweep on lib/src/terminal/, so the split doesn''t fight in-flight test work):
|
||||
|
||||
- parser.dart — entry point + state machine driver
|
||||
- esc_handlers.dart — single-char ESC dispatch table + handlers
|
||||
- csi_handlers.dart — CSI parameter parsing + handlers
|
||||
- osc_handlers.dart — OSC string handlers (title, colour set, etc.)
|
||||
- dcs_handlers.dart — DCS/SOS/PM/APC string handlers
|
||||
|
||||
Each handler module exports a registrar that the driver wires at construction.
|
||||
|
||||
Done when:
|
||||
- parser.dart < 400 LOC
|
||||
- All existing parser tests pass without changes
|
||||
- No new public surface; everything stays library-private
|
||||
|
||||
Source: T-107 / consultants.md "Code quality — Findings — [Major]".', 'backlog', 'low', NULL, NULL, NULL, '2026-05-18 10:29:02', '2026-05-18 10:29:02', NULL, '1ac51fb8f92a9e72903c16e31f076955', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-107', 'task', 'T-97', 'decide lib/src/terminal/ status: formally vendor or clean to project bar', '~7k LOC forked from xterm.dart in an undeclared middle state. Carries commented-out `print()` debugging (`custom_text_edit.dart:244-275`), dangling TODOs (`parser.dart:110-113`, `keytab.dart:91`), a 1137-line `parser.dart`, and the only `// ignore: invalid_use_of_protected_member` in the repo (`terminal_view.dart:363`). Memory says "code under `lib/` is owned, not vendored."
|
||||
|
||||
**Decide one path and execute:**
|
||||
- **(a)** Formally vendor: relocate to `native/` or document as frozen in a D-record + `licenses.yaml`. Coverage carve-out documented.
|
||||
- **(b)** Clean to the project bar: remove debug prints, resolve TODOs, justify or remove the protected-member suppression, consider splitting `parser.dart`.
|
||||
|
||||
Source: consultants.md "Code quality — Findings — [Major]".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:55', '2026-05-18 10:30:30', NULL, '930012b3c62d18d9fe242c7d5879a347', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-116', 'bug', 'T-97', 'integration_test/theme_picker_test.dart hangs pumpAndSettle on theme.pick', 'When running `flutter test integration_test/theme_picker_test.dart` against the real boot path, calling `services.commands.execute(''theme.pick'')` followed by `await tester.pumpAndSettle()` does not settle — the test hangs for 60+ seconds and never reaches the `Select theme` / `Cancel` assertions. Likely an animation loop or a never-completing Future in the modal mount path.
|
||||
|
||||
Blocks T-103 from adding integration tests to `make push-check` (the other two integration tests pass after viewport + assertion fixes).
|
||||
|
||||
**Acceptance:**
|
||||
1. `flutter test integration_test/theme_picker_test.dart` completes in <10s.
|
||||
2. Both assertions (`Select theme` visible after `theme.pick`; gone after `Cancel`) pass.
|
||||
3. T-103''s `push-check-full` can include integration tests without the theme-picker hanging.
|
||||
|
||||
Source: surfaced 2026-05-17 during T-103 work.', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 19:14:31', '2026-05-18 11:22:46', NULL, 'ff3b728310bd265a57a60778417d026f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-74', 'task', NULL, 'forward real mouse events to TUI apps (not just PgUp/PgDown)', 'Mouse wheel scroll is currently translated to PgUp/PgDown key input in `lib/src/terminal/src/terminal_view.dart` as a pragmatic workaround — TUIs that capture mouse events without binding the wheel get a no-op otherwise. The downside: any TUI that does its own wheel handling (selection, click-to-position) loses the actual mouse coordinates because all we send is keyboard.
|
||||
|
||||
**Acceptance:**
|
||||
- The terminal forwards `PointerScrollEvent` and click/drag/release as proper xterm mouse-protocol escape sequences (CSI `M` / SGR variant per the report mode the inner program declared via `?1000h` / `?1006h`).
|
||||
- Tested with `vim` (mouse mode `a`), `htop`, `less` — all should respond to mouse selection / click correctly.
|
||||
- Falls back to PgUp/PgDown only when the inner program has not declared a mouse mode.
|
||||
- The `mouseInput` API on `Terminal` is wired through `Listener.onPointerSignal` instead of being bypassed.
|
||||
|
||||
**Why this isn`t the default already:** the bypass was the cheapest fix to get scroll working at all when the multitab/Scrollable ate scroll events. With those fixes landed, the right-shaped mouse forwarding is the proper next step.', 'done', 'medium', NULL, NULL, NULL, '2026-05-05 12:53:22', '2026-05-18 11:51:40', NULL, '20b66d93b9315ad51170a6f8762d4abe', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-125', 'task', 'T-99', 'argv→IpcRequest translator on the Dart side', 'Second slice of T-99(a). Owns the CLI grammar so the C client stays a dumb pipe.
|
||||
|
||||
Add lib/src/cli/argv_to_request.dart: takes a List<String> (the argv after `clide`) and returns an IpcRequest. Handles `clide <subsystem> <verb> [positionals...] [--flag value] [-- argv...]` per D-6''s shape. Errors (unknown subsystem/verb, malformed flag) surface as IpcResponse.err with the right exit code per pql''s contract.
|
||||
|
||||
Acceptance:
|
||||
1. Pure-Dart function with no I/O; unit-testable.
|
||||
2. Covers every active subsystem from D-6''s list (pane/tab/open/editor/panel/tree/git/pql/canvas/graph/theme/settings/project) at least to the level of "recognise the verb".
|
||||
3. Round-trip tests against the existing IpcRequest envelope.
|
||||
4. Sysexit-code parity with pql (0/1/2/3/4 + 64-78 reserved).
|
||||
|
||||
Source: T-99 sketch.', 'backlog', 'high', NULL, NULL, NULL, '2026-05-18 11:58:52', '2026-05-18 11:58:52', NULL, '807086680d24e2394bf13a4f25fb6998', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'task', 'T-99', 'thin C `clide` client binary in native/clide-cli/', 'Third slice of T-99(a). The user-facing entry point.
|
||||
|
||||
~150 LOC of C in native/clide-cli/. Resolves the socket path (env override + default), connects, sends a single JSON-line request shaped as {"argv": [...]} (parsing happens in Dart per T-125), reads the JSON-line response, writes stdout/stderr per the pql contract, exits with the response''s exit code.
|
||||
|
||||
Acceptance:
|
||||
1. native/clide-cli/clide compiles on Linux + macOS via the existing native build harness (same shape as dugite / libtree-sitter.so).
|
||||
2. Binary lands in the install bundle; runs from the user''s PATH after `make build-linux` / `make build-macos`.
|
||||
3. `clide status` against a running app returns the status JSON and exits 0.
|
||||
4. `clide nonsense` returns the right exit code (sysexit 64 = usage error per D-6).
|
||||
5. Documented in assets/licenses.yaml + a one-paragraph note in CONTRIBUTING.md.
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 (server) + T-125 (argv translator).', 'backlog', 'high', NULL, NULL, NULL, '2026-05-18 11:58:57', '2026-05-18 11:58:57', NULL, 'f7f5ca46af98cb7d0aecd8e3472081e9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-127', 'task', 'T-99', 'replace InProcessClient call sites with the socket loopback', 'Fourth slice of T-99(a). Convert the in-process callers — KernelServices.boot''s daemonClientFactory and friends — to talk to the local socket instead of the direct in-process dispatch path. Delete InProcessClient afterward.
|
||||
|
||||
Acceptance:
|
||||
1. lib/kernel/src/ipc/in_process.dart removed; nothing imports it.
|
||||
2. main.dart''s daemonClientFactory builds a socket-connected DaemonClient pointed at the local server.
|
||||
3. Test fixtures use a synthetic in-memory socket pair (or short-lived AF_UNIX socket in /tmp) for isolation.
|
||||
4. Wall-clock perf delta is within reason (no widget rebuild storm; round-trip ~ms) — measure before/after under a representative test.
|
||||
5. Falls back to in-process direct dispatch if the perf delta is unacceptable; surface the finding in a Q-record before doing so.
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 (server must exist). Blocks T-128 (legacy IPC cleanup).', 'backlog', 'high', NULL, NULL, NULL, '2026-05-18 11:59:02', '2026-05-18 11:59:02', NULL, '69d3791bf8e0f5320243538eaa062fbc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-128', 'task', 'T-99', 'delete IsolateClient + Backend + backend_entry.dart; collapse service registration', 'Fifth slice of T-99(a). Pure cleanup once T-127 lands.
|
||||
|
||||
Remove the third unused IPC path entirely. Today three implementations coexist: DaemonClient (socket — gets a real server in T-124), InProcessClient (deleted in T-127), and IsolateClient + Backend + backend_entry.dart (a backend isolate path that was never wired through). After T-127, none of those should still be referenced.
|
||||
|
||||
Also: consolidate the two duplicate service-wiring sites into one (currently main.dart''s buildDispatcher and the kernel facade both register subsystem handlers; one should own it).
|
||||
|
||||
Acceptance:
|
||||
1. lib/kernel/src/ipc/isolate_client.dart, lib/src/daemon/backend.dart, lib/src/daemon/backend_entry.dart removed.
|
||||
2. Single service-registration site; no duplicate registerPaneCommands / registerFilesCommands / etc. across files.
|
||||
3. flutter analyze + full test suite green.
|
||||
|
||||
Source: T-99 sketch. Depends on T-127.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 11:59:06', '2026-05-18 11:59:06', NULL, 'a5ab2120d437f63d7d892e7fe6ae1689', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-129', 'task', 'T-99', 'event streaming over the socket — `clide tail --events`', 'Sixth slice of T-99(a). Long-lived subscription channel — the second half of D-6 (`clide tail --events [--filter <subsystem>[:<id>]]`).
|
||||
|
||||
Client opens a connection, sends {"subscribe": "<subsystem>|*"}, server pushes JSON-line events until the client closes. Per D-6: replay buffer per subsystem (default depth 16) so a late subscriber still sees recent effects.
|
||||
|
||||
Acceptance:
|
||||
1. `clide tail --events --filter git` streams git.* events from the running app.
|
||||
2. Replay buffer per subsystem; new subscribers receive the last 16 events.
|
||||
3. Server doesn''t block writes on a slow client (back-pressure handling per Q-2 — drop with a warning or apply flow control; resolve in this ticket).
|
||||
4. End-to-end smoke: launch app, run `clide tail --events --filter pane` in another shell, perform a pane action in the UI, observe the event.
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 + T-126.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 11:59:11', '2026-05-18 11:59:11', NULL, '732603dbb5bd24ef0d62811223d13d96', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-130', 'task', 'T-99', 'MCP server alongside the CLI, wrapping the same dispatcher (D-68)', 'Seventh slice of T-99(a). Adds an /ide-compatible MCP endpoint per D-68 so Claude Code instances outside clide can connect via /ide.
|
||||
|
||||
Minimum tools (per D-68): mcp__ide__getDiagnostics, mcp__ide__executeCode. Optional mcp__clide__* namespace deferred to Q-32. Transport choice (SSE vs WebSocket vs stdio) — resolve Q-33 at the start of this ticket.
|
||||
|
||||
Both surfaces (CLI socket + MCP) wrap the SAME DaemonDispatcher — there is no second source of truth.
|
||||
|
||||
Acceptance:
|
||||
1. lib/src/ipc/mcp_server.dart serves the chosen transport.
|
||||
2. Claude Code''s /ide command discovers and connects to clide.
|
||||
3. Both minimum tools work end-to-end against a real Claude Code session.
|
||||
4. Q-32 + Q-33 closed (either as decisions or with resolutions written into the ticket).
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 (server foundation must exist). Can land in parallel with T-127 / T-128 / T-129.', 'backlog', 'medium', NULL, NULL, 'D-68', '2026-05-18 11:59:17', '2026-05-18 11:59:17', NULL, 'bb89c5fdc348dcc9e029296569416876', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-131', 'task', 'T-99', 'T-99 wrap-up: governance + docs + unblock T-119 / T-120', 'Eighth and final slice of T-99(a). Bookkeeping once the substantive work lands.
|
||||
|
||||
Acceptance:
|
||||
1. D-56 gets an amendment recording that the unix socket server + C client + MCP server all landed (with the dated amendment line format the rest of the testing.md / architecture.md decisions use).
|
||||
2. D-68 referenced as implemented in its own record.
|
||||
3. CONTRIBUTING.md gets a ''Running clide from the shell'' section (`clide status`, `clide tail --events`, etc.).
|
||||
4. T-119 (`clide panel resize` CLI verb) moved out of backlog into ready — its IPC dispatch path now exists.
|
||||
5. T-120 (typed IPC schema framework) re-scoped if needed; the wire contract is now real.
|
||||
6. T-99 itself transitions to done.
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 + T-125 + T-126 + T-127 + T-128 (+T-129, +T-130 if landed).', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 11:59:23', '2026-05-18 11:59:23', NULL, '4ea25c9c4d894b0fca02d1929fee7945', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'task', 'T-99', 'unix-domain IPC server, wired into Flutter app boot', 'First slice of T-99(a). Foundation for the rest.
|
||||
|
||||
Open a per-workspace unix-domain socket on Flutter app boot. Accept JSON-lines per the existing IpcRequest envelope. Route each request through the existing DaemonDispatcher (already wired in main.dart via daemonClientFactory). Tear down on app shutdown.
|
||||
|
||||
**Architectural commitments (read these first):**
|
||||
- D-70 — socket path is `$XDG_RUNTIME_DIR/clide/<sha256(workspace-root)[:16]>.sock` on Linux, `$HOME/Library/Caches/clide/<sha256(workspace-root)[:16]>.sock` on macOS. No env override. Workspace root = git toplevel.
|
||||
- D-71 — socket file is `0600`, parent dir is `0700`. No token auth at this layer.
|
||||
- D-72 — multi-connection accept loop, serial dispatch on the main isolate. Per-handler isolate offload as needed; not the IPC layer''s concern.
|
||||
|
||||
**Acceptance:**
|
||||
1. lib/src/ipc/server.dart exists; binds an AF_UNIX socket at the D-70 path; creates the parent dir with the D-71 perms.
|
||||
2. App boot starts the server; app shutdown closes the socket file cleanly and removes it.
|
||||
3. `socat - UNIX-CONNECT:$SOCK <<< ''{"command":"git.status"}''` returns a JSON-line response. Tests use a synthetic socket fixture (tempdir + XDG_RUNTIME_DIR override at the env level).
|
||||
4. Multi-connection accept loop — concurrent socat invocations interleave at the I/O level but serialise through DaemonDispatcher (per D-72) without failing each other.
|
||||
5. Stale socket on boot (left over from a crashed clide) is detected and unlinked before binding — same `live-daemon probe` pattern already used elsewhere in the codebase.
|
||||
6. No client yet — that lands in T-126.
|
||||
|
||||
Source: T-99 sketch. Coordinates with: T-127 (InProcessClient swap), T-130 (MCP).', 'backlog', 'high', NULL, NULL, NULL, '2026-05-18 11:58:47', '2026-05-18 12:42:56', NULL, 'e90d4b8786af0a9baa2b39c19a31540a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'task', 'T-99', 'unix-domain IPC server, wired into Flutter app boot', 'First slice of T-99(a). Foundation for the rest.
|
||||
|
||||
Open a per-workspace unix-domain socket on Flutter app boot. Accept JSON-lines per the existing IpcRequest envelope. Route each request through the existing DaemonDispatcher (already wired in main.dart via daemonClientFactory). Tear down on app shutdown.
|
||||
|
||||
**Architectural commitments (read these first):**
|
||||
- D-70 — socket path is `$XDG_RUNTIME_DIR/clide/<sha256(workspace-root)[:16]>.sock` on Linux, `$HOME/Library/Caches/clide/<sha256(workspace-root)[:16]>.sock` on macOS. No env override. Workspace root = git toplevel.
|
||||
- D-71 — socket file is `0600`, parent dir is `0700`. No token auth at this layer.
|
||||
- D-72 — multi-connection accept loop, serial dispatch on the main isolate. Per-handler isolate offload as needed; not the IPC layer''s concern.
|
||||
|
||||
**Acceptance:**
|
||||
1. lib/src/ipc/server.dart exists; binds an AF_UNIX socket at the D-70 path; creates the parent dir with the D-71 perms.
|
||||
2. App boot starts the server; app shutdown closes the socket file cleanly and removes it.
|
||||
3. `socat - UNIX-CONNECT:$SOCK <<< ''{"command":"git.status"}''` returns a JSON-line response. Tests use a synthetic socket fixture (tempdir + XDG_RUNTIME_DIR override at the env level).
|
||||
4. Multi-connection accept loop — concurrent socat invocations interleave at the I/O level but serialise through DaemonDispatcher (per D-72) without failing each other.
|
||||
5. Stale socket on boot (left over from a crashed clide) is detected and unlinked before binding — same `live-daemon probe` pattern already used elsewhere in the codebase.
|
||||
6. No client yet — that lands in T-126.
|
||||
|
||||
Source: T-99 sketch. Coordinates with: T-127 (InProcessClient swap), T-130 (MCP).', 'done', 'high', NULL, NULL, NULL, '2026-05-18 11:58:47', '2026-05-18 12:51:08', NULL, 'eaa0d45789ecd577e81dc07ef476e31c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-125', 'task', 'T-99', 'argv→IpcRequest translator on the Dart side', 'Second slice of T-99(a). Owns the CLI grammar so the C client stays a dumb pipe.
|
||||
|
||||
Add lib/src/cli/argv_to_request.dart: takes a List<String> (the argv after `clide`) and returns an IpcRequest. Handles `clide <subsystem> <verb> [positionals...] [--flag value] [-- argv...]` per D-6''s shape. Errors (unknown subsystem/verb, malformed flag) surface as IpcResponse.err with the right exit code per pql''s contract.
|
||||
|
||||
Acceptance:
|
||||
1. Pure-Dart function with no I/O; unit-testable.
|
||||
2. Covers every active subsystem from D-6''s list (pane/tab/open/editor/panel/tree/git/pql/canvas/graph/theme/settings/project) at least to the level of "recognise the verb".
|
||||
3. Round-trip tests against the existing IpcRequest envelope.
|
||||
4. Sysexit-code parity with pql (0/1/2/3/4 + 64-78 reserved).
|
||||
|
||||
Source: T-99 sketch.', 'done', 'high', NULL, NULL, NULL, '2026-05-18 11:58:52', '2026-05-18 15:51:09', NULL, '99f60de735779ddc37e7701ef4dd3304', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'task', 'T-99', 'thin C `clide` client binary in native/clide-cli/', 'Third slice of T-99(a). The user-facing entry point.
|
||||
|
||||
~150 LOC of C in native/clide-cli/. Resolves the socket path (env override + default), connects, sends a single JSON-line request shaped as {"argv": [...]} (parsing happens in Dart per T-125), reads the JSON-line response, writes stdout/stderr per the pql contract, exits with the response''s exit code.
|
||||
|
||||
Acceptance:
|
||||
1. native/clide-cli/clide compiles on Linux + macOS via the existing native build harness (same shape as dugite / libtree-sitter.so).
|
||||
2. Binary lands in the install bundle; runs from the user''s PATH after `make build-linux` / `make build-macos`.
|
||||
3. `clide status` against a running app returns the status JSON and exits 0.
|
||||
4. `clide nonsense` returns the right exit code (sysexit 64 = usage error per D-6).
|
||||
5. Documented in assets/licenses.yaml + a one-paragraph note in CONTRIBUTING.md.
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 (server) + T-125 (argv translator).', 'done', 'high', NULL, NULL, NULL, '2026-05-18 11:58:57', '2026-05-18 16:04:29', NULL, 'e4be31e3d80dbaadd64e3a4a2fa029e0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-127', 'task', 'T-99', 'replace InProcessClient call sites with the socket loopback', 'Fourth slice of T-99(a). Convert the in-process callers — KernelServices.boot''s daemonClientFactory and friends — to talk to the local socket instead of the direct in-process dispatch path. Delete InProcessClient afterward.
|
||||
|
||||
Acceptance:
|
||||
1. lib/kernel/src/ipc/in_process.dart removed; nothing imports it.
|
||||
2. main.dart''s daemonClientFactory builds a socket-connected DaemonClient pointed at the local server.
|
||||
3. Test fixtures use a synthetic in-memory socket pair (or short-lived AF_UNIX socket in /tmp) for isolation.
|
||||
4. Wall-clock perf delta is within reason (no widget rebuild storm; round-trip ~ms) — measure before/after under a representative test.
|
||||
5. Falls back to in-process direct dispatch if the perf delta is unacceptable; surface the finding in a Q-record before doing so.
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 (server must exist). Blocks T-128 (legacy IPC cleanup).', 'done', 'high', NULL, NULL, NULL, '2026-05-18 11:59:02', '2026-05-19 10:03:26', NULL, 'af4a706880181411b2aecf51d90ea748', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-128', 'task', 'T-99', 'delete IsolateClient + Backend + backend_entry.dart; collapse service registration', 'Fifth slice of T-99(a). Pure cleanup once T-127 lands.
|
||||
|
||||
Remove the third unused IPC path entirely. Today three implementations coexist: DaemonClient (socket — gets a real server in T-124), InProcessClient (deleted in T-127), and IsolateClient + Backend + backend_entry.dart (a backend isolate path that was never wired through). After T-127, none of those should still be referenced.
|
||||
|
||||
Also: consolidate the two duplicate service-wiring sites into one (currently main.dart''s buildDispatcher and the kernel facade both register subsystem handlers; one should own it).
|
||||
|
||||
Acceptance:
|
||||
1. lib/kernel/src/ipc/isolate_client.dart, lib/src/daemon/backend.dart, lib/src/daemon/backend_entry.dart removed.
|
||||
2. Single service-registration site; no duplicate registerPaneCommands / registerFilesCommands / etc. across files.
|
||||
3. flutter analyze + full test suite green.
|
||||
|
||||
Source: T-99 sketch. Depends on T-127.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 11:59:06', '2026-05-19 10:06:46', NULL, '481c51b17b027b5a04279a0ef6a3a029', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-129', 'task', 'T-99', 'event streaming over the socket — `clide tail --events`', 'Sixth slice of T-99(a). Long-lived subscription channel — the second half of D-6 (`clide tail --events [--filter <subsystem>[:<id>]]`).
|
||||
|
||||
Client opens a connection, sends {"subscribe": "<subsystem>|*"}, server pushes JSON-line events until the client closes. Per D-6: replay buffer per subsystem (default depth 16) so a late subscriber still sees recent effects.
|
||||
|
||||
Acceptance:
|
||||
1. `clide tail --events --filter git` streams git.* events from the running app.
|
||||
2. Replay buffer per subsystem; new subscribers receive the last 16 events.
|
||||
3. Server doesn''t block writes on a slow client (back-pressure handling per Q-2 — drop with a warning or apply flow control; resolve in this ticket).
|
||||
4. End-to-end smoke: launch app, run `clide tail --events --filter pane` in another shell, perform a pane action in the UI, observe the event.
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 + T-126.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 11:59:11', '2026-05-19 12:19:51', NULL, '44c9141fea8635a9c03053afa92f3999', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-130', 'task', 'T-99', 'MCP server alongside the CLI, wrapping the same dispatcher (D-68)', 'Seventh slice of T-99(a). Adds an /ide-compatible MCP endpoint per D-68 so Claude Code instances outside clide can connect via /ide.
|
||||
|
||||
Minimum tools (per D-68): mcp__ide__getDiagnostics, mcp__ide__executeCode. Optional mcp__clide__* namespace deferred to Q-32. Transport choice (SSE vs WebSocket vs stdio) — resolve Q-33 at the start of this ticket.
|
||||
|
||||
Both surfaces (CLI socket + MCP) wrap the SAME DaemonDispatcher — there is no second source of truth.
|
||||
|
||||
Acceptance:
|
||||
1. lib/src/ipc/mcp_server.dart serves the chosen transport.
|
||||
2. Claude Code''s /ide command discovers and connects to clide.
|
||||
3. Both minimum tools work end-to-end against a real Claude Code session.
|
||||
4. Q-32 + Q-33 closed (either as decisions or with resolutions written into the ticket).
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 (server foundation must exist). Can land in parallel with T-127 / T-128 / T-129.', 'done', 'medium', NULL, NULL, 'D-68', '2026-05-18 11:59:17', '2026-05-19 12:33:52', NULL, '0d03fa2376bf179665ee817dc363e186', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-119', 'task', 'T-99', 'clide panel resize CLI verb (split from T-111)', 'Once T-99 lands an IPC dispatch path, register a ''panel.resize'' command that takes <slot> and <delta-or-absolute> args and calls LayoutArrangement.setSize / setEditorRatio. Keyboard parity already landed in T-111; this completes user/Claude parity per D-6.', 'ready', 'low', NULL, NULL, NULL, '2026-05-18 07:43:50', '2026-05-19 13:14:01', NULL, '08bd7d35f6ac787f1895d92a23a65cec', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-120', 'task', 'T-97', 'typed IPC command schema framework (split from T-104)', 'T-104 covered the spot-fixes (argv-injection rejection, size/count caps on specific commands). What remains is the framework piece: a typed schema per IPC command — branch/remote/path/etc. with regex/charset constraints — applied at DaemonDispatcher dispatch time rather than scattered through individual handlers.
|
||||
|
||||
Re-scope (2026-05-19, post T-99): the wire contract is now real — IpcRequest envelopes flow over the unix socket (per D-70/71/72) and through the C clide client which serialises raw argv under the `_argv` sentinel (per D-72/D-6). The argv parser (lib/src/cli/argv_to_request.dart) is the de-facto schema today; T-120 should formalise it by:
|
||||
|
||||
- Lifting the per-cmd grammar from argv_to_request.dart into a typed registry keyed by cmd, sharing it with both the argv parser and the dispatcher.
|
||||
- Using the same registry to validate inbound IpcRequests at dispatch time (regex/charset constraints on branch/remote/path args).
|
||||
- Exposing the registry to the MCP server (T-130) so the `tools/list` payload is generated rather than hand-rolled.
|
||||
|
||||
Design still open:
|
||||
- Where the schema lives (per-handler? central registry?).
|
||||
- Whether it generates handler boilerplate or wraps existing handlers.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]" item 1.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 07:53:08', '2026-05-19 13:14:12', NULL, 'cac26b3ebcf14b056fdd178d4c60c721', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-131', 'task', 'T-99', 'T-99 wrap-up: governance + docs + unblock T-119 / T-120', 'Eighth and final slice of T-99(a). Bookkeeping once the substantive work lands.
|
||||
|
||||
Acceptance:
|
||||
1. D-56 gets an amendment recording that the unix socket server + C client + MCP server all landed (with the dated amendment line format the rest of the testing.md / architecture.md decisions use).
|
||||
2. D-68 referenced as implemented in its own record.
|
||||
3. CONTRIBUTING.md gets a ''Running clide from the shell'' section (`clide status`, `clide tail --events`, etc.).
|
||||
4. T-119 (`clide panel resize` CLI verb) moved out of backlog into ready — its IPC dispatch path now exists.
|
||||
5. T-120 (typed IPC schema framework) re-scoped if needed; the wire contract is now real.
|
||||
6. T-99 itself transitions to done.
|
||||
|
||||
Source: T-99 sketch. Depends on T-124 + T-125 + T-126 + T-127 + T-128 (+T-129, +T-130 if landed).', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 11:59:23', '2026-05-19 13:14:41', NULL, '2acd8d20756c1615238472fedfe0d6df', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-99', 'task', 'T-97', 'implement (or amend) the IPC socket server per D-1/D-6/D-56/D-68', 'D-56 specifies the Flutter app hosts an in-process IPC server reachable by a thin C client over a Unix socket; D-1/D-6 commit to CLI-first. Today no `ServerSocket` for unix-domain exists anywhere in `lib/`. Three IPC clients coexist (`DaemonClient` socket, `InProcessClient`, `IsolateClient` + `Backend`/`backend_entry.dart`); two service-wiring sites duplicate registration. The load-bearing CLI-first guardrail has no runtime path. D-68 (filed in this session) commits to the dual-surface plan.
|
||||
|
||||
**Pick one path:**
|
||||
- **(a)** Implement the socket server per D-56 + the C `clide` client. Delete `InProcessClient` and `IsolateClient` + `backend_entry.dart`. Wire MCP server alongside per D-68.
|
||||
- **(b)** Amend D-56 to declare in-process direct dispatch as the design; delete `DaemonClient`''s socket code, `IsolateClient`, `Backend`, and `backend_entry.dart`. Re-evaluate D-1''s CLI-first claim and D-68''s dual-surface plan.
|
||||
|
||||
**Acceptance:**
|
||||
1. Exactly one IPC model exists in code.
|
||||
2. `clide <subcommand>` shells from a terminal can drive a running clide app (or the contract is formally rescinded).
|
||||
3. Service registration happens in one place.
|
||||
4. D-records updated to match reality.
|
||||
|
||||
Source: consultants.md "Architecture — Findings — [Critical] No IPC socket server exists".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:18', '2026-05-19 13:14:41', NULL, '2ad1fbf0b1b6f5b9e46d4a31d1ad830c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-119', 'task', 'T-99', 'clide panel resize CLI verb (split from T-111)', 'Once T-99 lands an IPC dispatch path, register a ''panel.resize'' command that takes <slot> and <delta-or-absolute> args and calls LayoutArrangement.setSize / setEditorRatio. Keyboard parity already landed in T-111; this completes user/Claude parity per D-6.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-18 07:43:50', '2026-05-19 14:44:43', NULL, 'ac4c7a1d76836e546dac66e77ed4cb67', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -18,12 +18,69 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- `clide panel resize <slot>` CLI verb (T-119) — set an absolute size
|
||||
with `--to` or nudge with `--by`; `editor` targets the split ratio.
|
||||
Completes user/Claude parity (D-6) with T-111's keyboard resize.
|
||||
- Unix-domain IPC socket server in the Flutter app (T-99 / T-124).
|
||||
Per-workspace path (D-70: `$XDG_RUNTIME_DIR/clide/<hash>.sock` on
|
||||
Linux, `~/Library/Caches/clide/<hash>.sock` on macOS). 0600 socket
|
||||
+ 0700 parent (D-71). Multi-connection accept loop with serial
|
||||
dispatch through `DaemonDispatcher` (D-72). Foundation for the C
|
||||
`clide` client (T-126) and MCP (T-130). No client yet — testable
|
||||
via `socat - UNIX-CONNECT:$SOCK`.
|
||||
- argv→IpcRequest translator (`lib/src/cli/argv_to_request.dart`) —
|
||||
parses `clide SUBSYSTEM VERB [pos...] [--flag] [-- passthrough]` and
|
||||
the umbrella commands (`status`, `tail`, `version`, `ping`) per D-6
|
||||
into the wire envelope. Pure Dart; lets the C client (T-126) stay a
|
||||
dumb pipe (T-99 / T-125).
|
||||
- `DaemonClient.reconnectAt(newPath)` — swap an active client onto a
|
||||
different socket without restart (project switch in T-127).
|
||||
- Event streaming over the IPC socket (T-99 / T-129) — `clide tail
|
||||
--events [--filter X]` opens a long-lived subscription, replays up
|
||||
to 16 recent matching events per subsystem (D-6), and streams new
|
||||
ones as JSON lines. C client loops on `data.streaming` ack. Slow /
|
||||
broken subscribers drop themselves without blocking the bus.
|
||||
- MCP server over HTTP+SSE (T-99 / T-130, per D-68 / D-73). Localhost
|
||||
HTTP listener advertises via `$HOME/.claude/ide/<pid>.lock` so
|
||||
Claude Code's `/ide` discovers it. JSON-RPC 2.0 with the two
|
||||
minimum `/ide` tools shipped as stubs
|
||||
(`mcp__ide__getDiagnostics`, `mcp__ide__executeCode`); real
|
||||
implementations follow.
|
||||
- C `clide` shell client at `native/clide-cli/clide.c`. Walks CWD up
|
||||
to the git root, hashes to the per-workspace socket (D-70), ships
|
||||
argv. `make clide-cli` builds it; on PATH, `clide status` works
|
||||
from any clide-workspace directory once the app is up (T-99,
|
||||
T-126).
|
||||
- Startup project picker — clide now opens to the welcome screen by
|
||||
default instead of auto-opening the last project. A per-row
|
||||
"always open this project on launch" checkbox in welcome's RECENT
|
||||
list sets a sticky-startup flag; if exactly one project has it,
|
||||
that one opens directly. Two or more, or none ⇒ picker (T-115).
|
||||
- CONTRIBUTING.md "Running clide from the shell" section — documents
|
||||
the shell verbs, exit-code contract per D-68, and Claude Code
|
||||
`/ide` MCP discovery via `~/.claude/ide/<pid>.lock` (T-131).
|
||||
|
||||
### Changed
|
||||
|
||||
- In-process IPC dispatch swapped for socket loopback (T-127). The
|
||||
Flutter UI's `DaemonClient` now talks to its own `IpcServer` over
|
||||
the same per-workspace Unix socket the C `clide` client uses — one
|
||||
transport, one contract.
|
||||
- D-56 / D-68 amended with implementation notes — both decisions now
|
||||
link out to T-99's eight slices (T-124–T-131) and the D-70/71/72/73
|
||||
records they spawned (T-131).
|
||||
|
||||
### Deprecated
|
||||
|
||||
### Removed
|
||||
|
||||
- `lib/kernel/src/ipc/in_process.dart` (`InProcessClient`) — replaced
|
||||
by the socket-loopback `DaemonClient` (T-127).
|
||||
- `lib/kernel/src/backend.dart`, `lib/kernel/src/backend_entry.dart`,
|
||||
`lib/kernel/src/ipc/isolate_client.dart` — the third unused IPC
|
||||
path (a backend-isolate model that was never wired through). Only
|
||||
the socket model survives now (T-128).
|
||||
|
||||
### Fixed
|
||||
|
||||
### Security
|
||||
@@ -91,12 +148,33 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Changed
|
||||
|
||||
- Terminal mouse wheel forwards as proper xterm wheel-button escapes
|
||||
when the inner program declares a mouse mode (?1000h / ?1002h /
|
||||
?1003h, optionally +?1006h SGR). Falls back to PgUp/PgDown only
|
||||
when no mouse mode is active. vim mouse=a / htop / less mouse modes
|
||||
now react to the wheel (T-74).
|
||||
- `lib/src/terminal/` cleaned to the project bar — commented-out
|
||||
`print()` debugging stubs stripped from `custom_text_edit.dart`,
|
||||
stale TODOs in `parser.dart` + `keytab.dart` replaced with clear
|
||||
"not implemented" notes (G2/G3 charsets, VT52 records), and the
|
||||
one `// ignore: invalid_use_of_protected_member` in
|
||||
`terminal_view.dart` gets an inline reason explaining why
|
||||
TerminalView owns its own ShortcutManager. Parser split deferred
|
||||
to T-123 (T-107).
|
||||
- Window-control close-button red, white close glyph, and palette
|
||||
ambient shadow are now tokens (`windowControl.closeHover*`,
|
||||
`shadow.ambient`) instead of hard-coded hex. Light themes get a
|
||||
softer ink-tinted shadow (T-114).
|
||||
- Text-zoom (Ctrl +/-/0) is now a kernel `TextZoom` service and shows
|
||||
up in the palette as `View: Zoom In/Out/Reset Zoom` (T-114).
|
||||
- `make gen-build-info` bakes `lib/src/build_info.g.dart` (name,
|
||||
tagline, version, repository, commit, date) and rewrites
|
||||
`assets/licenses.yaml` `self.version:` from `pubspec.yaml` on every
|
||||
build/run/test target. Welcome banner / status line / window title
|
||||
/ project switcher labels all read from those constants — one
|
||||
source of truth, no manual sync, no `--dart-define` plumbing.
|
||||
New `tagline:` field in pubspec for the short user-facing line
|
||||
(welcome subtitle, future web meta).
|
||||
- Panel splitters (sidebar / context / editor-split) are tab-focusable;
|
||||
arrow keys nudge by 10 px, Shift+arrow by 50 px (2% / 10% for the
|
||||
editor split). Exposed as slider Semantics nodes so screen readers
|
||||
@@ -168,6 +246,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Welcome status line no longer overflows on narrow viewports —
|
||||
whole-row `FittedBox(scaleDown)` instead of fixed sibling widths.
|
||||
Inline `fontSize:` literals replaced with the typography
|
||||
constants. Version label reads `clideVersion` so the status line
|
||||
stays in sync with `pubspec.yaml` (T-116).
|
||||
- Integration test `theme_picker_test.dart` no longer deadlocks —
|
||||
was `await`ing `services.commands.execute('theme.pick')` whose
|
||||
Future doesn't complete until the dialog is dismissed. Now
|
||||
fire-and-forget around `pumpAndSettle` (T-116).
|
||||
- `TerminalView.onTapUp` now actually fires on primary tap — was
|
||||
wired to a dead code path (T-93). Dead `onTapUp` surface on
|
||||
`TerminalGestureHandler` / `TerminalGestureDetector` removed.
|
||||
|
||||
@@ -56,6 +56,83 @@ commit. Pre-push includes:
|
||||
[D-66](governance/decisions/testing.md#d-66))
|
||||
- `CHANGELOG.md` `[Unreleased]` bullets ≤ 60 words each
|
||||
|
||||
## The C `clide` shell client
|
||||
|
||||
`clide` (the binary) is a ~250 LOC C program in
|
||||
[`native/clide-cli/clide.c`](native/clide-cli/clide.c) that talks to
|
||||
the running Flutter app's IPC socket so Claude (and you) can drive
|
||||
clide from any shell. It walks CWD up to the workspace's `.git`,
|
||||
computes the same FNV-1a 64-bit hash the Dart side uses (per D-70),
|
||||
opens the per-workspace socket, and sends argv across the wire under
|
||||
a sentinel `_argv` cmd. The argv parser lives in Dart
|
||||
([`lib/src/cli/argv_to_request.dart`](lib/src/cli/argv_to_request.dart)),
|
||||
so the C side stays a dumb pipe.
|
||||
|
||||
Build it with `make clide-cli` — output lands at
|
||||
`native/<platform>/clide` (gitignored). Drop that on your PATH (or
|
||||
symlink) and `clide status` works from any directory inside a clide
|
||||
workspace once the app is running. Standard POSIX + libc only;
|
||||
pure C99; no third-party deps.
|
||||
|
||||
The cross-language hash agreement is load-bearing — if the Dart
|
||||
server and C client disagree on the socket path, every shell
|
||||
invocation fails to connect. The test suite covers it:
|
||||
[`test/ipc/paths_test.dart`](test/ipc/paths_test.dart) pins
|
||||
FNV-1a vectors against the reference, and
|
||||
[`test/cli/clide_cli_e2e_test.dart`](test/cli/clide_cli_e2e_test.dart)
|
||||
compiles the C client and exercises the full round-trip.
|
||||
|
||||
## Running clide from the shell
|
||||
|
||||
Once the desktop app is running and `clide` is on your `$PATH`, every
|
||||
shell inside the workspace can drive it. The argv grammar is fixed by
|
||||
[D-6](governance/decisions/architecture.md#d-6-cli-and-event-surface-contract)
|
||||
— every UI affordance has a CLI counterpart, and every CLI verb has a
|
||||
UI affordance.
|
||||
|
||||
```sh
|
||||
clide status # one-shot snapshot of pane state
|
||||
clide ping # round-trip health check
|
||||
clide tail --events # stream every subsystem event
|
||||
clide tail --events --filter git # stream a single subsystem
|
||||
clide pane focus editor # drive the running app
|
||||
clide panel toggle git # show/hide a panel
|
||||
clide panel resize sidebar --to 320 # absolute width (px)
|
||||
clide panel resize context --by -40 # relative nudge (px)
|
||||
clide panel resize editor --to 0.5 # editor split ratio (0.15–0.70)
|
||||
```
|
||||
|
||||
Output is JSON on stdout, one envelope per line. Streaming verbs
|
||||
(`tail --events`) keep the connection open and emit one event line per
|
||||
push; Ctrl-C cleanly closes the socket. Exit codes follow the pql
|
||||
convention ([D-68](governance/decisions/architecture.md#d-68-cli-and-mcp-surface-contracts)):
|
||||
|
||||
| Code | Meaning |
|
||||
|------|------------------------------------------------------|
|
||||
| 0 | success |
|
||||
| 64 | user error — bad argv, unknown verb, malformed flag |
|
||||
| 65 | data error — request well-formed but rejected |
|
||||
| 69 | service unavailable — no running clide for this repo |
|
||||
| 70 | internal error — dispatcher threw |
|
||||
|
||||
If `clide` exits 69, the desktop app isn't running for this workspace
|
||||
— start it with `make run` (during development) or launch the
|
||||
installed app pointed at this repo.
|
||||
|
||||
### Claude Code's `/ide` and the MCP server
|
||||
|
||||
The desktop app also runs an MCP companion server on a random
|
||||
localhost port (HTTP + Server-Sent Events, per
|
||||
[D-73](governance/decisions/architecture.md#d-73-mcp-server-transport-over-http-sse)).
|
||||
Discovery follows the Claude Code `/ide` contract: clide writes a
|
||||
JSON descriptor to `~/.claude/ide/<pid>.lock` containing the chosen
|
||||
port, the workspace root, and the protocol version. Claude Code's
|
||||
`/ide` command picks the lock for the workspace it's running in and
|
||||
connects automatically — no manual configuration. The lock is removed
|
||||
on graceful shutdown; stale locks from crashed processes are reaped
|
||||
on the next start. The implementation lives in
|
||||
[`lib/src/ipc/mcp_server.dart`](lib/src/ipc/mcp_server.dart).
|
||||
|
||||
## Decisions, questions, rejected (DQR)
|
||||
|
||||
clide tracks architectural commitments as durable records under
|
||||
|
||||
@@ -19,6 +19,10 @@ else
|
||||
endif
|
||||
|
||||
VERSION ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
||||
NAME ?= $(shell awk -F': *' '/^name:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
||||
TAGLINE ?= $(shell awk -F': *' '/^tagline:/ {sub(/^tagline: *"?/,"",$$0); sub(/"$$/,"",$$0); print; exit}' pubspec.yaml)
|
||||
REPOSITORY ?= $(shell awk -F': *' '/^repository:/ {sub(/^repository: */,"",$$0); print; exit}' pubspec.yaml)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
.PHONY: help
|
||||
@@ -28,7 +32,7 @@ help: ## Show this help.
|
||||
# -- app (Flutter) -------------------------------------------------------
|
||||
|
||||
.PHONY: run
|
||||
run: ## Launch the Flutter desktop app.
|
||||
run: gen-build-info ## Launch the Flutter desktop app.
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
else
|
||||
@@ -64,7 +68,7 @@ pubget: ## flutter pub get.
|
||||
flutter pub get
|
||||
|
||||
.PHONY: build-check
|
||||
build-check: ## Verify native + Dart build compiles (no run).
|
||||
build-check: gen-build-info ## Verify native + Dart build compiles (no run).
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter build linux
|
||||
else
|
||||
@@ -79,23 +83,46 @@ analyze: ## flutter analyze.
|
||||
format: ## dart format --set-exit-if-changed.
|
||||
dart format --set-exit-if-changed .
|
||||
|
||||
# The single bake of every build-time fact derived from pubspec.yaml
|
||||
# + git + clock. Runs implicitly as a prereq of every target that
|
||||
# compiles or executes Dart code so nobody has to remember it.
|
||||
#
|
||||
# Outputs:
|
||||
# - lib/src/build_info.g.dart — gitignored, fresh on every build.
|
||||
# Exposes `clideName`, `clideTagline`, `clideVersion`, `clideRepository`
|
||||
# (from pubspec), `clideCommit` (git short SHA), `clideDate` (UTC
|
||||
# now) for the app to read directly.
|
||||
# - assets/licenses.yaml `self.version:` — rewritten in place so the
|
||||
# bundled license manifest never drifts from pubspec. (Tracked in
|
||||
# git; the rewrite is a no-op when in sync.)
|
||||
.PHONY: gen-build-info
|
||||
gen-build-info:
|
||||
@printf '// GENERATED — do not edit. Regenerated by `make` on every\n// build/run/test (see gen-build-info in Makefile). Name / tagline /\n// version / repository come from pubspec.yaml — the single source\n// of truth. Commit + date stamp at run time.\nconst String clideName = '"'"'%s'"'"';\nconst String clideTagline = '"'"'%s'"'"';\nconst String clideVersion = '"'"'%s'"'"';\nconst String clideRepository = '"'"'%s'"'"';\nconst String clideCommit = '"'"'%s'"'"';\nconst String clideDate = '"'"'%s'"'"';\n' "$(NAME)" "$(TAGLINE)" "$(VERSION)" "$(REPOSITORY)" "$(COMMIT)" "$(DATE)" > lib/src/build_info.g.dart
|
||||
@awk -v v="$(VERSION)" '/^self:/ {in_self=1} in_self && /^[[:space:]]+version:/ {sub(/version:.*/, "version: \"" v "\""); in_self=0} {print}' assets/licenses.yaml > assets/licenses.yaml.tmp && mv assets/licenses.yaml.tmp assets/licenses.yaml
|
||||
|
||||
.PHONY: verify
|
||||
verify: analyze format decisions-validate changelog-gate ## No-tests sweep — analyze + format + decisions-validate + changelog-gate. For mid-edit "are the gates green?" checks; `push-check` is the full pre-push pipeline.
|
||||
verify: gen-build-info analyze format decisions-validate changelog-gate ## No-tests sweep — gen-build-info + analyze + format + decisions-validate + changelog-gate. For mid-edit "are the gates green?" checks; `push-check` is the full pre-push pipeline.
|
||||
|
||||
.PHONY: t
|
||||
t: gen-build-info ## Run one test path with tee'd output. Usage: make t T=test/path/to/spec.dart
|
||||
@mkdir -p test/.test-output
|
||||
@if [ -z "$(T)" ]; then echo "usage: make t T=test/path/to/spec.dart" >&2; exit 2; fi
|
||||
flutter test $(T) 2>&1 | tee test/.test-output/last.log
|
||||
|
||||
.PHONY: test
|
||||
test: ## Fast: analyze + format + unit + widget + golden (<60s).
|
||||
test: gen-build-info ## Fast: analyze + format + unit + widget + golden (<60s).
|
||||
ci/test.sh
|
||||
|
||||
.PHONY: test-core
|
||||
test-core: ## Core subsystem tests (IPC, PTY, git, pane registry).
|
||||
test-core: gen-build-info ## Core subsystem tests (IPC, PTY, git, pane registry).
|
||||
ci/test_core.sh
|
||||
|
||||
.PHONY: test-a11y
|
||||
test-a11y: ## A11y contract (semantic coverage + keyboard + contrast + i18n).
|
||||
test-a11y: gen-build-info ## A11y contract (semantic coverage + keyboard + contrast + i18n).
|
||||
ci/test_a11y.sh
|
||||
|
||||
.PHONY: test-integration
|
||||
test-integration: ## Integration tests (real app boot; xvfb on headless Linux).
|
||||
test-integration: gen-build-info ## Integration tests (real app boot; xvfb on headless Linux).
|
||||
ci/test_integration.sh
|
||||
|
||||
.PHONY: test-e2e
|
||||
@@ -135,15 +162,15 @@ ui-smoke: ## Build + serve + run Playwright smoke + stop.
|
||||
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
|
||||
|
||||
.PHONY: build
|
||||
build: ## flutter build for the current OS.
|
||||
build: gen-build-info ## flutter build for the current OS.
|
||||
flutter build $(FLUTTER_OS)
|
||||
|
||||
.PHONY: build-linux
|
||||
build-linux: ## flutter build linux (desktop bundle).
|
||||
build-linux: gen-build-info ## flutter build linux (desktop bundle).
|
||||
flutter build linux
|
||||
|
||||
.PHONY: build-macos
|
||||
build-macos: ## flutter build macos (desktop bundle).
|
||||
build-macos: gen-build-info ## flutter build macos (desktop bundle).
|
||||
flutter build macos
|
||||
|
||||
# -- install / uninstall -----------------------------------------------------
|
||||
@@ -241,6 +268,27 @@ dugite-fetch: ## Download and extract the dugite-native git distribution.
|
||||
dugite-clean: ## Remove the dugite-native directory.
|
||||
rm -rf $(DUGITE_DIR)
|
||||
|
||||
# -- clide-cli ----------------------------------------------------------
|
||||
# The C `clide` shell client that talks to the in-process IPC server
|
||||
# (T-99 / T-126). One source file, no third-party deps; the build
|
||||
# target picks up whatever `cc` is on PATH.
|
||||
|
||||
CLIDE_CLI_SRC := native/clide-cli/clide.c
|
||||
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
|
||||
CC ?= cc
|
||||
|
||||
.PHONY: clide-cli
|
||||
clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
|
||||
|
||||
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
|
||||
@mkdir -p $(dir $(CLIDE_CLI_BIN))
|
||||
$(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC)
|
||||
@echo "==> built $(CLIDE_CLI_BIN)"
|
||||
|
||||
.PHONY: clide-cli-clean
|
||||
clide-cli-clean: ## Remove the compiled C `clide` client.
|
||||
rm -f $(CLIDE_CLI_BIN)
|
||||
|
||||
# -- security -------------------------------------------------------------
|
||||
|
||||
.PHONY: security
|
||||
@@ -257,7 +305,7 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
|
||||
push-check: decisions-validate test-core test test-a11y coverage-gate changelog-gate ## Pre-push gate (fast — <2 min target).
|
||||
|
||||
.PHONY: push-check-full
|
||||
push-check-full: push-check test-integration smoke-bundle ## Pre-release gate (push-check + integration + smoke; slower; skips theme_picker per T-116).
|
||||
push-check-full: push-check test-integration smoke-bundle ## Pre-release gate (push-check + integration + smoke; slower).
|
||||
|
||||
.PHONY: hooks
|
||||
hooks: ## Install the repo's git hooks.
|
||||
|
||||
@@ -36,7 +36,10 @@ schema_version: 1
|
||||
# About screen can read it at runtime.
|
||||
self:
|
||||
name: clide
|
||||
version: "2.0.0-dev"
|
||||
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||
# pubspec instead.
|
||||
version: "2.1.0"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
# start" regression gate. Flutter integration tests prefer one file at
|
||||
# a time on desktop; we iterate to avoid the "Unable to start the app"
|
||||
# error that hits when they run as a batch.
|
||||
#
|
||||
# Skips: theme_picker_test.dart — pumpAndSettle hangs on theme.pick
|
||||
# (T-116). Restore once that's fixed.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
for f in integration_test/*_test.dart; do
|
||||
case "$f" in
|
||||
integration_test/theme_picker_test.dart) echo "==> integration_test: $f (SKIPPED — T-116)"; continue ;;
|
||||
esac
|
||||
echo "==> integration_test: $f"
|
||||
flutter test "$f"
|
||||
done
|
||||
|
||||
@@ -111,6 +111,10 @@ You might also want, project-permitting:
|
||||
- [D-67: Pql changelog files are committed alongside code](decisions/process.md#d-67-pql-changelog-files-are-committed-alongside-code) — _process_
|
||||
- [D-68: Dual integration surface — Bash CLI primary, MCP secondary](decisions/architecture.md#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary) — _architecture_
|
||||
- [D-69: published themes are user contracts; ship -hc variants for a11y](decisions/accessibility.md#d-69-published-themes-are-user-contracts-ship--hc-variants-for-a11y) — _accessibility_
|
||||
- [D-70: IPC socket path is per-workspace, deterministic](decisions/architecture.md#d-70-ipc-socket-path-is-per-workspace-deterministic) — _architecture_
|
||||
- [D-71: IPC socket access gated by chmod 0600 on socket + parent](decisions/architecture.md#d-71-ipc-socket-access-gated-by-chmod-0600-on-socket--parent) — _architecture_
|
||||
- [D-72: IPC server is multi-connection with serial dispatch on the main isolate](decisions/architecture.md#d-72-ipc-server-is-multi-connection-with-serial-dispatch-on-the-main-isolate) — _architecture_
|
||||
- [D-73: MCP transport for /ide is SSE over HTTP](decisions/architecture.md#d-73-mcp-transport-for-ide-is-sse-over-http) — _architecture_
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -226,7 +226,8 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- `/decisions/`, `/docs/`, `/legacy/` — unchanged
|
||||
- **Rationale:** One package means one `pubspec.yaml`, one `flutter analyze`, one `flutter test`, no `cd` gymnastics, no cross-package import barriers. The IPC server running in-process eliminates the daemon lifecycle (start, stop, reconnect, pid file). If the app crashes, tmux sessions survive; the app re-attaches on restart. The CLI client in C is ~100 lines (socket connect + JSON exchange) with the same contract as pql.
|
||||
- **Cost:** If the Flutter app is not running, Claude's `clide` commands fail. In practice this is acceptable — the IDE being closed means the user isn't working. A future "headless mode" could start the Flutter engine without a window if needed.
|
||||
- **Cross-reference:** [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended), [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) (tmux persistence), [D-1](#d-1-cli-first-not-mcp) (CLI-first surface preserved via C client).
|
||||
- **Amendment (2026-05-19):** Implemented in T-99 across eight slices (T-124 server, T-125 argv translator, T-126 C client, T-127 socket loopback replacing InProcessClient, T-128 legacy-IPC cleanup, T-129 event streaming, T-130 MCP companion, T-131 this wrap-up). Per-workspace unix socket at the D-70 path; D-71 chmod gate; D-72 multi-connection serial dispatch; D-73 MCP transport. The C `clide` client lives at `native/clide-cli/clide.c` and ships with `make clide-cli`. Only the socket IPC model survives — InProcessClient + IsolateClient + Backend gone.
|
||||
- **Cross-reference:** [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended), [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) (tmux persistence), [D-1](#d-1-cli-first-not-mcp) (CLI-first surface preserved via C client), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic) / [D-71](#d-71-ipc-socket-access-gated-by-chmod-0600-on-socket--parent) / [D-72](#d-72-ipc-server-is-multi-connection-with-serial-dispatch-on-the-main-isolate) (implementation contracts).
|
||||
- **Raised by:** 2026-04-23 architectural simplification.
|
||||
|
||||
### D-57: Frameless custom chrome with per-column 24px hats
|
||||
@@ -248,11 +249,44 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
### D-68: Dual integration surface — Bash CLI primary, MCP secondary
|
||||
- **Date:** 2026-05-15
|
||||
- **Decision:** clide exposes two integration surfaces over the same in-process `DaemonDispatcher`. **(1) Bash CLI over Unix socket — primary.** Per [D-1](#d-1-cli-first-not-mcp) and [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), a thin C client (`clide …`) connects to a per-user Unix socket served in-process by the Flutter app, exchanges JSON-lines, and exits. This is the surface Claude-Code-in-a-pane uses; it is also the surface for human shell use, scripts, and external editor integrations. Full action surface — `pane.*`, `files.*`, `editor.*`, `git.*`, `pql.*`, …. **(2) `/ide`-compatible MCP server — secondary.** clide additionally serves an MCP endpoint compatible with Claude Code's `/ide` integration (the same protocol VS Code and JetBrains plugins serve). Minimum tools: `mcp__ide__getDiagnostics`, `mcp__ide__executeCode`. Optional `mcp__clide__*` namespace exposing high-leverage clide tools is deferred to [Q-32](../questions/architecture.md#q-32-mcp-tool-surface-minimum-slash-ide-or-extended-clide-tools). Transport choice deferred to [Q-33](../questions/architecture.md#q-33-mcp-transport-sse-websocket-stdio-or-all). The MCP server wraps the *same* `DaemonDispatcher`; there is no second source of truth.
|
||||
- **Amendment (2026-05-19):** Both surfaces implemented in T-99. CLI lands as `IpcServer` over the unix socket (T-124, per D-70/71/72) with the C client at `native/clide-cli/clide.c` (T-126); the argv grammar lives in `lib/src/cli/argv_to_request.dart` (T-125). MCP lands as `McpServer` over HTTP+SSE (T-130, per D-73) — the transport choice closed Q-33. The two `/ide` minimum tools ship as stubs; real implementations follow as Q-32 resolves the broader tool-surface question.
|
||||
- **Decision:** clide exposes two integration surfaces over the same in-process `DaemonDispatcher`. **(1) Bash CLI over Unix socket — primary.** Per [D-1](#d-1-cli-first-not-mcp) and [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), a thin C client (`clide …`) connects to a per-user Unix socket served in-process by the Flutter app, exchanges JSON-lines, and exits. This is the surface Claude-Code-in-a-pane uses; it is also the surface for human shell use, scripts, and external editor integrations. Full action surface — `pane.*`, `files.*`, `editor.*`, `git.*`, `pql.*`, …. **(2) `/ide`-compatible MCP server — secondary.** clide additionally serves an MCP endpoint compatible with Claude Code's `/ide` integration (the same protocol VS Code and JetBrains plugins serve). Minimum tools: `mcp__ide__getDiagnostics`, `mcp__ide__executeCode`. Optional `mcp__clide__*` namespace exposing high-leverage clide tools is deferred to [Q-32](../questions/architecture.md#q-32-mcp-tool-surface-minimum-slash-ide-or-extended-clide-tools). Transport closed by [D-73](#d-73-mcp-transport-for-ide-is-sse-over-http) — SSE over HTTP. The MCP server wraps the *same* `DaemonDispatcher`; there is no second source of truth.
|
||||
- **Context:** [D-1](#d-1-cli-first-not-mcp) chose CLI-first over MCP-only because MCP alone doesn't cover the action surface clide needs — Claude Code's `/ide` MCP exposes only two narrow tools (`getDiagnostics`, `executeCode`), enough for Claude to read diagnostics and run Jupyter cells but not enough to *drive* an IDE. The CLI surface gives full reach. But for users who run Claude Code *outside* clide and connect via `/ide`, MCP is the only path Claude Code knows; not serving it means clide is invisible to that workflow. The two surfaces are complementary, not alternatives. Reinforced by the [2026-05-14 consultant review](../../consultants.md): the architect flagged the absent socket server as the most critical drift; user confirmed the socket server (D-56 path a) plus an MCP companion.
|
||||
- **Rationale:** Both surfaces wrap the same dispatcher, so neither becomes a second source of truth. CLI remains the contract user/Claude parity ([D-6](#d-6-cli-and-event-surface-contract)) is enforced against. MCP is added because the `/ide` ecosystem is real and growing — VS Code, JetBrains, Cursor, Windsurf all serve compatible MCP — and clide should be a peer there. The implementation cost is a protocol adapter + tool definitions, not duplicate business logic.
|
||||
- **Cost:** Two transports to maintain. Mitigated by both wrapping the same dispatcher: the MCP adapter is the only thing that has to track `/ide` protocol evolution. If `mcp__clide__*` tools are added (pending Q-32), surface bloat is the obvious risk — every CLI verb invites an MCP twin; resist by default, justify on user need.
|
||||
- **Cross-reference:** [D-1](#d-1-cli-first-not-mcp) (amended — see amendment line there), [D-6](#d-6-cli-and-event-surface-contract), [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), [Q-32](../questions/architecture.md#q-32-mcp-tool-surface-minimum-slash-ide-or-extended-clide-tools), [Q-33](../questions/architecture.md#q-33-mcp-transport-sse-websocket-stdio-or-all).
|
||||
- **Raised by:** 2026-05-15 — consultant review (`consultants.md`) flagged the absent socket server (D-56 unimplemented) as the highest architectural drift; user chose option (a) "implement the server" and asked for MCP coverage alongside.
|
||||
|
||||
### D-70: IPC socket path is per-workspace, deterministic
|
||||
- **Date:** 2026-05-18
|
||||
- **Decision:** The Unix-domain IPC socket served by the Flutter app (per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server) / [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary)) lives at `$XDG_RUNTIME_DIR/clide/<hash(workspace-root)>.sock` on Linux and `$HOME/Library/Caches/clide/<hash(workspace-root)>.sock` on macOS. The workspace root is the git toplevel (the same path the Flutter app resolved on boot). The hash is **FNV-1a 64-bit, lower-case hex (16 chars)** — deterministic, dependency-free (no `package:crypto`), matches the existing `session_naming.dart` `_hash` shape so users see one hashing pattern across clide's process boundaries. No env override. The C client (T-126) and any other consumer resolves its target socket by walking CWD up to the git toplevel and computing the same hash.
|
||||
- **Rationale:** "Repo-is-the-workspace" (CLAUDE.md guardrail) means clide instances are per-repo, so the socket must be too — a per-user-global socket would force one running clide per user and break the multi-repo workflow. The same hash on both sides ensures the shell client + the running app always agree without configuration. No env override because the deterministic path is the contract; the only reason to override is a test fixture, and tests can set `XDG_RUNTIME_DIR` to a tempdir directly. FNV-1a over a crypto hash: collision resistance isn't a security need (workspace paths are user-supplied; the path is `0600`-readable only by that user anyway); 64 bits is overkill for the cardinality (a user with 65k workspaces would still see negligible birthday collisions). Aligns with [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)'s tmux-socket-per-repo convention so users see one consistent pattern.
|
||||
- **Cost:** The 16-char hex prefix means socket paths aren't human-readable at a glance — `ls $XDG_RUNTIME_DIR/clide/` won't tell you which one is which repo. Acceptable; the C client never asks the user to type the path, and debugging can use a sibling `.path` file next to each socket if it becomes painful.
|
||||
- **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary), `lib/kernel/src/files.dart` (workspace root resolution).
|
||||
- **Raised by:** 2026-05-18 — T-99 design pass; locked in before T-124 starts so the server + client agree on path strategy.
|
||||
|
||||
### D-71: IPC socket access gated by chmod 0600 on socket + parent
|
||||
- **Date:** 2026-05-18
|
||||
- **Decision:** The IPC socket file and its parent directory ([D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)) are created with permissions `0600` (owner read/write only) and `0700` respectively. No token-based auth at the IPC layer; the Unix file-permission check is the only gate. Capability-scoped auth for third-party Lua/Dart extensions is a separate concern and remains tracked by [Q-1](../questions/architecture.md#q-1-authorisation-granularity) for when extensions actually need it.
|
||||
- **Rationale:** clide's threat model on a developer workstation is "another user on the same host should not be able to drive my IDE." File perms cover this exhaustively — the kernel enforces the check on every `connect(2)`, no userspace token comparison can match that. Adding a session token on top would be belt-and-suspenders without expanding the threat model. Capability tokens become useful when extensions can publish their own dispatcher routes and we want to gate which third-party code can reach which subsystem — but that's a Tier-6 concern.
|
||||
- **Cost:** Doesn't defend against same-uid attacks (a malicious process running as the user can connect). Accepted — same-uid is outside this layer's threat model; that's a sandboxing / capability concern that lives further out. Doesn't work on shared multi-user dev hosts where one socket needs to be reachable by multiple uids — clide isn't targeted at that workflow today.
|
||||
- **Cross-reference:** [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [Q-1](../questions/architecture.md#q-1-authorisation-granularity).
|
||||
- **Raised by:** 2026-05-18 — T-99 design pass.
|
||||
|
||||
### D-72: IPC server is multi-connection with serial dispatch on the main isolate
|
||||
- **Date:** 2026-05-18
|
||||
- **Decision:** The IPC server ([D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)) is a multi-connection accept loop over `ServerSocket.listen`. Multiple clients (one-shot `clide <verb>` calls, a long-lived `clide tail --events` subscriber, the MCP adapter) can hold simultaneous connections. Each connection reads its own JSON-line stream asynchronously. **Dispatch through the existing `DaemonDispatcher` is serial on the main Flutter isolate** — the dispatcher walks one request at a time. Individual handlers are free to offload heavy or blocking work to short-lived worker isolates (the pattern `NativePty` and `SchedulerService` already use); the IPC layer doesn't impose that choice.
|
||||
- **Rationale:** Multi-connection at the socket layer is what every other concurrent operation in the codebase needs — `clide tail --events` (T-129) is structurally a long-lived subscription, the MCP adapter (T-130) lives on a separate connection from the CLI's one-shot requests, and parallel CLI invocations from a developer's shell shouldn't serialise on the I/O level. Serial dispatch on the main isolate is forced by the architecture, not chosen: subsystem handlers (PaneRegistry, FilesService, EditorRegistry, GitClient, PqlClient) hold mutable Dart objects + `ChangeNotifier`s the UI rebuilds from, and Dart isolates don't share heaps. A worker-isolate-per-connection design would have to ferry every request back to the main isolate via `SendPort` to actually execute — pure overhead with no parallel-dispatch gain. Per-handler isolate offload solves the only real problem (a slow handler janking the UI) without paying the isolate-safety tax across every subsystem.
|
||||
- **Cost:** A genuinely slow handler that doesn't offload to an isolate blocks the dispatch queue for all other connections until it returns. Mitigated by the per-handler offload pattern already in the codebase. Doesn't fit a future world where clide hosts headless workers (CI, batch jobs) that want true parallel dispatch — that's a different product shape and would warrant rethinking this decision.
|
||||
- **Cross-reference:** [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), `lib/src/pty/native_pty.dart` (per-handler isolate offload example), `lib/kernel/src/scheduler.dart` (same pattern).
|
||||
- **Raised by:** 2026-05-18 — T-99 design pass; user explicitly considered worker-isolate-per-connection and confirmed serial dispatch on main is the right shape given the shared-state architecture.
|
||||
|
||||
### D-73: MCP transport for /ide is SSE over HTTP
|
||||
- **Date:** 2026-05-19
|
||||
- **Decision:** The `/ide`-compatible MCP server clide ships per [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary) uses **HTTP + Server-Sent Events** as its transport. The Flutter app binds an HTTP server on a random localhost port at startup, advertises itself via a discovery file at `$HOME/.claude/ide/<pid>.lock` (the format Claude Code's `/ide` command discovers), and serves: a `GET /sse` endpoint that opens a long-lived SSE stream for server-to-client JSON-RPC responses + events, and a `POST /messages` endpoint that accepts client-to-server JSON-RPC requests. Closes Q-33.
|
||||
- **Rationale:** clide's Flutter app is always-running and user-launched — the agent connects to it, not the other way around. That rules out stdio (which assumes the agent spawns the server as a subprocess). Between SSE and WebSocket, SSE wins on three counts: (a) Claude Code's existing `/ide` discovery already uses HTTP servers advertised via lock files, (b) SSE is trivially implementable on `dart:io`'s `HttpServer` (long-lived response + `data: <json>\n\n` per message — no upgrade dance, no framing), (c) JSON-RPC is fundamentally client-pushes-requests / server-pushes-responses-and-events, which maps cleanly to "POST in / SSE out." WebSocket buys bidirectional symmetry we don't need. Per [D-72](#d-72-ipc-server-is-multi-connection-with-serial-dispatch-on-the-main-isolate) the MCP layer is just another transport that wraps the same `DaemonDispatcher` — no second source of truth.
|
||||
- **Cost:** SSE requires a long-lived HTTP response. Browsers cap concurrent SSE connections per origin at 6, but the consumers here are Claude Code instances (not browsers) and one-per-workspace is the expected fan-out. Adds an HTTP listener alongside the unix socket — a small surface increase, but the alternatives are worse. Each running clide grabs a random localhost port; no contention.
|
||||
- **Cross-reference:** [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary), [D-72](#d-72-ipc-server-is-multi-connection-with-serial-dispatch-on-the-main-isolate), `lib/src/ipc/mcp_server.dart` (this transport's implementation lands in T-130).
|
||||
- **Raised by:** 2026-05-19 — T-130 design pass; user picked SSE over HTTP after weighing against WebSocket and stdio.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -40,7 +40,7 @@ void main() {
|
||||
'builtin.ipc-status',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:clide/builtin/theme_picker/theme_picker.dart';
|
||||
import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -30,7 +31,7 @@ void main() {
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
@@ -39,21 +40,39 @@ void main() {
|
||||
..register(ThemePickerExtension());
|
||||
await services.extensions.activateAll();
|
||||
|
||||
// Larger viewport so the welcome screen's _StatusLine row doesn't
|
||||
// overflow on the default ~800x600 — that overflow throws a layout
|
||||
// assertion that fails the test before we get to theme.pick.
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(ClideApp(services: services));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Invoke the command.
|
||||
await services.commands.execute('theme.pick');
|
||||
// Fire-and-forget: theme.pick's run handler awaits
|
||||
// ctx.dialog.show(...), whose Future doesn't complete until the
|
||||
// dialog is dismissed. Awaiting here would deadlock the test
|
||||
// before the dialog ever mounts.
|
||||
final pending = services.commands.execute('theme.pick');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Select theme'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
|
||||
// Dismiss via Cancel.
|
||||
// Dismiss via Cancel — this completes the pending future above.
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Select theme'), findsNothing);
|
||||
await pending;
|
||||
|
||||
// Tear the widget tree down BEFORE disposing services so widgets
|
||||
// that listen to kernel notifiers (KeymapService, etc.) unsubscribe
|
||||
// first. Disposing services while the tree is mounted triggers
|
||||
// "ChangeNotifier used after dispose" during teardown rebuilds.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
await services.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io' show Platform, Process, ProcessStartMode;
|
||||
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/clide.dart' show clideName;
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
@@ -34,7 +35,7 @@ class _AppRoot extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return WidgetsApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'clide',
|
||||
title: clideName,
|
||||
color: const Color(0xFF000000),
|
||||
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(
|
||||
settings: settings,
|
||||
@@ -380,7 +381,7 @@ class _ProjectSwitcherButton extends StatelessWidget {
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
final name = kernel.project.current?.path.split('/').last;
|
||||
final label = name != null ? 'clide > $name' : 'clide';
|
||||
final label = name != null ? '$clideName > $name' : clideName;
|
||||
return ClideTappable(
|
||||
onTap: _openSwitcher,
|
||||
builder: (context, hovered, _) => Row(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show MissingPluginException;
|
||||
@@ -90,7 +91,7 @@ class _TipsCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('TIPS', fontSize: 12, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('TIPS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 14),
|
||||
_tipRow(firstRow),
|
||||
const SizedBox(height: 8),
|
||||
@@ -108,8 +109,8 @@ class _TipsCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(tips[i].$1, fontSize: 13, color: tokens.globalTextMuted)),
|
||||
ClideText(tips[i].$2, fontSize: 12, color: tokens.globalForeground, fontFamily: clideMonoFamily),
|
||||
Expanded(child: ClideText(tips[i].$1, fontSize: clideFontMeta, color: tokens.globalTextMuted)),
|
||||
ClideText(tips[i].$2, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -134,8 +135,8 @@ class _Header extends StatelessWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('clide', fontSize: 52, fontWeight: FontWeight.w300, color: tokens.globalForeground),
|
||||
ClideText('IDE for Claude Code CLI', muted: true, fontSize: 16),
|
||||
ClideText(clideName, fontSize: clideFontWelcomeBanner, fontWeight: FontWeight.w300, color: tokens.globalForeground),
|
||||
ClideText(clideTagline, muted: true, fontSize: clideFontDialogTitle),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -153,7 +154,7 @@ class _StartColumn extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('START', fontSize: 12, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 20),
|
||||
_ActionRow(
|
||||
icon: PhosphorIcons.folder,
|
||||
@@ -236,8 +237,8 @@ class _ActionRow extends StatelessWidget {
|
||||
children: [
|
||||
ClideIcon(icon, size: 18, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: ClideText(label, fontSize: 15, color: tokens.globalForeground)),
|
||||
if (shortcut != null) ClideText(shortcut!, fontSize: 13, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
Expanded(child: ClideText(label, fontSize: clideFontBody, color: tokens.globalForeground)),
|
||||
if (shortcut != null) ClideText(shortcut!, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -259,12 +260,18 @@ class _RecentColumn extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('RECENT', fontSize: 12, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('RECENT', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 20),
|
||||
if (recents.isEmpty)
|
||||
const ClideText('No recent projects.', muted: true, fontSize: 14)
|
||||
const ClideText('No recent projects.', muted: true, fontSize: clideFontCaption)
|
||||
else
|
||||
for (final r in recents) _RecentRow(project: r, tokens: tokens, onTap: () => _openRecent(r.path)),
|
||||
for (final r in recents)
|
||||
_RecentRow(
|
||||
project: r,
|
||||
tokens: tokens,
|
||||
onTap: () => _openRecent(r.path),
|
||||
onToggleSticky: () => kernel.project.setStickyStartup(r.path, !r.startupSticky),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -279,10 +286,16 @@ class _RecentColumn extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _RecentRow extends StatelessWidget {
|
||||
const _RecentRow({required this.project, required this.tokens, required this.onTap});
|
||||
const _RecentRow({
|
||||
required this.project,
|
||||
required this.tokens,
|
||||
required this.onTap,
|
||||
required this.onToggleSticky,
|
||||
});
|
||||
final RecentProject project;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onToggleSticky;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -300,25 +313,32 @@ class _RecentRow extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(project.name, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
ClideText(project.name, fontSize: clideFontBody, fontWeight: FontWeight.w500),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClideText(project.relativePath,
|
||||
muted: true, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
if (project.branch != null) ...[
|
||||
ClideText(' · ', muted: true, fontSize: 13),
|
||||
ClideText(' · ', muted: true, fontSize: clideFontMeta),
|
||||
ClideIcon(PhosphorIcons.gitBranch, size: 11, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(project.branch!, muted: true, fontSize: 13, fontFamily: clideMonoFamily),
|
||||
ClideText(project.branch!, muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClideText(project.timeAgo, muted: true, fontSize: 13),
|
||||
_StickyToggle(
|
||||
key: ValueKey('welcome.sticky.${project.path}'),
|
||||
sticky: project.startupSticky,
|
||||
tokens: tokens,
|
||||
onTap: onToggleSticky,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ClideText(project.timeAgo, muted: true, fontSize: clideFontMeta),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -326,6 +346,39 @@ class _RecentRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sticky-startup checkbox shown on each recent-project row (T-115).
|
||||
/// When exactly one row is checked, clide opens that project on next
|
||||
/// launch instead of showing the picker. Tooltip explains the rule.
|
||||
class _StickyToggle extends StatelessWidget {
|
||||
const _StickyToggle({super.key, required this.sticky, required this.tokens, required this.onTap});
|
||||
final bool sticky;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
checked: sticky,
|
||||
label: 'always open this project on launch',
|
||||
tooltip: sticky ? 'Always open this project on launch (uncheck to restore picker)' : 'Always open this project on launch',
|
||||
child: ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: sticky ? tokens.statusBarItemActiveBackground : null,
|
||||
border: Border.all(color: hovered || sticky ? tokens.panelActiveBorder : tokens.globalBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: sticky ? ClideIcon(PhosphorIcons.check, size: 12, color: tokens.buttonForeground) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
const _StatusLine({required this.tokens, required this.kernel});
|
||||
final SurfaceTokens tokens;
|
||||
@@ -338,20 +391,29 @@ class _StatusLine extends StatelessWidget {
|
||||
listenable: kernel.toolchain,
|
||||
builder: (ctx, _) {
|
||||
final tc = kernel.toolchain;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
if (!tc.resolved)
|
||||
ClideText('checking…', muted: true, fontSize: 12, fontFamily: clideMonoFamily)
|
||||
else if (tc.allOk)
|
||||
ClideText('application ok', fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
|
||||
else
|
||||
ClideText(tc.missing.map((t) => '$t not found').join(' · '), fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusWarning),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
|
||||
],
|
||||
// FittedBox+scaleDown shrinks the row uniformly on narrow
|
||||
// viewports rather than overflowing — at standard widths it's
|
||||
// a no-op. The status line is decorative chrome; keep it on
|
||||
// one line by accepting a tiny font on very narrow screens.
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText('$clideName $clideVersion', muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
ClideText(' · ', muted: true, fontSize: clideFontSmall),
|
||||
if (!tc.resolved)
|
||||
ClideText('checking…', muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)
|
||||
else if (tc.allOk)
|
||||
ClideText('application ok', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
|
||||
else
|
||||
ClideText(tc.missing.map((t) => '$t not found').join(' · '),
|
||||
fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusWarning),
|
||||
ClideText(' · ', muted: true, fontSize: clideFontSmall),
|
||||
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -371,8 +433,8 @@ class _ThemeLink extends StatelessWidget {
|
||||
builder: (ctx, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText('theme: ', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(themeName, fontSize: 12, fontFamily: clideMonoFamily, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
ClideText('theme: ', muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
ClideText(themeName, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -437,9 +499,9 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ClideText('Open project', fontSize: 16, fontWeight: FontWeight.w600),
|
||||
const ClideText('Open project', fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600),
|
||||
const SizedBox(height: 4),
|
||||
const ClideText('Enter the path to a git repository.', muted: true, fontSize: 13),
|
||||
const ClideText('Enter the path to a git repository.', muted: true, fontSize: clideFontMeta),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
@@ -451,7 +513,8 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
child: EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(color: tokens.globalForeground, fontSize: 14, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground, fontSize: clideFontCaption, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
||||
cursorColor: tokens.globalForeground,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
@@ -459,7 +522,7 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClideText(_error!, color: tokens.statusError, fontSize: 12),
|
||||
ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -496,14 +559,14 @@ class _NotARepoDialog extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ClideText('No git repo found', fontSize: 16, fontWeight: FontWeight.w600),
|
||||
const ClideText('No git repo found', fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(path, muted: true, fontSize: 13),
|
||||
ClideText(path, muted: true, fontSize: clideFontMeta),
|
||||
const SizedBox(height: 8),
|
||||
const ClideText(
|
||||
'A clide project root requires a git repository.',
|
||||
muted: true,
|
||||
fontSize: 13,
|
||||
fontSize: clideFontMeta,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
|
||||
+5
-19
@@ -27,22 +27,8 @@ export 'src/ipc/schema_v1.dart';
|
||||
export 'src/panes/event_sink.dart';
|
||||
export 'src/panes/pane.dart' show Pane, PaneKind;
|
||||
|
||||
/// Build-time-stamped version string.
|
||||
///
|
||||
/// The Makefile's `build` target passes `--define=clideVersion=…`,
|
||||
/// stamping `pubspec.yaml`'s `version:` plus the git short SHA and
|
||||
/// dirty marker.
|
||||
const clideVersion = String.fromEnvironment(
|
||||
'clideVersion',
|
||||
defaultValue: '2.0.0-dev',
|
||||
);
|
||||
|
||||
const clideCommit = String.fromEnvironment(
|
||||
'clideCommit',
|
||||
defaultValue: 'unknown',
|
||||
);
|
||||
|
||||
const clideDate = String.fromEnvironment(
|
||||
'clideDate',
|
||||
defaultValue: 'unknown',
|
||||
);
|
||||
// clideName, clideTagline, clideVersion, clideRepository, clideCommit,
|
||||
// clideDate live in lib/src/build_info.g.dart, regenerated by every
|
||||
// `make` build/run/test target from pubspec.yaml + git short SHA +
|
||||
// current UTC time. See `gen-build-info` in the Makefile.
|
||||
export 'src/build_info.g.dart' show clideName, clideTagline, clideVersion, clideRepository, clideCommit, clideDate;
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/// Manages the backend isolate lifecycle.
|
||||
///
|
||||
/// Two-phase boot:
|
||||
/// 1. [Backend.spawn] — starts the isolate, resolves toolchain (binary checks only).
|
||||
/// 2. [Backend.openProject] — initializes services for a specific project root.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/backend_entry.dart';
|
||||
import 'package:clide/kernel/src/ipc/isolate_client.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
|
||||
class Backend {
|
||||
Backend._({
|
||||
required this.client,
|
||||
required this.toolchain,
|
||||
required SendPort backendRequestPort,
|
||||
required Isolate isolate,
|
||||
required ReceivePort receivePort,
|
||||
}) : _backendRequestPort = backendRequestPort,
|
||||
_isolate = isolate,
|
||||
_receivePort = receivePort;
|
||||
|
||||
final IsolateClient client;
|
||||
final Toolchain toolchain;
|
||||
final SendPort _backendRequestPort;
|
||||
final Isolate _isolate;
|
||||
final ReceivePort _receivePort;
|
||||
|
||||
Completer<void>? _projectCompleter;
|
||||
final Map<String, Completer<String?>> _validateCompleters = {};
|
||||
int _validateId = 0;
|
||||
|
||||
/// Spawn the backend isolate. Returns when the toolchain is resolved.
|
||||
/// No services are active yet — call [openProject] to activate.
|
||||
static Future<Backend> spawn({
|
||||
required IsolateClient Function(SendPort backendPort) clientFactory,
|
||||
String? hintRoot,
|
||||
}) async {
|
||||
final receivePort = ReceivePort();
|
||||
final completer = Completer<Backend>();
|
||||
|
||||
late final IsolateClient client;
|
||||
late final Isolate isolate;
|
||||
late final SendPort backendRequestPort;
|
||||
late final Backend backend;
|
||||
|
||||
receivePort.listen((message) {
|
||||
if (message is Map<String, Object?>) {
|
||||
final type = message['type'] as String?;
|
||||
if (type == 'ready') {
|
||||
backendRequestPort = message['requestPort'] as SendPort;
|
||||
client = clientFactory(backendRequestPort);
|
||||
|
||||
final tcData = message['toolchain'] as Map<String, Object?>;
|
||||
final toolchain = Toolchain();
|
||||
toolchain.applyResolved(ResolvedPaths(
|
||||
git: tcData['git'] as String?,
|
||||
pql: tcData['pql'] as String?,
|
||||
tmux: tcData['tmux'] as String?,
|
||||
shell: tcData['shell'] as String?,
|
||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||
));
|
||||
|
||||
backend = Backend._(
|
||||
client: client,
|
||||
toolchain: toolchain,
|
||||
backendRequestPort: backendRequestPort,
|
||||
isolate: isolate,
|
||||
receivePort: receivePort,
|
||||
);
|
||||
|
||||
if (!completer.isCompleted) completer.complete(backend);
|
||||
} else if (type == 'project.validated') {
|
||||
final id = message['id'] as String;
|
||||
final root = message['root'] as String?;
|
||||
final c = backend._validateCompleters.remove(id);
|
||||
if (c != null && !c.isCompleted) c.complete(root);
|
||||
} else if (type == 'project.ready') {
|
||||
// Update toolchain with project-specific paths.
|
||||
final tcData = message['toolchain'] as Map<String, Object?>;
|
||||
backend.toolchain.applyResolved(ResolvedPaths(
|
||||
git: tcData['git'] as String?,
|
||||
pql: tcData['pql'] as String?,
|
||||
tmux: tcData['tmux'] as String?,
|
||||
shell: tcData['shell'] as String?,
|
||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||
));
|
||||
backend._projectCompleter?.complete();
|
||||
backend._projectCompleter = null;
|
||||
} else {
|
||||
// Response or event — forward to the client.
|
||||
client.handleMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isolate = await Isolate.spawn(
|
||||
backendEntry,
|
||||
BackendBootMessage(frontendPort: receivePort.sendPort, hintRoot: hintRoot),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Validate a path as a git repo. Returns the repo root or null.
|
||||
/// Runs git rev-parse in the backend isolate (no main-thread I/O).
|
||||
Future<String?> validateProject(String path) {
|
||||
final id = '${_validateId++}';
|
||||
final c = Completer<String?>();
|
||||
_validateCompleters[id] = c;
|
||||
_backendRequestPort.send({
|
||||
'type': 'project.validate',
|
||||
'path': path,
|
||||
'id': id,
|
||||
});
|
||||
return c.future;
|
||||
}
|
||||
|
||||
/// Activate a project. The backend (re)initializes all services
|
||||
/// for the given root directory. Returns when services are ready.
|
||||
Future<void> openProject(String path) {
|
||||
_projectCompleter = Completer<void>();
|
||||
_backendRequestPort.send({
|
||||
'type': 'project.open',
|
||||
'path': path,
|
||||
});
|
||||
return _projectCompleter!.future;
|
||||
}
|
||||
|
||||
/// Shut down the backend isolate.
|
||||
void dispose() {
|
||||
_isolate.kill(priority: Isolate.beforeNextEvent);
|
||||
_receivePort.close();
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/// Backend isolate entry point.
|
||||
///
|
||||
/// Two-phase boot:
|
||||
/// 1. Resolve toolchain (find binaries) → report ready.
|
||||
/// 2. On `project.open` message → initialize services for the project.
|
||||
///
|
||||
/// The dispatcher only registers command handlers after a project is
|
||||
/// activated. IPC requests arriving before that get an error response.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
import 'package:clide/src/panes/event_sink.dart';
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
|
||||
/// Message sent from main isolate to bootstrap the backend.
|
||||
class BackendBootMessage {
|
||||
const BackendBootMessage({required this.frontendPort, this.hintRoot});
|
||||
final SendPort frontendPort;
|
||||
|
||||
/// Optional path hint for initial toolchain resolution (e.g. CLIDE_PROJECT).
|
||||
/// Used to find project-local binaries like dugite before a project opens.
|
||||
final String? hintRoot;
|
||||
}
|
||||
|
||||
/// Top-level entry point for the backend isolate.
|
||||
void backendEntry(BackendBootMessage boot) {
|
||||
final frontendPort = boot.frontendPort;
|
||||
final requestPort = ReceivePort();
|
||||
final eventSink = _IsolateEventSink(frontendPort);
|
||||
final dispatcher = DaemonDispatcher();
|
||||
late Toolchain toolchain;
|
||||
|
||||
// Phase 1: resolve toolchain — just find binaries, don't init services.
|
||||
// Dugite is resolved against the install dir; per T-98 the project
|
||||
// root is never inspected during toolchain resolution.
|
||||
toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths());
|
||||
|
||||
// Listen for messages from the frontend.
|
||||
requestPort.listen((message) async {
|
||||
if (message is! Map<String, Object?>) return;
|
||||
final type = message['type'] as String?;
|
||||
|
||||
if (type == 'project.validate') {
|
||||
// Validate a path as a git repo. Runs git rev-parse in the backend
|
||||
// isolate (safe from the merged thread). Returns the repo root or null.
|
||||
final path = message['path'] as String;
|
||||
final id = message['id'] as String;
|
||||
try {
|
||||
final r = await Process.run(toolchain.git, ['rev-parse', '--show-toplevel'], workingDirectory: path, environment: toolchain.gitEnv);
|
||||
if (r.exitCode == 0) {
|
||||
final root = (r.stdout as String).trim();
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': root});
|
||||
} else {
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': null});
|
||||
}
|
||||
} catch (_) {
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': null});
|
||||
}
|
||||
} else if (type == 'project.open') {
|
||||
// Phase 2: (re)initialize services for the given project.
|
||||
final projectPath = message['path'] as String;
|
||||
final workDir = Directory(projectPath);
|
||||
|
||||
// Re-resolve toolchain. Project path is not inspected (T-98);
|
||||
// dugite still comes from the install dir + env override.
|
||||
toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths());
|
||||
|
||||
// Clear existing handlers and re-register with new project.
|
||||
dispatcher.clear();
|
||||
|
||||
final filesService = FilesService(root: workDir, events: eventSink);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workDir);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
|
||||
final gitClient = GitClient(toolchain: toolchain, workDir: workDir);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
|
||||
final pql = PqlClient(workDir: workDir, toolchain: toolchain);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
|
||||
// Tell the frontend the project is active.
|
||||
frontendPort.send({
|
||||
'type': 'project.ready',
|
||||
'path': projectPath,
|
||||
'toolchain': _serializeToolchain(toolchain),
|
||||
});
|
||||
} else {
|
||||
// IPC request — dispatch if we have handlers.
|
||||
final req = IpcRequest.fromJson(message);
|
||||
if (dispatcher.isEmpty) {
|
||||
frontendPort.send(IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'No project active',
|
||||
hint: 'Open a project first',
|
||||
),
|
||||
).toJson());
|
||||
} else {
|
||||
final resp = await dispatcher.dispatch(req);
|
||||
frontendPort.send(resp.toJson());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send ready with toolchain state and request port.
|
||||
frontendPort.send({
|
||||
'type': 'ready',
|
||||
'requestPort': requestPort.sendPort,
|
||||
'toolchain': _serializeToolchain(toolchain),
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object?> _serializeToolchain(Toolchain tc) => {
|
||||
'git': tc.git,
|
||||
'pql': tc.pql,
|
||||
'tmux': tc.tmux,
|
||||
'shell': tc.shell,
|
||||
'gitEnv': tc.gitEnv,
|
||||
'missing': tc.missing,
|
||||
};
|
||||
|
||||
/// Sends IPC events to the frontend via SendPort.
|
||||
class _IsolateEventSink implements DaemonEventSink {
|
||||
_IsolateEventSink(this._port);
|
||||
final SendPort _port;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_port.send(event.toJson());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
abstract class ClideEvent {
|
||||
const ClideEvent();
|
||||
|
||||
@@ -10,7 +7,6 @@ abstract class ClideEvent {
|
||||
Map<String, Object?> payload() => const {};
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ClideEventEnvelope {
|
||||
const ClideEventEnvelope(this.event, this.timestamp);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/clipboard.dart';
|
||||
import 'package:clide/kernel/src/commands/keybindings.dart';
|
||||
import 'package:clide/kernel/src/keymap/keymap_service.dart';
|
||||
@@ -107,7 +106,7 @@ class KernelServices {
|
||||
Locale? initialLocale,
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
String? socketPath,
|
||||
DaemonClient Function(Logger, DaemonBus)? daemonClientFactory,
|
||||
DaemonClient Function(Logger, DaemonBus, LayoutArrangement)? daemonClientFactory,
|
||||
DaemonClient? isolateClient,
|
||||
bool autoStartDaemonClient = true,
|
||||
Toolchain? toolchain,
|
||||
@@ -165,9 +164,16 @@ class KernelServices {
|
||||
);
|
||||
final ipc = isolateClient ??
|
||||
(daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
? daemonClientFactory(log, events, arrangement)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
// Legacy socket-client fallback — kept until T-127
|
||||
// replaces it with the in-process socket loopback.
|
||||
// Today nothing in production hits this branch
|
||||
// (main.dart and the test harness pass an explicit
|
||||
// daemonClientFactory). If a caller does land here
|
||||
// without `autoStartDaemonClient: false`, the
|
||||
// placeholder path makes the failure mode obvious.
|
||||
socketPath: socketPath ?? '/dev/null/clide-legacy.sock',
|
||||
log: log,
|
||||
events: events,
|
||||
));
|
||||
|
||||
@@ -11,13 +11,15 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
class DaemonClient extends ChangeNotifier {
|
||||
DaemonClient({
|
||||
required this.socketPath,
|
||||
required String socketPath,
|
||||
required Logger log,
|
||||
required DaemonBus events,
|
||||
}) : _log = log,
|
||||
}) : _socketPath = socketPath,
|
||||
_log = log,
|
||||
_events = events;
|
||||
|
||||
final String socketPath;
|
||||
String _socketPath;
|
||||
String get socketPath => _socketPath;
|
||||
final Logger _log;
|
||||
final DaemonBus _events;
|
||||
|
||||
@@ -47,6 +49,28 @@ class DaemonClient extends ChangeNotifier {
|
||||
_setConnected(false);
|
||||
}
|
||||
|
||||
/// Point the client at a different socket path and reconnect.
|
||||
/// Used on project switch — the workspace-derived socket path
|
||||
/// (D-70) changes when the user opens a different project, so the
|
||||
/// client follows. Cancels the reconnect timer, closes the live
|
||||
/// socket (failing in-flight requests with `disconnect`), updates
|
||||
/// the path, and re-arms the connect loop. Idempotent if the new
|
||||
/// path equals the current one.
|
||||
Future<void> reconnectAt(String newPath) async {
|
||||
if (newPath == _socketPath && _connected) return;
|
||||
_socketPath = newPath;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
final s = _socket;
|
||||
_socket = null;
|
||||
await s?.close();
|
||||
_failPending('socket path changed');
|
||||
_setConnected(false);
|
||||
_disposed = false;
|
||||
_backoff = const Duration(milliseconds: 200);
|
||||
await _connect();
|
||||
}
|
||||
|
||||
Future<IpcResponse> request(
|
||||
String cmd, {
|
||||
Map<String, Object?> args = const {},
|
||||
@@ -72,12 +96,12 @@ class DaemonClient extends ChangeNotifier {
|
||||
Future<void> _connect() async {
|
||||
if (_disposed) return;
|
||||
try {
|
||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
||||
final addr = InternetAddress(_socketPath, type: InternetAddressType.unix);
|
||||
final socket = await Socket.connect(addr, 0);
|
||||
_socket = socket;
|
||||
_backoff = const Duration(milliseconds: 200);
|
||||
_setConnected(true);
|
||||
_log.info('ipc', 'connected to $socketPath');
|
||||
_log.info('ipc', 'connected to $_socketPath');
|
||||
socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
|
||||
_handleLine,
|
||||
onDone: _handleDisconnect,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
|
||||
class InProcessClient extends DaemonClient {
|
||||
InProcessClient({
|
||||
required super.log,
|
||||
required super.events,
|
||||
required this.dispatcher,
|
||||
}) : super(socketPath: '');
|
||||
|
||||
DaemonDispatcher dispatcher;
|
||||
int _nextReqId = 0;
|
||||
|
||||
@override
|
||||
bool get isConnected => true;
|
||||
|
||||
@override
|
||||
Future<void> start() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
|
||||
final id = '${_nextReqId++}';
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
return dispatcher.dispatch(req);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/// IPC client that sends requests to a backend isolate via SendPort.
|
||||
///
|
||||
/// Replaces [InProcessClient] for production use. The backend isolate
|
||||
/// owns the [DaemonDispatcher] and all subprocess/file-I/O services.
|
||||
/// Requests and responses travel as serialized Maps over SendPort,
|
||||
/// reusing the existing IPC protocol (IpcRequest/IpcResponse/IpcEvent).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
|
||||
class IsolateClient extends DaemonClient {
|
||||
IsolateClient({
|
||||
required super.log,
|
||||
required super.events,
|
||||
required SendPort backendPort,
|
||||
}) : _backendPort = backendPort,
|
||||
_events = events,
|
||||
super(socketPath: '');
|
||||
|
||||
final SendPort _backendPort;
|
||||
final DaemonBus _events;
|
||||
|
||||
/// The event bus that receives events from the backend.
|
||||
DaemonBus get events => _events;
|
||||
final Map<String, Completer<IpcResponse>> _pending = {};
|
||||
int _nextId = 0;
|
||||
|
||||
/// Called by [Backend] to feed incoming messages from the backend isolate.
|
||||
void handleMessage(Map<String, Object?> msg) {
|
||||
final type = msg['type'] as String?;
|
||||
switch (type) {
|
||||
case 'response':
|
||||
final resp = IpcResponse.fromJson(msg);
|
||||
final c = _pending.remove(resp.id);
|
||||
if (c != null && !c.isCompleted) c.complete(resp);
|
||||
case 'event':
|
||||
final evt = IpcEvent.fromJson(msg);
|
||||
_events.emit(DaemonEvent(
|
||||
subsystem: evt.subsystem,
|
||||
kind: evt.kind,
|
||||
data: evt.data,
|
||||
ts: evt.timestamp,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isConnected => true;
|
||||
|
||||
@override
|
||||
Future<void> start() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
|
||||
final id = '${_nextId++}';
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
final c = Completer<IpcResponse>();
|
||||
_pending[id] = c;
|
||||
_backendPort.send(req.toJson());
|
||||
return c.future;
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,46 @@ import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class RecentProject {
|
||||
const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened});
|
||||
const RecentProject({
|
||||
required this.path,
|
||||
required this.name,
|
||||
this.branch,
|
||||
required this.lastOpened,
|
||||
this.startupSticky = false,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String name;
|
||||
final String? branch;
|
||||
final DateTime lastOpened;
|
||||
|
||||
Map<String, dynamic> toJson() => {'path': path, 'name': name, 'branch': branch, 'lastOpened': lastOpened.toIso8601String()};
|
||||
/// When set, this project is preferred on startup over the picker.
|
||||
/// If exactly one recent has this flag, [ProjectManager.openStickyOrNothing]
|
||||
/// opens it directly; otherwise the welcome screen takes over (T-115).
|
||||
final bool startupSticky;
|
||||
|
||||
RecentProject copyWith({bool? startupSticky, DateTime? lastOpened, String? branch}) => RecentProject(
|
||||
path: path,
|
||||
name: name,
|
||||
branch: branch ?? this.branch,
|
||||
lastOpened: lastOpened ?? this.lastOpened,
|
||||
startupSticky: startupSticky ?? this.startupSticky,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
'name': name,
|
||||
'branch': branch,
|
||||
'lastOpened': lastOpened.toIso8601String(),
|
||||
if (startupSticky) 'startupSticky': true,
|
||||
};
|
||||
|
||||
factory RecentProject.fromJson(Map<String, dynamic> json) => RecentProject(
|
||||
path: json['path'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
branch: json['branch'] as String?,
|
||||
lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(),
|
||||
startupSticky: json['startupSticky'] as bool? ?? false,
|
||||
);
|
||||
|
||||
String get relativePath {
|
||||
@@ -76,6 +102,7 @@ class ProjectManager extends ChangeNotifier {
|
||||
final raw = _settings.get<String>('app.recentProjects');
|
||||
if (raw == null || raw.isEmpty) {
|
||||
_recents = [];
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -84,6 +111,7 @@ class ProjectManager extends ChangeNotifier {
|
||||
} catch (_) {
|
||||
_recents = [];
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> open(String path) async {
|
||||
@@ -104,8 +132,15 @@ class ProjectManager extends ChangeNotifier {
|
||||
|
||||
final branch = await _currentBranch(root);
|
||||
final name = root.split('/').last;
|
||||
// Preserve the sticky-startup flag across reopens — otherwise the
|
||||
// user would have to re-toggle it every time they touched the
|
||||
// project (T-115).
|
||||
final wasSticky = isStickyStartup(root);
|
||||
_recents.removeWhere((r) => r.path == root);
|
||||
_recents.insert(0, RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now()));
|
||||
_recents.insert(
|
||||
0,
|
||||
RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now(), startupSticky: wasSticky),
|
||||
);
|
||||
if (_recents.length > 10) _recents = _recents.sublist(0, 10);
|
||||
await _settings.set<String>('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList()));
|
||||
|
||||
@@ -122,6 +157,42 @@ class ProjectManager extends ChangeNotifier {
|
||||
return open(last);
|
||||
}
|
||||
|
||||
/// Path of the lone sticky-startup project, or null if zero or
|
||||
/// multiple recents have the flag. Zero ⇒ show the picker; multiple
|
||||
/// ⇒ ambiguous, also show the picker (T-115).
|
||||
String? get stickyProjectPath {
|
||||
final sticky = _recents.where((r) => r.startupSticky).toList();
|
||||
return sticky.length == 1 ? sticky.first.path : null;
|
||||
}
|
||||
|
||||
/// Open the single sticky-startup project, if any. Returns false on
|
||||
/// "no unambiguous sticky" so the caller (main.dart) can fall through
|
||||
/// to the welcome screen.
|
||||
Future<bool> openStickyOrNothing() async {
|
||||
final path = stickyProjectPath;
|
||||
if (path == null) return false;
|
||||
final dir = Directory(path);
|
||||
if (!await dir.exists()) return false;
|
||||
return open(path);
|
||||
}
|
||||
|
||||
/// Toggle the per-project sticky-startup flag and persist. No-op if
|
||||
/// the path isn't in recents (open the project first to land it
|
||||
/// there).
|
||||
Future<void> setStickyStartup(String path, bool value) async {
|
||||
final idx = _recents.indexWhere((r) => r.path == path);
|
||||
if (idx < 0) return;
|
||||
if (_recents[idx].startupSticky == value) return;
|
||||
_recents[idx] = _recents[idx].copyWith(startupSticky: value);
|
||||
await _settings.set<String>('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList()));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool isStickyStartup(String path) {
|
||||
final idx = _recents.indexWhere((r) => r.path == path);
|
||||
return idx >= 0 && _recents[idx].startupSticky;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_current == null) return;
|
||||
_current = null;
|
||||
|
||||
+101
-13
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/app.dart';
|
||||
import 'package:clide/test_app.dart';
|
||||
import 'package:clide/builtin/canvas/canvas.dart';
|
||||
@@ -27,16 +29,21 @@ import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'dart:io' show Directory, Platform;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/ipc/in_process.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/panel_commands.dart';
|
||||
import 'package:clide/src/daemon/panel_resizer_kernel.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/cli/argv_dispatch.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/mcp_server.dart';
|
||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
||||
import 'package:clide/src/ipc/server.dart';
|
||||
import 'package:clide/src/panes/event_sink.dart';
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
@@ -74,10 +81,71 @@ Future<void> main() async {
|
||||
toolchain.applyResolved(resolveToolchainPaths());
|
||||
}
|
||||
|
||||
InProcessClient? ipcClient;
|
||||
DaemonClient? ipcClient;
|
||||
DaemonBus? daemonBus;
|
||||
LayoutArrangement? kernelArrangement;
|
||||
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per
|
||||
// workspace; restarted when the active project switches because the
|
||||
// socket path is workspace-derived. The local DaemonClient connects
|
||||
// back to it over the socket so all IPC — including from UI widgets
|
||||
// in the same process — goes through the wire contract (T-127).
|
||||
IpcServer? ipcServer;
|
||||
// MCP server (T-130, per D-68 + D-73). Localhost HTTP+SSE, advertised
|
||||
// via $HOME/.claude/ide/<pid>.lock so Claude Code's /ide command
|
||||
// discovers it. Restarted alongside the unix server on project
|
||||
// switch so the discovery file reports the current workspace.
|
||||
McpServer? mcpServer;
|
||||
final ipcLog = Logger();
|
||||
|
||||
DaemonDispatcher buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot) {
|
||||
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
await ipcServer?.stop();
|
||||
} catch (e, st) {
|
||||
ipcLog.warn('ipc', 'stop failed during swap: $e');
|
||||
ipcLog.debug('ipc', '$st');
|
||||
}
|
||||
try {
|
||||
await mcpServer?.stop();
|
||||
} catch (e) {
|
||||
ipcLog.warn('mcp', 'stop failed during swap: $e');
|
||||
}
|
||||
final server = IpcServer(
|
||||
dispatcher: dispatcher,
|
||||
workspaceRoot: workRoot.path,
|
||||
log: ipcLog,
|
||||
events: daemonBus,
|
||||
);
|
||||
ipcServer = server;
|
||||
try {
|
||||
await server.start();
|
||||
} catch (e, st) {
|
||||
ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st);
|
||||
return;
|
||||
}
|
||||
final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog);
|
||||
mcpServer = mcp;
|
||||
try {
|
||||
await mcp.start();
|
||||
} catch (e, st) {
|
||||
ipcLog.warn('mcp', 'MCP server start failed (non-fatal): $e');
|
||||
ipcLog.debug('mcp', '$st');
|
||||
}
|
||||
// Point the in-process DaemonClient at the new socket. On first
|
||||
// boot (no client yet) the daemonClientFactory below kicks it
|
||||
// off; on project switch we just reconnect to the new path.
|
||||
final client = ipcClient;
|
||||
if (client != null) {
|
||||
await client.reconnectAt(server.socketPath);
|
||||
}
|
||||
}
|
||||
|
||||
DaemonDispatcher buildDispatcher(
|
||||
DaemonBus events,
|
||||
Toolchain tc,
|
||||
Directory workRoot,
|
||||
LayoutArrangement arrangement,
|
||||
) {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _BusEventSink(events);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
@@ -90,6 +158,8 @@ Future<void> main() async {
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
final pql = PqlClient(workDir: workRoot, toolchain: tc);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
registerPanelCommands(dispatcher, ArrangementPanelResizer(arrangement));
|
||||
registerArgvUnwrap(dispatcher);
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
@@ -102,18 +172,36 @@ Future<void> main() async {
|
||||
toolchain: toolchain,
|
||||
daemonClientFactory: kIsWeb
|
||||
? null
|
||||
: (log, events) {
|
||||
: (log, events, arrangement) {
|
||||
daemonBus = events;
|
||||
kernelArrangement = arrangement;
|
||||
final workRoot = FilesService.atCwd(events: _BusEventSink(events)).root;
|
||||
final dispatcher = buildDispatcher(events, toolchain, workRoot);
|
||||
ipcClient = InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
||||
return ipcClient!;
|
||||
final dispatcher = buildDispatcher(events, toolchain, workRoot, arrangement);
|
||||
// Build the client at the workspace's socket path. The
|
||||
// server is started below (swapIpcServer) which the
|
||||
// client will then auto-connect to via its reconnect
|
||||
// loop. autoStartDaemonClient:false means we own the
|
||||
// lifecycle here.
|
||||
final client = DaemonClient(
|
||||
socketPath: workspaceSocketPath(workRoot.path),
|
||||
log: log,
|
||||
events: events,
|
||||
);
|
||||
ipcClient = client;
|
||||
unawaited(() async {
|
||||
await swapIpcServer(dispatcher, workRoot);
|
||||
await client.start();
|
||||
}());
|
||||
return client;
|
||||
},
|
||||
onProjectOpen: kIsWeb
|
||||
? null
|
||||
: (path) async {
|
||||
if (ipcClient == null || daemonBus == null) return;
|
||||
ipcClient!.dispatcher = buildDispatcher(daemonBus!, toolchain, Directory(path));
|
||||
final bus = daemonBus;
|
||||
final arrangement = kernelArrangement;
|
||||
if (bus == null || arrangement == null) return;
|
||||
final dispatcher = buildDispatcher(bus, toolchain, Directory(path), arrangement);
|
||||
await swapIpcServer(dispatcher, Directory(path));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -156,10 +244,10 @@ Future<void> main() async {
|
||||
|
||||
if (!kIsWeb) {
|
||||
await services.project.loadRecents();
|
||||
var opened = await services.project.openLast();
|
||||
if (!opened) {
|
||||
opened = await services.project.open(Directory.current.path);
|
||||
}
|
||||
// T-115: picker-first. Auto-open only when exactly one recent has
|
||||
// its sticky-startup flag set; otherwise the welcome tab (default
|
||||
// workspace content) serves as the project picker.
|
||||
final opened = await services.project.openStickyOrNothing();
|
||||
if (opened) {
|
||||
services.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/// Register the `_argv` sentinel command on a [DaemonDispatcher].
|
||||
///
|
||||
/// The C client (T-126) doesn't know the dispatcher's command surface
|
||||
/// — it ships raw argv across the wire under cmd `_argv`. This handler
|
||||
/// runs [parseArgv] on the embedded argv and either dispatches the
|
||||
/// resulting [IpcRequest] or returns the pre-built error response.
|
||||
///
|
||||
/// Why a sentinel cmd rather than a top-level parse step in the IPC
|
||||
/// server: keeps the server transport-agnostic — every consumer that
|
||||
/// already has a typed [IpcRequest] goes the direct path; only the
|
||||
/// CLI's raw-argv envelope hits this unwrap shim.
|
||||
library;
|
||||
|
||||
import 'package:clide/src/cli/argv_to_request.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
|
||||
/// Sentinel command id the C `clide` client sends. Anything else
|
||||
/// goes through the normal dispatcher path unchanged.
|
||||
const String argvSentinelCmd = '_argv';
|
||||
|
||||
/// Unwrap an `_argv` IpcRequest into the inner parsed request, or
|
||||
/// return an error response if the envelope is malformed or the
|
||||
/// argv doesn't parse. Pure function — no dispatch. Used by both
|
||||
/// the IPC server (which needs the unwrapped cmd to decide whether
|
||||
/// to enter streaming mode for `tail --events`, per T-129) and the
|
||||
/// dispatcher-side handler below.
|
||||
ArgvParseResult unwrapArgvRequest(IpcRequest outer) {
|
||||
final raw = outer.args['argv'];
|
||||
if (raw is! List) {
|
||||
return ArgvError(IpcResponse.err(
|
||||
id: outer.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: '_argv requires args.argv to be a JSON array',
|
||||
),
|
||||
));
|
||||
}
|
||||
return parseArgv(raw.cast<String>(), requestId: outer.id);
|
||||
}
|
||||
|
||||
/// Wire the `_argv` sentinel handler onto [dispatcher]. The handler
|
||||
/// unwraps the inner argv via [unwrapArgvRequest], dispatches the
|
||||
/// resulting request through the same dispatcher, and otherwise
|
||||
/// returns the pre-built error response. Kept registered for the
|
||||
/// non-streaming path; the IPC server intercepts before dispatch
|
||||
/// for `tail --events` (T-129).
|
||||
void registerArgvUnwrap(DaemonDispatcher dispatcher) {
|
||||
dispatcher.register(argvSentinelCmd, (outer) async {
|
||||
return switch (unwrapArgvRequest(outer)) {
|
||||
ArgvParsed(:final request) => await dispatcher.dispatch(request),
|
||||
ArgvError(:final response) => response,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/// Translate the argv tail of a `clide …` shell invocation into an
|
||||
/// [IpcRequest] the server can dispatch. Pure Dart, no I/O.
|
||||
///
|
||||
/// Second slice of T-99 (D-56 path a). The C client (T-126) is a
|
||||
/// dumb pipe: it sends `{argv:[…]}` over the socket, the server
|
||||
/// runs this function, then dispatches. Keeping the CLI grammar in
|
||||
/// Dart means the translator can be unit-tested and shared with
|
||||
/// `make t T=…` workflows that don't shell out to the C client.
|
||||
///
|
||||
/// Grammar (per D-6):
|
||||
///
|
||||
/// ```
|
||||
/// clide SUBSYSTEM VERB [positional...] [--flag value] [-- passthrough...]
|
||||
/// clide UMBRELLA # status, tail, version, ping
|
||||
/// ```
|
||||
///
|
||||
/// The umbrella entries have no subsystem.verb split; the first arg
|
||||
/// IS the command id. D-6 lists `tail` and `status` explicitly; the
|
||||
/// dispatcher also recognises `ping` and `version` (registered by
|
||||
/// default in [DaemonDispatcher]).
|
||||
library;
|
||||
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
|
||||
/// Umbrella commands — single-token names with no subsystem.verb
|
||||
/// split. Match the IDs the dispatcher exposes directly.
|
||||
const Set<String> _umbrellaCommands = {'status', 'tail', 'version', 'ping'};
|
||||
|
||||
/// Sealed result of translating argv. Caller (the IPC server, or the
|
||||
/// C client wrapper in T-126) handles either branch.
|
||||
sealed class ArgvParseResult {
|
||||
const ArgvParseResult();
|
||||
}
|
||||
|
||||
/// Successful parse — the [request] is ready to dispatch.
|
||||
class ArgvParsed extends ArgvParseResult {
|
||||
const ArgvParsed(this.request);
|
||||
final IpcRequest request;
|
||||
}
|
||||
|
||||
/// argv was malformed at the syntactic layer (no subsystem, broken
|
||||
/// flag, etc.). The [response] is ready to write back to the client
|
||||
/// — exit code in `response.error.code` follows pql's sysexit
|
||||
/// convention (64 = EX_USAGE for shape errors).
|
||||
class ArgvError extends ArgvParseResult {
|
||||
const ArgvError(this.response);
|
||||
final IpcResponse response;
|
||||
}
|
||||
|
||||
/// Translate [argv] (everything after the program name) into an
|
||||
/// [IpcRequest] or an [ArgvError]. [requestId] is the wire id the
|
||||
/// server should echo on the response — caller assigns it (typically
|
||||
/// a counter or random short string).
|
||||
ArgvParseResult parseArgv(List<String> argv, {required String requestId}) {
|
||||
if (argv.isEmpty) {
|
||||
return ArgvError(_err(requestId, 'usage: clide <subsystem> <verb> [args...]'));
|
||||
}
|
||||
|
||||
final first = argv[0];
|
||||
// Umbrella commands: single-token name, no verb required.
|
||||
if (_umbrellaCommands.contains(first)) {
|
||||
final tail = argv.sublist(1);
|
||||
final parsed = _parseTail(tail);
|
||||
if (parsed is _TailError) {
|
||||
return ArgvError(_err(requestId, parsed.message));
|
||||
}
|
||||
return ArgvParsed(IpcRequest(
|
||||
id: requestId,
|
||||
cmd: first,
|
||||
args: (parsed as _TailOk).toArgs(),
|
||||
));
|
||||
}
|
||||
|
||||
// Subsystem.verb form: need at least two tokens.
|
||||
if (argv.length < 2) {
|
||||
return ArgvError(_err(requestId, 'usage: clide $first <verb> [args...]'));
|
||||
}
|
||||
final subsystem = first;
|
||||
final verb = argv[1];
|
||||
if (!_isValidIdentifier(subsystem)) {
|
||||
return ArgvError(_err(requestId, 'invalid subsystem: $subsystem'));
|
||||
}
|
||||
if (!_isValidIdentifier(verb)) {
|
||||
return ArgvError(_err(requestId, 'invalid verb: $verb'));
|
||||
}
|
||||
final tail = argv.sublist(2);
|
||||
final parsed = _parseTail(tail);
|
||||
if (parsed is _TailError) {
|
||||
return ArgvError(_err(requestId, parsed.message));
|
||||
}
|
||||
return ArgvParsed(IpcRequest(
|
||||
id: requestId,
|
||||
cmd: '$subsystem.$verb',
|
||||
args: (parsed as _TailOk).toArgs(),
|
||||
));
|
||||
}
|
||||
|
||||
// -- internals --------------------------------------------------------------
|
||||
|
||||
sealed class _TailParseResult {
|
||||
const _TailParseResult();
|
||||
}
|
||||
|
||||
class _TailOk extends _TailParseResult {
|
||||
const _TailOk({required this.positional, required this.flags, required this.passthrough});
|
||||
final List<String> positional;
|
||||
final Map<String, Object?> flags;
|
||||
final List<String> passthrough;
|
||||
|
||||
Map<String, Object?> toArgs() => {
|
||||
if (positional.isNotEmpty) 'positional': positional,
|
||||
if (flags.isNotEmpty) 'flags': flags,
|
||||
if (passthrough.isNotEmpty) 'passthrough': passthrough,
|
||||
};
|
||||
}
|
||||
|
||||
class _TailError extends _TailParseResult {
|
||||
const _TailError(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Walk [tail] splitting it into positionals, flags, and (everything
|
||||
/// after a lone `--`) passthrough.
|
||||
///
|
||||
/// Flag forms:
|
||||
/// --key=value → flags[key] = value
|
||||
/// --key value → flags[key] = value (value can't start with `--`)
|
||||
/// --key → flags[key] = true (boolean; next token is `--…` or end)
|
||||
///
|
||||
/// Anything not matching `--` is a positional. A bare `--` token
|
||||
/// terminates option parsing — everything after lands in passthrough.
|
||||
_TailParseResult _parseTail(List<String> tail) {
|
||||
final positional = <String>[];
|
||||
final flags = <String, Object?>{};
|
||||
final passthrough = <String>[];
|
||||
var i = 0;
|
||||
var inPassthrough = false;
|
||||
while (i < tail.length) {
|
||||
final t = tail[i];
|
||||
if (inPassthrough) {
|
||||
passthrough.add(t);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (t == '--') {
|
||||
inPassthrough = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (t.startsWith('--')) {
|
||||
final body = t.substring(2);
|
||||
if (body.isEmpty) {
|
||||
return const _TailError('empty flag: "--" with no name; use a bare "--" to terminate options');
|
||||
}
|
||||
final eq = body.indexOf('=');
|
||||
if (eq >= 0) {
|
||||
final key = body.substring(0, eq);
|
||||
final value = body.substring(eq + 1);
|
||||
if (!_isValidFlagName(key)) {
|
||||
return _TailError('invalid flag name: $key');
|
||||
}
|
||||
flags[key] = value;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// --key with no `=` — peek at next token.
|
||||
if (!_isValidFlagName(body)) {
|
||||
return _TailError('invalid flag name: $body');
|
||||
}
|
||||
final next = i + 1 < tail.length ? tail[i + 1] : null;
|
||||
if (next == null || next == '--' || next.startsWith('--')) {
|
||||
// Boolean flag — no value follows.
|
||||
flags[body] = true;
|
||||
i++;
|
||||
} else {
|
||||
flags[body] = next;
|
||||
i += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
positional.add(t);
|
||||
i++;
|
||||
}
|
||||
return _TailOk(positional: positional, flags: flags, passthrough: passthrough);
|
||||
}
|
||||
|
||||
/// Subsystems + verbs use the same shape — letters, digits, dot,
|
||||
/// hyphen, underscore. Reject anything else so a typo doesn't reach
|
||||
/// the dispatcher as a wire-shaped command id.
|
||||
bool _isValidIdentifier(String s) {
|
||||
if (s.isEmpty) return false;
|
||||
for (final code in s.codeUnits) {
|
||||
final isLetter = (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
|
||||
final isDigit = code >= 0x30 && code <= 0x39;
|
||||
final isOther = code == 0x2e /* . */ || code == 0x2d /* - */ || code == 0x5f /* _ */;
|
||||
if (!isLetter && !isDigit && !isOther) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Flag names are the same alphabet plus `.` is rare but allowed.
|
||||
/// Tighter than identifier here would just create false rejections.
|
||||
bool _isValidFlagName(String s) => _isValidIdentifier(s);
|
||||
|
||||
IpcResponse _err(String id, String message) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
/// Registers panel.* command handlers on a [DaemonDispatcher].
|
||||
///
|
||||
/// Verb list (T-119):
|
||||
/// panel.resize
|
||||
///
|
||||
/// Mirrors the keyboard-driven resize that landed in T-111
|
||||
/// (`lib/kernel/src/panels/drag_resize.dart`): the CLI verb is the
|
||||
/// other half of D-6's user/Claude parity for panel sizing.
|
||||
///
|
||||
/// Kept Flutter-free so test/daemon/ stays under `dart test` (not
|
||||
/// `flutter test`). The kernel-side bridge that wraps
|
||||
/// `LayoutArrangement` lives in
|
||||
/// `lib/src/daemon/panel_resizer_kernel.dart`.
|
||||
library;
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
/// Pluggable backend the CLI/IPC layer drives. Kernel-side
|
||||
/// implementation wraps `LayoutArrangement`; tests substitute an
|
||||
/// in-memory fake.
|
||||
abstract class PanelResizer {
|
||||
/// Apply an absolute pixel size to [slot]. Returns false when
|
||||
/// the slot is unknown.
|
||||
bool setSlotSize(String slot, double size);
|
||||
|
||||
/// Bump [slot]'s current size by a raw delta (px). Sign convention
|
||||
/// matches the drag/keyboard handlers from T-111 — positive deltas
|
||||
/// grow the slot from its natural edge; right-edge slots flip the
|
||||
/// sign internally. Returns false when the slot is unknown or has
|
||||
/// no current size.
|
||||
bool bumpSlotSize(String slot, double rawDelta);
|
||||
|
||||
/// Set the absolute editor / bottom-panel split ratio. The kernel
|
||||
/// clamps to its supported range (0.15..0.70 today).
|
||||
void setEditorRatio(double ratio);
|
||||
|
||||
/// Bump the editor split ratio by [delta] (additive, post-clamp).
|
||||
void bumpEditorRatio(double delta);
|
||||
|
||||
double? currentSlotSize(String slot);
|
||||
double get currentEditorRatio;
|
||||
}
|
||||
|
||||
/// Reserved slot name that routes to [PanelResizer.setEditorRatio] /
|
||||
/// [PanelResizer.bumpEditorRatio] instead of [PanelResizer.setSlotSize].
|
||||
const String editorSplitSlot = 'editor';
|
||||
|
||||
void registerPanelCommands(DaemonDispatcher d, PanelResizer resizer) {
|
||||
d.register('panel.resize', (req) => _resize(req, resizer));
|
||||
}
|
||||
|
||||
Future<IpcResponse> _resize(IpcRequest req, PanelResizer r) async {
|
||||
final view = _ResizeArgs.from(req.args);
|
||||
if (view.slot == null || view.slot!.isEmpty) {
|
||||
return _userErr(req.id, 'slot is required (e.g. "sidebar", "context", "$editorSplitSlot")');
|
||||
}
|
||||
if (!view.hasTo && !view.hasBy) {
|
||||
return _userErr(req.id, 'one of `to` (absolute) or `by` (delta) is required');
|
||||
}
|
||||
if (view.hasTo && view.hasBy) {
|
||||
return _userErr(req.id, 'pass only one of `to` and `by`');
|
||||
}
|
||||
final value = view.value;
|
||||
if (value == null) {
|
||||
return _userErr(req.id, '${view.hasTo ? "to" : "by"} must be numeric');
|
||||
}
|
||||
final slot = view.slot!;
|
||||
|
||||
if (slot == editorSplitSlot) {
|
||||
if (view.hasTo) {
|
||||
r.setEditorRatio(value);
|
||||
} else {
|
||||
r.bumpEditorRatio(value);
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'slot': slot,
|
||||
'ratio': r.currentEditorRatio,
|
||||
});
|
||||
}
|
||||
|
||||
final ok = view.hasTo ? r.setSlotSize(slot, value) : r.bumpSlotSize(slot, value);
|
||||
if (!ok) {
|
||||
return _notFound(req.id, 'no such slot: $slot');
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'slot': slot,
|
||||
'size': r.currentSlotSize(slot),
|
||||
});
|
||||
}
|
||||
|
||||
/// Tiny adapter that lifts `panel.resize` arguments out of either
|
||||
/// the direct call shape (`{slot: ..., to: ...}`) or the argv-
|
||||
/// translator shape (`{positional: [slot], flags: {to: '...'}}`).
|
||||
/// Until T-120 formalises a shared schema, individual commands carry
|
||||
/// the lift themselves.
|
||||
class _ResizeArgs {
|
||||
_ResizeArgs._({
|
||||
required this.slot,
|
||||
required this.hasTo,
|
||||
required this.hasBy,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String? slot;
|
||||
final bool hasTo;
|
||||
final bool hasBy;
|
||||
final double? value;
|
||||
|
||||
factory _ResizeArgs.from(Map<String, Object?> args) {
|
||||
String? slot;
|
||||
final rawSlot = args['slot'];
|
||||
if (rawSlot is String) slot = rawSlot;
|
||||
final positional = args['positional'];
|
||||
if (slot == null && positional is List && positional.isNotEmpty) {
|
||||
slot = positional.first.toString();
|
||||
}
|
||||
final flags = args['flags'];
|
||||
final flagsMap = flags is Map ? flags : const <Object?, Object?>{};
|
||||
final hasTo = args.containsKey('to') || flagsMap.containsKey('to');
|
||||
final hasBy = args.containsKey('by') || flagsMap.containsKey('by');
|
||||
final raw = args.containsKey('to')
|
||||
? args['to']
|
||||
: args.containsKey('by')
|
||||
? args['by']
|
||||
: flagsMap.containsKey('to')
|
||||
? flagsMap['to']
|
||||
: flagsMap['by'];
|
||||
return _ResizeArgs._(
|
||||
slot: slot,
|
||||
hasTo: hasTo,
|
||||
hasBy: hasBy,
|
||||
value: _coerceNum(raw),
|
||||
);
|
||||
}
|
||||
|
||||
static double? _coerceNum(Object? v) {
|
||||
if (v is num) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: message,
|
||||
hint: hint,
|
||||
),
|
||||
);
|
||||
|
||||
IpcResponse _notFound(String id, String message) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
/// Kernel-side [PanelResizer] that drives [LayoutArrangement] for
|
||||
/// the `panel.resize` IPC verb (T-119).
|
||||
///
|
||||
/// Lives in `lib/src/daemon/` next to the other dispatcher wiring,
|
||||
/// not `lib/kernel/`, because it bridges the Flutter-bound kernel
|
||||
/// state into the Flutter-free `panel_commands.dart` surface. Same
|
||||
/// rationale as the `_BusEventSink` in main.dart: the daemon layer
|
||||
/// owns the adapter, the kernel layer owns the data.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide/kernel/src/panels/drag_resize.dart' show bumpedSlotSize;
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:clide/src/daemon/panel_commands.dart';
|
||||
|
||||
class ArrangementPanelResizer implements PanelResizer {
|
||||
ArrangementPanelResizer(this._a);
|
||||
|
||||
final LayoutArrangement _a;
|
||||
|
||||
@override
|
||||
bool setSlotSize(String slot, double size) {
|
||||
final id = SlotId(slot);
|
||||
if (_a.sizeOf(id) == null) return false;
|
||||
_a.setSize(id, size);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
bool bumpSlotSize(String slot, double rawDelta) {
|
||||
final id = SlotId(slot);
|
||||
final current = _a.sizeOf(id);
|
||||
if (current == null) return false;
|
||||
_a.setSize(id, bumpedSlotSize(slot: id, current: current, rawDelta: rawDelta));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void setEditorRatio(double ratio) => _a.setEditorRatio(ratio);
|
||||
|
||||
@override
|
||||
void bumpEditorRatio(double delta) => _a.setEditorRatio(_a.editorRatio + delta);
|
||||
|
||||
@override
|
||||
double? currentSlotSize(String slot) => _a.sizeOf(SlotId(slot));
|
||||
|
||||
@override
|
||||
double get currentEditorRatio => _a.editorRatio;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/// MCP server for `/ide`-compatible Claude Code integrations
|
||||
/// (T-99 / T-130, per D-68 + D-73).
|
||||
///
|
||||
/// **Transport:** HTTP + Server-Sent Events (D-73). The server binds
|
||||
/// an HTTP listener on a random localhost port at startup and writes
|
||||
/// a discovery file at `$HOME/.claude/ide/<pid>.lock` so Claude
|
||||
/// Code's `/ide` command can find us.
|
||||
///
|
||||
/// **Protocol:** JSON-RPC 2.0 carried over SSE.
|
||||
/// - `GET /sse` opens a long-lived stream. The server pushes
|
||||
/// JSON-RPC responses + notifications as `data: <json>\n\n`
|
||||
/// events.
|
||||
/// - `POST /messages?sessionId=<id>` accepts a JSON-RPC request
|
||||
/// and returns 202; the response lands on the matching session's
|
||||
/// SSE stream.
|
||||
///
|
||||
/// **Surface:** the two minimum tools per D-68 — `mcp__ide__getDiagnostics`
|
||||
/// and `mcp__ide__executeCode`. Both stubbed today; real
|
||||
/// implementations land as follow-up tickets once the analyzer
|
||||
/// integration is ready and we have a clide-side eval surface.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
|
||||
/// One connected SSE client. Each session has its own response
|
||||
/// stream; POST /messages routes back to the right one via the
|
||||
/// `sessionId` query param.
|
||||
class _McpSession {
|
||||
_McpSession(this.id, this.response);
|
||||
final String id;
|
||||
final HttpResponse response;
|
||||
bool closed = false;
|
||||
|
||||
void send(Map<String, Object?> payload) {
|
||||
if (closed) return;
|
||||
try {
|
||||
response.write('data: ${jsonEncode(payload)}\n\n');
|
||||
} catch (_) {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
await response.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP + SSE MCP server. Lifecycle mirrors [IpcServer]: `start()`
|
||||
/// binds + writes the discovery file; `stop()` unbinds + removes it.
|
||||
class McpServer {
|
||||
McpServer({
|
||||
required this.workspaceRoot,
|
||||
required this.log,
|
||||
this.discoveryDirOverride,
|
||||
this.bindHost = '127.0.0.1',
|
||||
this.bindPort = 0,
|
||||
});
|
||||
|
||||
/// Workspace root reported in the discovery file. Helps Claude
|
||||
/// Code show "which clide is this" when multiple are running.
|
||||
final String workspaceRoot;
|
||||
final Logger log;
|
||||
|
||||
/// Override of `$HOME/.claude/ide/` for tests. Production code
|
||||
/// passes null; tests inject a tempdir.
|
||||
final String? discoveryDirOverride;
|
||||
|
||||
/// Bind host. localhost-only by default per D-73 (no remote
|
||||
/// access; the threat model matches D-71's `0600`).
|
||||
final String bindHost;
|
||||
|
||||
/// Bind port. 0 ⇒ kernel picks a random free port.
|
||||
final int bindPort;
|
||||
|
||||
HttpServer? _http;
|
||||
String? _lockFile;
|
||||
int? _port;
|
||||
final Map<String, _McpSession> _sessions = {};
|
||||
int _sessionCounter = 0;
|
||||
|
||||
bool get isRunning => _http != null;
|
||||
int? get port => _port;
|
||||
String? get lockFilePath => _lockFile;
|
||||
|
||||
Future<void> start() async {
|
||||
if (isRunning) return;
|
||||
final server = await HttpServer.bind(bindHost, bindPort);
|
||||
_http = server;
|
||||
_port = server.port;
|
||||
_lockFile = await _writeDiscoveryFile();
|
||||
server.listen(_route, onError: (Object e, StackTrace st) {
|
||||
log.warn('mcp', 'http error: $e');
|
||||
});
|
||||
log.info('mcp', 'MCP/SSE listening at http://$bindHost:${server.port} (workspace: $workspaceRoot)');
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final s = _http;
|
||||
if (s == null) return;
|
||||
_http = null;
|
||||
_port = null;
|
||||
for (final session in List<_McpSession>.from(_sessions.values)) {
|
||||
await session.close();
|
||||
}
|
||||
_sessions.clear();
|
||||
await s.close(force: true);
|
||||
final lock = _lockFile;
|
||||
_lockFile = null;
|
||||
if (lock != null) {
|
||||
try {
|
||||
final f = File(lock);
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (e) {
|
||||
log.warn('mcp', 'failed to unlink lock $lock: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- routing --------------------------------------------------------------
|
||||
|
||||
Future<void> _route(HttpRequest req) async {
|
||||
final path = req.uri.path;
|
||||
if (path == '/sse' && req.method == 'GET') {
|
||||
await _openSseStream(req);
|
||||
return;
|
||||
}
|
||||
if (path == '/messages' && req.method == 'POST') {
|
||||
await _receivePost(req);
|
||||
return;
|
||||
}
|
||||
req.response.statusCode = HttpStatus.notFound;
|
||||
await req.response.close();
|
||||
}
|
||||
|
||||
Future<void> _openSseStream(HttpRequest req) async {
|
||||
final sessionId = 's${_sessionCounter++}';
|
||||
req.response.headers.contentType = ContentType('text', 'event-stream');
|
||||
req.response.headers.set('Cache-Control', 'no-cache');
|
||||
req.response.headers.set('Connection', 'keep-alive');
|
||||
req.response.headers.set('X-Accel-Buffering', 'no');
|
||||
req.response.bufferOutput = false;
|
||||
final session = _McpSession(sessionId, req.response);
|
||||
_sessions[sessionId] = session;
|
||||
// Initial endpoint event tells the client where to POST.
|
||||
req.response.write('event: endpoint\n');
|
||||
req.response.write('data: /messages?sessionId=$sessionId\n\n');
|
||||
// Keep alive until the client closes.
|
||||
try {
|
||||
await req.response.done;
|
||||
} catch (_) {}
|
||||
session.closed = true;
|
||||
_sessions.remove(sessionId);
|
||||
}
|
||||
|
||||
Future<void> _receivePost(HttpRequest req) async {
|
||||
final sessionId = req.uri.queryParameters['sessionId'];
|
||||
final session = sessionId == null ? null : _sessions[sessionId];
|
||||
if (session == null) {
|
||||
req.response.statusCode = HttpStatus.notFound;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final body = await utf8.decodeStream(req);
|
||||
Map<String, Object?>? msg;
|
||||
try {
|
||||
msg = jsonDecode(body) as Map<String, Object?>;
|
||||
} catch (_) {
|
||||
req.response.statusCode = HttpStatus.badRequest;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
// Acknowledge the POST immediately; the actual JSON-RPC reply
|
||||
// travels back over the SSE channel.
|
||||
req.response.statusCode = HttpStatus.accepted;
|
||||
await req.response.close();
|
||||
final reply = await _dispatchJsonRpc(msg);
|
||||
if (reply != null) session.send(reply);
|
||||
}
|
||||
|
||||
// -- JSON-RPC handlers ---------------------------------------------------
|
||||
|
||||
Future<Map<String, Object?>?> _dispatchJsonRpc(Map<String, Object?> msg) async {
|
||||
final id = msg['id'];
|
||||
final method = msg['method'] as String?;
|
||||
if (method == null) {
|
||||
// Notifications without a method are ignored.
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final result = await _handleMethod(method, msg['params'] as Map<String, Object?>?);
|
||||
if (id == null) return null; // notification — no reply
|
||||
return {'jsonrpc': '2.0', 'id': id, 'result': result};
|
||||
} catch (e, st) {
|
||||
log.warn('mcp', 'method $method threw: $e');
|
||||
log.debug('mcp', '$st');
|
||||
return {
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'error': {'code': -32000, 'message': '$e'},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Object?> _handleMethod(String method, Map<String, Object?>? params) async {
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return {
|
||||
'protocolVersion': '2024-11-05',
|
||||
'capabilities': {
|
||||
'tools': {'listChanged': false},
|
||||
},
|
||||
'serverInfo': {'name': 'clide', 'version': 'dev'},
|
||||
};
|
||||
case 'tools/list':
|
||||
return {
|
||||
'tools': [
|
||||
{
|
||||
'name': 'mcp__ide__getDiagnostics',
|
||||
'description': 'Return diagnostics from the open editor (stubbed).',
|
||||
'inputSchema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'uri': {'type': 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
'name': 'mcp__ide__executeCode',
|
||||
'description': 'Execute a code cell in clide (stubbed; clide has no eval surface yet).',
|
||||
'inputSchema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'code': {'type': 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
case 'tools/call':
|
||||
final name = (params?['name'] as String?) ?? '';
|
||||
switch (name) {
|
||||
case 'mcp__ide__getDiagnostics':
|
||||
return {
|
||||
'content': [
|
||||
{'type': 'text', 'text': '[]'},
|
||||
],
|
||||
};
|
||||
case 'mcp__ide__executeCode':
|
||||
return {
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'executeCode is not implemented in clide today.'},
|
||||
],
|
||||
'isError': true,
|
||||
};
|
||||
default:
|
||||
throw StateError('unknown tool: $name');
|
||||
}
|
||||
default:
|
||||
throw StateError('unknown method: $method');
|
||||
}
|
||||
}
|
||||
|
||||
// -- discovery file -------------------------------------------------------
|
||||
|
||||
Future<String> _writeDiscoveryFile() async {
|
||||
final dir = discoveryDirOverride ?? '${Platform.environment['HOME'] ?? '/tmp'}/.claude/ide';
|
||||
final dirHandle = Directory(dir);
|
||||
if (!dirHandle.existsSync()) {
|
||||
dirHandle.createSync(recursive: true);
|
||||
}
|
||||
final path = '$dir/$pid.lock';
|
||||
final body = jsonEncode({
|
||||
'pid': pid,
|
||||
'workspace': workspaceRoot,
|
||||
'transport': 'sse',
|
||||
'url': 'http://$bindHost:$_port/sse',
|
||||
});
|
||||
File(path).writeAsStringSync(body);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
+55
-17
@@ -1,21 +1,59 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// Resolve the daemon unix-socket path.
|
||||
/// Resolve the per-workspace Unix-domain socket path served by the
|
||||
/// running clide app. Per D-70:
|
||||
///
|
||||
/// Precedence (highest first):
|
||||
/// 1. `CLIDE_SOCKET_PATH` — explicit override. Used by tests that
|
||||
/// run multiple daemons in parallel and by power users who want
|
||||
/// their own layout.
|
||||
/// 2. `$XDG_RUNTIME_DIR/clide-<user>.sock` — Linux default; the
|
||||
/// per-user tmpfs lives exactly for this kind of short-lived
|
||||
/// socket and is auto-cleaned on logout.
|
||||
/// 3. `/tmp/clide-<user>.sock` — fallback for environments without
|
||||
/// `XDG_RUNTIME_DIR`.
|
||||
String defaultSocketPath() {
|
||||
final override = Platform.environment['CLIDE_SOCKET_PATH'];
|
||||
if (override != null && override.isNotEmpty) return override;
|
||||
final xdg = Platform.environment['XDG_RUNTIME_DIR'];
|
||||
final user = Platform.environment['USER'] ?? 'anon';
|
||||
final base = (xdg != null && xdg.isNotEmpty) ? xdg : '/tmp';
|
||||
return '$base/clide-$user.sock';
|
||||
/// Linux: `$XDG_RUNTIME_DIR/clide/<hash>.sock`
|
||||
/// macOS: `$HOME/Library/Caches/clide/<hash>.sock`
|
||||
///
|
||||
/// The C `clide` client and any other consumer derive the same path
|
||||
/// from the same workspace root, so server + client always agree
|
||||
/// without configuration.
|
||||
String workspaceSocketPath(String workspaceRoot) {
|
||||
final dir = socketDirectory();
|
||||
return '$dir/${_hash(workspaceRoot)}.sock';
|
||||
}
|
||||
|
||||
/// Parent directory that holds every per-workspace socket for this
|
||||
/// user. Created with `0700` on bind (see D-71). Exposed separately
|
||||
/// so the server can prepare/perm-fix the directory before binding.
|
||||
String socketDirectory() {
|
||||
if (Platform.isMacOS) {
|
||||
final home = Platform.environment['HOME'] ?? '/tmp';
|
||||
return '$home/Library/Caches/clide';
|
||||
}
|
||||
final xdg = Platform.environment['XDG_RUNTIME_DIR'];
|
||||
final base = (xdg != null && xdg.isNotEmpty) ? xdg : '/tmp';
|
||||
return '$base/clide';
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash of [s] as a 16-char lower-case hex string.
|
||||
/// The C client (T-126) reproduces the same algorithm byte-for-byte
|
||||
/// so server + client always agree on socket path. Not cryptographic
|
||||
/// — D-70 explains why one isn't needed here. The algorithm:
|
||||
///
|
||||
/// h = 0xcbf29ce484222325 // FNV offset basis
|
||||
/// for each byte b in utf-8(s):
|
||||
/// h = (h xor b) * 0x100000001b3 mod 2^64 // FNV prime, 64-bit wrap
|
||||
///
|
||||
/// Reference: <http://isthe.com/chongo/tech/comp/fnv/> — FNV-1a 64-bit.
|
||||
String fnv1a64Hex(String s) {
|
||||
// Desktop-only (the IPC server is desktop-only per D-56). Dart VM
|
||||
// ints are 64-bit; arithmetic wraps modulo 2^64 naturally.
|
||||
var h = 0xcbf29ce484222325;
|
||||
const prime = 0x100000001b3;
|
||||
final bytes = utf8.encode(s);
|
||||
for (final b in bytes) {
|
||||
h ^= b;
|
||||
h = h * prime; // wraps mod 2^64 on the VM (signed int64)
|
||||
}
|
||||
// Dart's `int` is signed 64-bit on the VM; once the high bit lights
|
||||
// up, `toRadixString` would emit a leading minus. Split into two
|
||||
// unsigned 32-bit halves (>>> is logical shift) and concatenate.
|
||||
final hi = (h >>> 32) & 0xffffffff;
|
||||
final lo = h & 0xffffffff;
|
||||
return '${hi.toRadixString(16).padLeft(8, '0')}${lo.toRadixString(16).padLeft(8, '0')}';
|
||||
}
|
||||
|
||||
String _hash(String s) => fnv1a64Hex(s);
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/cli/argv_dispatch.dart';
|
||||
import 'package:clide/src/cli/argv_to_request.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/paths.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
|
||||
/// Unix-domain IPC server for the running Flutter app.
|
||||
///
|
||||
/// First slice of T-99 (D-56 path a). One server per workspace —
|
||||
/// the socket path is derived from the workspace root per D-70. File
|
||||
/// perms gate access per D-71 (`0600` socket, `0700` parent). The
|
||||
/// server's accept loop is multi-connection; dispatch through the
|
||||
/// supplied [DaemonDispatcher] is serial on the main isolate per
|
||||
/// D-72. Per-handler isolate offload is the dispatcher / handler's
|
||||
/// concern, not this layer's.
|
||||
class IpcServer {
|
||||
IpcServer({
|
||||
required this.dispatcher,
|
||||
required this.workspaceRoot,
|
||||
required this.log,
|
||||
this.events,
|
||||
this.replayDepth = 16,
|
||||
});
|
||||
|
||||
final DaemonDispatcher dispatcher;
|
||||
final String workspaceRoot;
|
||||
final Logger log;
|
||||
|
||||
/// Bus the server subscribes to for events forwarded to
|
||||
/// `clide tail --events` subscribers. Optional — when null, the
|
||||
/// tail handler still accepts subscriptions but never gets events
|
||||
/// (useful in tests that don't need the full kernel wiring).
|
||||
final DaemonBus? events;
|
||||
|
||||
/// Per-subsystem replay-buffer depth (D-6: default 16). New
|
||||
/// subscribers receive up to this many recent matching events on
|
||||
/// connect so they don't miss effects emitted just before they
|
||||
/// subscribed.
|
||||
final int replayDepth;
|
||||
|
||||
ServerSocket? _socket;
|
||||
String? _socketPath;
|
||||
final List<Socket> _clients = [];
|
||||
StreamSubscription<Socket>? _accepts;
|
||||
|
||||
// Event streaming (T-129).
|
||||
StreamSubscription<DaemonEvent>? _busSub;
|
||||
|
||||
/// Subscribers: client socket → filter (`*` or a subsystem name).
|
||||
/// A connection enters this map after it sends `tail --events`.
|
||||
final Map<Socket, String> _subscribers = {};
|
||||
|
||||
/// Per-subsystem ring buffer of recent events for replay.
|
||||
final Map<String, Queue<IpcEvent>> _replay = {};
|
||||
|
||||
String get socketPath => _socketPath ?? workspaceSocketPath(workspaceRoot);
|
||||
bool get isRunning => _socket != null;
|
||||
|
||||
/// Bind the socket and start accepting connections. Idempotent —
|
||||
/// a second [start] on the same instance is a no-op.
|
||||
///
|
||||
/// Stale sockets left from a crashed previous clide are detected
|
||||
/// and unlinked before binding. If a *live* clide is already
|
||||
/// listening on the path the bind throws — the caller is the
|
||||
/// stale-vs-live arbiter (per D-72 there's one server per
|
||||
/// workspace; a colliding live process means a real conflict).
|
||||
Future<void> start() async {
|
||||
if (isRunning) return;
|
||||
final path = workspaceSocketPath(workspaceRoot);
|
||||
await _prepareParentDir(path);
|
||||
await _unlinkStale(path);
|
||||
final socket = await ServerSocket.bind(
|
||||
InternetAddress(path, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
try {
|
||||
await _chmod(path, 0x180); // 0o600
|
||||
} catch (e, st) {
|
||||
// chmod failure is fatal — D-71 says perms are the gate.
|
||||
await socket.close();
|
||||
log.error('ipc', 'chmod 0600 failed on $path', error: e, stackTrace: st);
|
||||
rethrow;
|
||||
}
|
||||
_socket = socket;
|
||||
_socketPath = path;
|
||||
_accepts = socket.listen(_onClient, onError: (Object e, StackTrace st) {
|
||||
log.error('ipc', 'accept loop error', error: e, stackTrace: st);
|
||||
});
|
||||
// Subscribe to the bus so we can populate the replay ring AND
|
||||
// fan out to live `tail --events` subscribers. Idempotent —
|
||||
// we only attach when a bus is supplied.
|
||||
final bus = events;
|
||||
if (bus != null) {
|
||||
_busSub = bus.on<DaemonEvent>().listen(_onBusEvent);
|
||||
}
|
||||
log.info('ipc', 'IPC server listening at $path');
|
||||
}
|
||||
|
||||
/// Close the listening socket, kill any in-flight client
|
||||
/// connections, and remove the socket file from disk.
|
||||
Future<void> stop() async {
|
||||
final s = _socket;
|
||||
final path = _socketPath;
|
||||
if (s == null) return;
|
||||
_socket = null;
|
||||
_socketPath = null;
|
||||
await _busSub?.cancel();
|
||||
_busSub = null;
|
||||
_subscribers.clear();
|
||||
_replay.clear();
|
||||
await _accepts?.cancel();
|
||||
_accepts = null;
|
||||
for (final c in List<Socket>.from(_clients)) {
|
||||
try {
|
||||
await c.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
_clients.clear();
|
||||
await s.close();
|
||||
if (path != null) {
|
||||
try {
|
||||
final f = File(path);
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'failed to unlink $path: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onClient(Socket client) {
|
||||
_clients.add(client);
|
||||
final buffer = StringBuffer();
|
||||
late StreamSubscription<List<int>> sub;
|
||||
sub = client.listen(
|
||||
(chunk) async {
|
||||
buffer.write(utf8.decode(chunk, allowMalformed: true));
|
||||
var idx = buffer.toString().indexOf('\n');
|
||||
while (idx >= 0) {
|
||||
final raw = buffer.toString().substring(0, idx);
|
||||
// Trim consumed bytes by rebuilding the buffer with the
|
||||
// tail — StringBuffer can't slice in place.
|
||||
final tail = buffer.toString().substring(idx + 1);
|
||||
buffer.clear();
|
||||
buffer.write(tail);
|
||||
await _handleLine(client, raw);
|
||||
idx = buffer.toString().indexOf('\n');
|
||||
}
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
},
|
||||
onDone: () {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
sub.cancel();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLine(Socket client, String line) async {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
IpcResponse response;
|
||||
try {
|
||||
final msg = IpcMessage.decode(trimmed);
|
||||
if (msg is! IpcRequest) {
|
||||
response = IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: 'expected request, got ${msg.runtimeType}',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Peel off the `_argv` envelope at the server layer so the
|
||||
// streaming check sees the unwrapped command (T-129). Plain
|
||||
// typed requests skip this path.
|
||||
var req = msg;
|
||||
if (req.cmd == argvSentinelCmd) {
|
||||
final result = unwrapArgvRequest(req);
|
||||
if (result is ArgvError) {
|
||||
response = result.response;
|
||||
// Fall through to write below.
|
||||
try {
|
||||
client.write('${response.encode()}\n');
|
||||
await client.flush();
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'client write failed: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
req = (result as ArgvParsed).request;
|
||||
}
|
||||
if (_isTailSubscribe(req)) {
|
||||
// Long-lived subscription branch (T-129). Send the streaming
|
||||
// ack, replay matching ring buffer entries, register the
|
||||
// client. The connection stays open until the client closes.
|
||||
await _enterStreamingMode(client, req);
|
||||
return;
|
||||
}
|
||||
response = await dispatcher.dispatch(req);
|
||||
}
|
||||
} on FormatException catch (e) {
|
||||
response = IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: 'malformed request: ${e.message}',
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
log.error('ipc', 'dispatch threw', error: e, stackTrace: st);
|
||||
response = IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'internal error: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
client.write('${response.encode()}\n');
|
||||
await client.flush();
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'client write failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prepareParentDir(String socketPath) async {
|
||||
final dir = Directory(File(socketPath).parent.path);
|
||||
if (!dir.existsSync()) {
|
||||
dir.createSync(recursive: true);
|
||||
}
|
||||
try {
|
||||
await _chmod(dir.path, 0x1c0); // 0o700
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'chmod 0700 on ${dir.path} failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unlinkStale(String path) async {
|
||||
final f = File(path);
|
||||
if (!f.existsSync()) return;
|
||||
// Probe: try connecting. If something answers, refuse to bind.
|
||||
try {
|
||||
final test = await Socket.connect(
|
||||
InternetAddress(path, type: InternetAddressType.unix),
|
||||
0,
|
||||
).timeout(const Duration(milliseconds: 200));
|
||||
await test.close();
|
||||
throw StateError('another clide IPC server is already listening on $path');
|
||||
} on SocketException {
|
||||
// No live listener — safe to unlink the stale node.
|
||||
f.deleteSync();
|
||||
} on TimeoutException {
|
||||
throw StateError('socket $path exists and is unresponsive — refusing to clobber');
|
||||
}
|
||||
}
|
||||
|
||||
// -- event streaming (T-129) ----------------------------------------------
|
||||
|
||||
/// Recognise the `tail --events [--filter X]` subscription
|
||||
/// request that the argv translator (T-125) produces.
|
||||
bool _isTailSubscribe(IpcRequest req) {
|
||||
if (req.cmd != 'tail') return false;
|
||||
final flags = req.args['flags'];
|
||||
return flags is Map && flags['events'] == true;
|
||||
}
|
||||
|
||||
Future<void> _enterStreamingMode(Socket client, IpcRequest req) async {
|
||||
final flags = req.args['flags'] as Map?;
|
||||
final filter = (flags?['filter'] as String?) ?? '*';
|
||||
// Streaming ack — `data.streaming: true` tells the C client to
|
||||
// loop-read instead of exiting after one response.
|
||||
final ack = IpcResponse.ok(id: req.id, data: {'streaming': true, 'filter': filter});
|
||||
try {
|
||||
client.write('${ack.encode()}\n');
|
||||
await client.flush();
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'streaming ack write failed: $e');
|
||||
return;
|
||||
}
|
||||
// Replay matching events from the ring.
|
||||
final replay = _replayFor(filter);
|
||||
for (final ev in replay) {
|
||||
if (!_sendEvent(client, ev)) return;
|
||||
}
|
||||
_subscribers[client] = filter;
|
||||
}
|
||||
|
||||
Iterable<IpcEvent> _replayFor(String filter) {
|
||||
if (filter == '*') {
|
||||
// Flatten everything in arrival order. Per-subsystem rings
|
||||
// preserve order within a subsystem; across subsystems the
|
||||
// ordering is best-effort (interleaved-by-subsystem). Good
|
||||
// enough for "what just happened".
|
||||
return _replay.values.expand((q) => q);
|
||||
}
|
||||
return _replay[filter] ?? const [];
|
||||
}
|
||||
|
||||
void _onBusEvent(DaemonEvent e) {
|
||||
final ev = IpcEvent(
|
||||
subsystem: e.subsystem,
|
||||
kind: e.kind,
|
||||
data: e.data,
|
||||
timestamp: e.ts,
|
||||
);
|
||||
// Push to replay ring.
|
||||
final ring = _replay.putIfAbsent(e.subsystem, () => Queue<IpcEvent>());
|
||||
ring.addLast(ev);
|
||||
while (ring.length > replayDepth) {
|
||||
ring.removeFirst();
|
||||
}
|
||||
// Fan out to live subscribers whose filter matches.
|
||||
final stale = <Socket>[];
|
||||
for (final entry in _subscribers.entries) {
|
||||
final filter = entry.value;
|
||||
if (filter != '*' && filter != e.subsystem) continue;
|
||||
if (!_sendEvent(entry.key, ev)) {
|
||||
stale.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final s in stale) {
|
||||
_subscribers.remove(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an event line to [client]. Returns false on failure, which
|
||||
/// the caller uses to drop the subscriber. We deliberately don't
|
||||
/// await `flush` here — back-pressure handling per D-72: if the
|
||||
/// socket's write buffer is full, dart:io's Socket.write enqueues
|
||||
/// in-memory, and the kernel pushes through as it can. If the
|
||||
/// client is genuinely gone the write throws or onDone fires and
|
||||
/// the subscriber gets removed via _onClient's onDone.
|
||||
bool _sendEvent(Socket client, IpcEvent ev) {
|
||||
try {
|
||||
client.write('${ev.encode()}\n');
|
||||
return true;
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'subscriber write failed (dropping): $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -- internals ------------------------------------------------------------
|
||||
|
||||
/// `chmod` via `chmod(1)` because dart:io doesn't expose the
|
||||
/// syscall on unix. Cheap; only runs at start/stop.
|
||||
Future<void> _chmod(String path, int modeBits) async {
|
||||
final octal = modeBits.toRadixString(8).padLeft(3, '0');
|
||||
final r = await Process.run('chmod', [octal, path]);
|
||||
if (r.exitCode != 0) {
|
||||
throw ProcessException('chmod', [octal, path], r.stderr.toString(), r.exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,12 +105,14 @@ class EscapeParser {
|
||||
// 'P'.charCode: _unsupportedHandler, // Sixel
|
||||
// 'c'.charCode: _unsupportedHandler,
|
||||
// '#'.charCode: _unsupportedHandler,
|
||||
'('.charCode: _escHandleDesignateCharset0, // SCS - G0
|
||||
')'.charCode: _escHandleDesignateCharset1, // SCS - G1
|
||||
// '*'.charCode: _voidHandler(1), // TODO: G2 (vt220)
|
||||
// '+'.charCode: _voidHandler(1), // TODO: G3 (vt220)
|
||||
'>'.charCode: _escHandleResetAppKeypadMode, // TODO: Normal Keypad
|
||||
'='.charCode: _escHandleSetAppKeypadMode, // TODO: Application Keypad
|
||||
'('.charCode: _escHandleDesignateCharset0, // SCS — G0
|
||||
')'.charCode: _escHandleDesignateCharset1, // SCS — G1
|
||||
// G2 (`ESC *`) and G3 (`ESC +`) charset designators are VT220+
|
||||
// sequences we don't honour — no consumer in clide selects past
|
||||
// G0/G1. Sequences pass through as no-ops (one trailing byte is
|
||||
// consumed by the default parser).
|
||||
'>'.charCode: _escHandleResetAppKeypadMode,
|
||||
'='.charCode: _escHandleSetAppKeypadMode,
|
||||
});
|
||||
|
||||
/// `ESC 7` Save Cursor (DECSC)
|
||||
|
||||
@@ -88,7 +88,9 @@ class Keytab {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: support VT52
|
||||
// VT52 mode (ANSI=false) records are skipped — clide only emits
|
||||
// ANSI/VT100+ sequences. No consumer asks for VT52 today; if one
|
||||
// does, this branch is where the lookup would flip.
|
||||
if (record.ansi == false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
|
||||
|
||||
import 'package:clide/src/terminal/src/core/input/keys.dart';
|
||||
import 'package:clide/src/terminal/src/core/mouse/button.dart';
|
||||
import 'package:clide/src/terminal/src/core/mouse/button_state.dart';
|
||||
import 'package:clide/src/terminal/src/terminal.dart';
|
||||
import 'package:clide/src/terminal/src/ui/controller.dart';
|
||||
import 'package:clide/src/terminal/src/ui/cursor_type.dart';
|
||||
@@ -155,13 +157,23 @@ class TerminalViewState extends State<TerminalView> {
|
||||
final lh = renderTerminal.lineHeight;
|
||||
if (lh <= 0) return;
|
||||
final lines = (event.scrollDelta.dy / lh).round().clamp(-5, 5);
|
||||
// Always send PgUp/PgDown for scroll — the mouse-escape-sequence
|
||||
// path tends to be a no-op in TUI apps (claude, vim) that capture
|
||||
// mouse for other purposes. PgUp/PgDown is the universal scroll.
|
||||
for (var i = 0; i < lines.abs(); i++) {
|
||||
widget.terminal.keyInput(
|
||||
lines < 0 ? TerminalKey.pageUp : TerminalKey.pageDown,
|
||||
);
|
||||
if (lines == 0) return;
|
||||
final button = lines > 0 ? TerminalMouseButton.wheelDown : TerminalMouseButton.wheelUp;
|
||||
final count = lines.abs();
|
||||
// If the inner program has declared a mouse mode that reports
|
||||
// scroll (?1000h / ?1002h / ?1003h / +?1006h for SGR), forward
|
||||
// proper xterm wheel-button escapes so the TUI can react (vim
|
||||
// mouse=a scroll, less line-by-line, htop highlight). Otherwise
|
||||
// fall back to PgUp/PgDown so plain shells still scroll (T-74).
|
||||
if (widget.terminal.mouseMode.reportScroll) {
|
||||
for (var i = 0; i < count; i++) {
|
||||
renderTerminal.mouseEvent(button, TerminalMouseButtonState.down, event.localPosition);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final key = lines < 0 ? TerminalKey.pageUp : TerminalKey.pageDown;
|
||||
for (var i = 0; i < count; i++) {
|
||||
widget.terminal.keyInput(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +372,13 @@ class TerminalViewState extends State<TerminalView> {
|
||||
return resultOverride;
|
||||
}
|
||||
|
||||
// ShortcutManager.handleKeypress is @protected — it's only public
|
||||
// for the Shortcuts widget to call internally. We own our own
|
||||
// ShortcutManager because the terminal has its own keybinding
|
||||
// surface (defaultTerminalShortcuts: Ctrl+C → kill, Tab handoff
|
||||
// rules, etc.) that needs to fire BEFORE the app's Shortcuts
|
||||
// ancestor. Wrapping in a Shortcuts widget would invert that.
|
||||
// T-107 approved leaving this suppression with an inline reason.
|
||||
// ignore: invalid_use_of_protected_member
|
||||
final shortcutResult = _shortcutManager.handleKeypress(
|
||||
focusNode.context!,
|
||||
|
||||
@@ -241,42 +241,27 @@ class CustomTextEditState extends State<CustomTextEdit> with TextInputClient {
|
||||
|
||||
@override
|
||||
void performAction(TextInputAction action) {
|
||||
// print('performAction $action');
|
||||
widget.onAction(action);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateFloatingCursor(RawFloatingCursorPoint point) {
|
||||
// print('updateFloatingCursor $point');
|
||||
}
|
||||
void updateFloatingCursor(RawFloatingCursorPoint point) {}
|
||||
|
||||
@override
|
||||
void showAutocorrectionPromptRect(int start, int end) {
|
||||
// print('showAutocorrectionPromptRect');
|
||||
}
|
||||
void showAutocorrectionPromptRect(int start, int end) {}
|
||||
|
||||
@override
|
||||
void connectionClosed() {
|
||||
// print('connectionClosed');
|
||||
}
|
||||
void connectionClosed() {}
|
||||
|
||||
@override
|
||||
void performPrivateCommand(String action, Map<String, dynamic> data) {
|
||||
// print('performPrivateCommand $action');
|
||||
}
|
||||
void performPrivateCommand(String action, Map<String, dynamic> data) {}
|
||||
|
||||
@override
|
||||
void insertTextPlaceholder(Size size) {
|
||||
// print('insertTextPlaceholder');
|
||||
}
|
||||
void insertTextPlaceholder(Size size) {}
|
||||
|
||||
@override
|
||||
void removeTextPlaceholder() {
|
||||
// print('removeTextPlaceholder');
|
||||
}
|
||||
void removeTextPlaceholder() {}
|
||||
|
||||
@override
|
||||
void showToolbar() {
|
||||
// print('showToolbar');
|
||||
}
|
||||
void showToolbar() {}
|
||||
}
|
||||
|
||||
+9
-4
@@ -29,7 +29,6 @@ import 'dart:ffi' as ffi;
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
import 'kernel/kernel.dart';
|
||||
import 'src/pty/ffi/libc.dart' as libc;
|
||||
import 'kernel/src/ipc/in_process.dart';
|
||||
import 'src/daemon/pane_commands.dart';
|
||||
import 'src/ipc/envelope.dart';
|
||||
import 'src/panes/event_sink.dart';
|
||||
@@ -325,19 +324,25 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
Future<void> _runTerminalTests(Toolchain tc, String workDir) async {
|
||||
_say('--- terminal ---');
|
||||
|
||||
// Test PTY via InProcessClient — same path as the real app.
|
||||
// Test PTY via the dispatcher directly — skip the socket
|
||||
// round-trip for the smoke test since it adds setup without
|
||||
// testing anything new for pane.spawn. The real app's path is
|
||||
// covered by the IPC server + client tests under test/ipc/.
|
||||
await _testAsync('pane.spawn via IPC', () async {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final bus = DaemonBus();
|
||||
final eventSink = _TestEventSink(bus);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
final ipc = InProcessClient(log: Logger(), events: bus, dispatcher: dispatcher);
|
||||
|
||||
Future<IpcResponse> dispatch(String cmd, Map<String, Object?> args) {
|
||||
return dispatcher.dispatch(IpcRequest(id: 'tm-${DateTime.now().microsecondsSinceEpoch}', cmd: cmd, args: args));
|
||||
}
|
||||
|
||||
// Spawn a pane running /bin/echo.
|
||||
// Use interactive shell — fast-exiting commands lose output on macOS
|
||||
// because the slave closes before we can read the master.
|
||||
final spawnResp = await ipc.request('pane.spawn', args: {
|
||||
final spawnResp = await dispatch('pane.spawn', {
|
||||
'argv': [tc.shell],
|
||||
'kind': 'terminal',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart' show clideName;
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/kernel/src/window_controls.dart';
|
||||
@@ -71,7 +72,7 @@ class _CenterContent extends StatelessWidget {
|
||||
final parts = <String>[];
|
||||
if (project != null) parts.add(project!);
|
||||
if (branch != null) parts.add(branch!);
|
||||
final label = parts.isEmpty ? 'clide' : parts.join(' > ');
|
||||
final label = parts.isEmpty ? clideName : parts.join(' > ');
|
||||
return Center(
|
||||
child: ClideText(label, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
);
|
||||
|
||||
@@ -56,8 +56,14 @@ const String clideMonoFamily = 'JetBrainsMono';
|
||||
const double clideFontBody = 15;
|
||||
const double clideFontCaption = 14;
|
||||
const double clideFontMono = 14;
|
||||
const double clideFontMeta = 13;
|
||||
const double clideFontSmall = 12;
|
||||
const double clideFontBadge = 11;
|
||||
// Larger semantic sizes that aren't body text. clideFontDialogTitle is
|
||||
// the modal/dialog heading; clideFontWelcomeBanner is the oversized
|
||||
// "clide" mark on the welcome screen — one-off but worth naming.
|
||||
const double clideFontDialogTitle = 16;
|
||||
const double clideFontWelcomeBanner = 52;
|
||||
const double clideLineHeight = 1.25;
|
||||
|
||||
/// System fallback chain. Ordered by platform prevalence + quality of
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* clide — thin C client for the in-process IPC server hosted by the
|
||||
* Flutter app (T-99 / T-126). Third slice of D-56 path (a).
|
||||
*
|
||||
* What it does:
|
||||
* 1. Walks CWD up to a directory containing `.git` (the workspace
|
||||
* root, same definition the Flutter app uses on boot).
|
||||
* 2. Hashes that path with FNV-1a 64-bit and resolves the per-
|
||||
* workspace socket path per D-70 (Linux: $XDG_RUNTIME_DIR/clide/
|
||||
* <hash>.sock; macOS: $HOME/Library/Caches/clide/<hash>.sock).
|
||||
* 3. Connects, sends `{"v":1,"type":"request","id":"<pid>",
|
||||
* "cmd":"_argv","args":{"argv":[...]}}` (the server runs
|
||||
* parseArgv on it per T-125), reads the JSON-line response,
|
||||
* writes payload to stdout, error message (if any) to stderr,
|
||||
* exits with the response's exit code.
|
||||
*
|
||||
* Design notes:
|
||||
* - No third-party deps. Standard POSIX + a minimal JSON writer
|
||||
* (string-escape only — we never PARSE JSON, just emit argv into
|
||||
* it; the response is read whole then printed as-is to stdout).
|
||||
* - The argv→IpcRequest translator lives in Dart (T-125). We just
|
||||
* ship argv across the wire under a sentinel cmd `_argv`; the
|
||||
* server unpacks it.
|
||||
* - Workspace-root discovery: we look for `.git` (dir OR file —
|
||||
* submodules use a file). If we don't find one walking upward,
|
||||
* exit with EX_USAGE.
|
||||
*
|
||||
* Build: `make clide-cli` (see Makefile). Pure C99, builds with
|
||||
* any gcc / clang / cc.
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
#define EX_USAGE 64
|
||||
#define EX_SOFTWARE 70
|
||||
#define EX_OSERR 71
|
||||
#define EX_UNAVAILABLE 69
|
||||
|
||||
static const uint64_t FNV_OFFSET = 0xcbf29ce484222325ULL;
|
||||
static const uint64_t FNV_PRIME = 0x100000001b3ULL;
|
||||
|
||||
static void fnv1a64_hex(const char *s, char out[17]) {
|
||||
uint64_t h = FNV_OFFSET;
|
||||
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
||||
h ^= *p;
|
||||
h *= FNV_PRIME;
|
||||
}
|
||||
/* 16 lowercase hex chars + NUL. */
|
||||
snprintf(out, 17, "%016" PRIx64, h);
|
||||
}
|
||||
|
||||
/* Walk `start` upward looking for an entry named `.git`. Writes the
|
||||
* containing directory into `out` (PATH_MAX). Returns 0 on success,
|
||||
* -1 if no .git was found before /. */
|
||||
static int find_workspace_root(const char *start, char *out, size_t out_size) {
|
||||
char cwd[4096];
|
||||
if (start) {
|
||||
strncpy(cwd, start, sizeof(cwd) - 1);
|
||||
cwd[sizeof(cwd) - 1] = '\0';
|
||||
} else if (!getcwd(cwd, sizeof(cwd))) {
|
||||
return -1;
|
||||
}
|
||||
while (1) {
|
||||
size_t len = strlen(cwd);
|
||||
if (len + 6 >= sizeof(cwd)) return -1;
|
||||
char probe[4108];
|
||||
snprintf(probe, sizeof(probe), "%s/.git", cwd);
|
||||
struct stat st;
|
||||
if (lstat(probe, &st) == 0) {
|
||||
strncpy(out, cwd, out_size - 1);
|
||||
out[out_size - 1] = '\0';
|
||||
return 0;
|
||||
}
|
||||
/* Climb one. /foo/bar -> /foo, / -> stop. */
|
||||
if (cwd[0] == '/' && cwd[1] == '\0') return -1;
|
||||
char *slash = strrchr(cwd, '/');
|
||||
if (!slash) return -1;
|
||||
if (slash == cwd) cwd[1] = '\0';
|
||||
else *slash = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* Compose `$XDG_RUNTIME_DIR/clide/<hash>.sock` on Linux,
|
||||
* `$HOME/Library/Caches/clide/<hash>.sock` on macOS. */
|
||||
static int socket_path_for(const char *workspace_root, char *out, size_t out_size) {
|
||||
char hash[17];
|
||||
fnv1a64_hex(workspace_root, hash);
|
||||
#ifdef __APPLE__
|
||||
const char *home = getenv("HOME");
|
||||
if (!home || !*home) home = "/tmp";
|
||||
return snprintf(out, out_size, "%s/Library/Caches/clide/%s.sock", home, hash);
|
||||
#else
|
||||
const char *xdg = getenv("XDG_RUNTIME_DIR");
|
||||
if (!xdg || !*xdg) xdg = "/tmp";
|
||||
return snprintf(out, out_size, "%s/clide/%s.sock", xdg, hash);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Open a UNIX-domain stream socket connected to `path`. Returns fd
|
||||
* on success, -1 on failure (errno set). */
|
||||
static int connect_unix(const char *path) {
|
||||
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (fd < 0) return -1;
|
||||
struct sockaddr_un addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sun_family = AF_UNIX;
|
||||
if (strlen(path) >= sizeof(addr.sun_path)) {
|
||||
close(fd);
|
||||
errno = ENAMETOOLONG;
|
||||
return -1;
|
||||
}
|
||||
strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
|
||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
int saved = errno;
|
||||
close(fd);
|
||||
errno = saved;
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
/* Emit `s` as a JSON string literal (quotes + standard escapes) to
|
||||
* `out`. Caller ensures `out` is large enough — we cap at 8 * input
|
||||
* length + 2 (worst case is every byte → \uXXXX). */
|
||||
static void json_escape(const char *s, char *out, size_t out_size) {
|
||||
size_t j = 0;
|
||||
out[j++] = '"';
|
||||
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
||||
if (j + 8 >= out_size) break;
|
||||
switch (*p) {
|
||||
case '"': out[j++] = '\\'; out[j++] = '"'; break;
|
||||
case '\\': out[j++] = '\\'; out[j++] = '\\'; break;
|
||||
case '\b': out[j++] = '\\'; out[j++] = 'b'; break;
|
||||
case '\f': out[j++] = '\\'; out[j++] = 'f'; break;
|
||||
case '\n': out[j++] = '\\'; out[j++] = 'n'; break;
|
||||
case '\r': out[j++] = '\\'; out[j++] = 'r'; break;
|
||||
case '\t': out[j++] = '\\'; out[j++] = 't'; break;
|
||||
default:
|
||||
if (*p < 0x20) {
|
||||
j += snprintf(out + j, out_size - j, "\\u%04x", *p);
|
||||
} else {
|
||||
out[j++] = (char)*p;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (j + 1 < out_size) out[j++] = '"';
|
||||
out[j] = '\0';
|
||||
}
|
||||
|
||||
/* Build the request envelope and write it to `out`. Returns 0 on
|
||||
* success, -1 if any input was too large. */
|
||||
static int build_request(int argc, char **argv, pid_t pid, char *out, size_t out_size) {
|
||||
/* Compute argv array size: each arg gets its own escaped JSON. */
|
||||
int n = snprintf(out, out_size,
|
||||
"{\"type\":\"request\",\"v\":1,\"id\":\"c%lld\",\"cmd\":\"_argv\",\"args\":{\"argv\":[",
|
||||
(long long)pid);
|
||||
if (n < 0 || (size_t)n >= out_size) return -1;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
char esc[4096];
|
||||
json_escape(argv[i], esc, sizeof(esc));
|
||||
n += snprintf(out + n, out_size - n, "%s%s", i ? "," : "", esc);
|
||||
if (n < 0 || (size_t)n >= out_size) return -1;
|
||||
}
|
||||
n += snprintf(out + n, out_size - n, "]}}\n");
|
||||
return (n < 0 || (size_t)n >= out_size) ? -1 : 0;
|
||||
}
|
||||
|
||||
/* Read one line (terminated by \n) from fd into out. Returns 0 on
|
||||
* success, -1 on EOF / error. The trailing \n is stripped. */
|
||||
static int read_line(int fd, char *out, size_t out_size) {
|
||||
size_t i = 0;
|
||||
while (i + 1 < out_size) {
|
||||
char c;
|
||||
ssize_t r = read(fd, &c, 1);
|
||||
if (r <= 0) {
|
||||
if (r < 0 && errno == EINTR) continue;
|
||||
return -1;
|
||||
}
|
||||
if (c == '\n') {
|
||||
out[i] = '\0';
|
||||
return 0;
|
||||
}
|
||||
out[i++] = c;
|
||||
}
|
||||
out[i] = '\0';
|
||||
/* Line too long; treat as overflow but keep what we have. */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Minimal JSON peek: locate the bytes between `"key":` and the next
|
||||
* sibling separator (`,` or `}`). Returns a pointer into `buf` and
|
||||
* writes the length to *out_len. Returns NULL if the key isn't
|
||||
* found. This is a deliberately tiny scanner — we never need to
|
||||
* fully parse the response, just pick out `ok`, `code`, `message`,
|
||||
* `data`. The response is well-formed by construction (the server
|
||||
* builds it via Dart's JSON encoder). */
|
||||
static const char *json_value(const char *buf, const char *key, size_t *out_len) {
|
||||
/* Search for `"key": ` (allow optional whitespace). */
|
||||
char needle[128];
|
||||
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||
const char *p = strstr(buf, needle);
|
||||
if (!p) return NULL;
|
||||
p += strlen(needle);
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
if (*p != ':') return NULL;
|
||||
p++;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
const char *start = p;
|
||||
int depth = 0;
|
||||
int in_str = 0;
|
||||
while (*p) {
|
||||
if (in_str) {
|
||||
if (*p == '\\' && p[1]) { p += 2; continue; }
|
||||
if (*p == '"') in_str = 0;
|
||||
} else {
|
||||
if (*p == '"') in_str = 1;
|
||||
else if (*p == '{' || *p == '[') depth++;
|
||||
else if (*p == '}' || *p == ']') {
|
||||
if (depth == 0) break;
|
||||
depth--;
|
||||
} else if (*p == ',' && depth == 0) break;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
*out_len = (size_t)(p - start);
|
||||
return start;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
/* argv[0] is the program name; everything after is what the user
|
||||
* typed after `clide`. */
|
||||
if (argc < 2) {
|
||||
fprintf(stderr, "usage: clide <subsystem> <verb> [args...]\n"
|
||||
" clide status | tail | version | ping\n");
|
||||
return EX_USAGE;
|
||||
}
|
||||
|
||||
char ws_root[4096];
|
||||
if (find_workspace_root(NULL, ws_root, sizeof(ws_root)) != 0) {
|
||||
fprintf(stderr, "clide: not inside a git repository — no workspace to talk to\n");
|
||||
return EX_USAGE;
|
||||
}
|
||||
|
||||
char sock_path[4096];
|
||||
if (socket_path_for(ws_root, sock_path, sizeof(sock_path)) >= (int)sizeof(sock_path)) {
|
||||
fprintf(stderr, "clide: socket path overflow\n");
|
||||
return EX_SOFTWARE;
|
||||
}
|
||||
|
||||
int fd = connect_unix(sock_path);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "clide: cannot connect to %s: %s\n", sock_path, strerror(errno));
|
||||
return EX_UNAVAILABLE;
|
||||
}
|
||||
|
||||
/* Build + send request. Worst-case envelope sizing: argv totals
|
||||
* plus JSON overhead. 64 KB envelope handles 4 KB args * 16. */
|
||||
char req[65536];
|
||||
if (build_request(argc - 1, argv + 1, getpid(), req, sizeof(req)) != 0) {
|
||||
fprintf(stderr, "clide: request payload too large\n");
|
||||
close(fd);
|
||||
return EX_USAGE;
|
||||
}
|
||||
if (write(fd, req, strlen(req)) != (ssize_t)strlen(req)) {
|
||||
fprintf(stderr, "clide: write failed: %s\n", strerror(errno));
|
||||
close(fd);
|
||||
return EX_OSERR;
|
||||
}
|
||||
|
||||
/* Read the response — one JSON line. */
|
||||
char resp[65536];
|
||||
if (read_line(fd, resp, sizeof(resp)) != 0) {
|
||||
fprintf(stderr, "clide: response read failed: %s\n",
|
||||
errno ? strerror(errno) : "short read");
|
||||
close(fd);
|
||||
return EX_OSERR;
|
||||
}
|
||||
|
||||
/* Pull out `ok`, `data`/`error` from the response. */
|
||||
size_t ok_len = 0, data_len = 0, code_len = 0, msg_len = 0;
|
||||
const char *ok_v = json_value(resp, "ok", &ok_len);
|
||||
int ok = (ok_v && ok_len >= 4 && strncmp(ok_v, "true", 4) == 0);
|
||||
if (ok) {
|
||||
const char *data = json_value(resp, "data", &data_len);
|
||||
if (data) {
|
||||
fwrite(data, 1, data_len, stdout);
|
||||
fputc('\n', stdout);
|
||||
fflush(stdout);
|
||||
} else {
|
||||
fputs("{}\n", stdout);
|
||||
fflush(stdout);
|
||||
}
|
||||
/* If the server flagged this as a streaming response
|
||||
* (`tail --events` per T-129), loop-read event JSON-lines
|
||||
* until the connection closes. Detection: look for the
|
||||
* literal `"streaming":true` inside the data blob. */
|
||||
if (data && data_len > 0) {
|
||||
char data_copy[16384];
|
||||
size_t copy_len = data_len < sizeof(data_copy) - 1 ? data_len : sizeof(data_copy) - 1;
|
||||
memcpy(data_copy, data, copy_len);
|
||||
data_copy[copy_len] = '\0';
|
||||
if (strstr(data_copy, "\"streaming\":true") != NULL || strstr(data_copy, "\"streaming\": true") != NULL) {
|
||||
/* Streaming mode — keep reading event lines. Exit
|
||||
* 0 on EOF (server closed cleanly), non-zero on
|
||||
* read error. */
|
||||
char ev[65536];
|
||||
while (read_line(fd, ev, sizeof(ev)) == 0) {
|
||||
fputs(ev, stdout);
|
||||
fputc('\n', stdout);
|
||||
fflush(stdout);
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
close(fd);
|
||||
const char *code_v = json_value(resp, "code", &code_len);
|
||||
const char *msg_v = json_value(resp, "message", &msg_len);
|
||||
int exit_code = code_v ? (int)strtol(code_v, NULL, 10) : EX_SOFTWARE;
|
||||
if (msg_v) {
|
||||
/* Trim the surrounding quotes from the JSON string literal. */
|
||||
if (msg_len >= 2 && msg_v[0] == '"' && msg_v[msg_len - 1] == '"') {
|
||||
fwrite(msg_v + 1, 1, msg_len - 2, stderr);
|
||||
} else {
|
||||
fwrite(msg_v, 1, msg_len, stderr);
|
||||
}
|
||||
fputc('\n', stderr);
|
||||
}
|
||||
return exit_code;
|
||||
}
|
||||
@@ -15,6 +15,11 @@ description: >-
|
||||
publish_to: none
|
||||
version: 2.1.0
|
||||
repository: https://github.com/postmeridiem/clide
|
||||
# Short user-facing tagline (the welcome subtitle, web meta
|
||||
# description, etc.). Baked into lib/src/build_info.g.dart by
|
||||
# `make gen-build-info` — pubspec.yaml is the single source of
|
||||
# truth; nothing else duplicates this string in source.
|
||||
tagline: "IDE for Claude Code CLI"
|
||||
|
||||
# Pre-push line-coverage floor. Ratchets up only — see D-66.
|
||||
# Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`.
|
||||
|
||||
@@ -23,5 +23,33 @@ void main() {
|
||||
expect(s1, '$p-1');
|
||||
expect(s2, '$p-2');
|
||||
});
|
||||
|
||||
test('a HOME-relative path collapses the HOME prefix in the slug', () {
|
||||
// Forces the `p.startsWith(home)` branch.
|
||||
final home = const String.fromEnvironment('HOME');
|
||||
// Use a path we know lives under the platform HOME so the branch fires.
|
||||
// In test environments HOME is set; the path /tmp may or may not be
|
||||
// under it. Use a synthesized HOME path so the assert holds regardless.
|
||||
final fake = '${home.isEmpty ? '/home/test' : home}/projects/clide';
|
||||
final name = primarySessionName(fake);
|
||||
expect(name, contains('projects-clide'));
|
||||
});
|
||||
|
||||
test('path of only "/" slugifies to "root"', () {
|
||||
// Exercises the "strip leading/trailing '-' then fall back" branch.
|
||||
expect(primarySessionName('/'), 'clide-claude-root');
|
||||
});
|
||||
|
||||
test('path longer than the slug cap hashes to 8 hex chars', () {
|
||||
final long = '/${'segment/' * 30}leaf';
|
||||
final name = primarySessionName(long);
|
||||
// Hash form: clide-claude-<8 hex>.
|
||||
expect(name, matches(RegExp(r'^clide-claude-[0-9a-f]{8}$')));
|
||||
});
|
||||
|
||||
test('the same long path produces a stable hash', () {
|
||||
final long = '/${'a/' * 200}';
|
||||
expect(primarySessionName(long), primarySessionName(long));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
@@ -131,6 +129,35 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'sticky-startup toggle renders + flips when tapped (T-115)',
|
||||
(tester) async {
|
||||
// Seed a recent directly so we don't need a real git repo.
|
||||
await f.services.settings.set<String>(
|
||||
'app.recentProjects',
|
||||
'[{"path":"/tmp/clide-fixture","name":"clide-fixture","lastOpened":"2026-05-18T00:00:00.000Z"}]',
|
||||
);
|
||||
await f.services.project.loadRecents();
|
||||
tester.view.physicalSize = const Size(1200, 900);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
await tester.pump();
|
||||
expect(find.text('clide-fixture'), findsOneWidget);
|
||||
final toggle = find.byKey(const ValueKey('welcome.sticky./tmp/clide-fixture'));
|
||||
expect(toggle, findsOneWidget);
|
||||
await tester.tap(toggle, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
expect(f.services.project.recents.first.startupSticky, isTrue);
|
||||
},
|
||||
// T-122: pumpWidget(WelcomeView) with a non-empty recents list
|
||||
// strands the test until the 10-min Flutter timeout, even after
|
||||
// ruling out ClideTooltip and find/tap shape. Cause not yet
|
||||
// localized — skip until reproduced in isolation.
|
||||
skip: true,
|
||||
);
|
||||
|
||||
testWidgets('Open folder opens the fallback dialog when the picker throws MissingPluginException', (tester) async {
|
||||
// Pre-register a mock that throws — emulating a platform without
|
||||
// native picker support.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/// Unit tests for the `_argv` unwrap handler (T-126).
|
||||
///
|
||||
/// Exercises the two branches the e2e test doesn't reliably hit:
|
||||
/// 1. `args.argv` not a List → userError
|
||||
/// 2. argv parses to an ArgvError → that error flows back through
|
||||
library;
|
||||
|
||||
import 'package:clide/src/cli/argv_dispatch.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late DaemonDispatcher d;
|
||||
|
||||
setUp(() {
|
||||
d = DaemonDispatcher();
|
||||
registerArgvUnwrap(d);
|
||||
});
|
||||
|
||||
test('valid argv → parsed and re-dispatched (round-trip via ping)', () async {
|
||||
final res = await d.dispatch(IpcRequest(id: 'x', cmd: argvSentinelCmd, args: {
|
||||
'argv': ['ping'],
|
||||
}));
|
||||
expect(res.ok, isTrue);
|
||||
expect(res.id, 'x');
|
||||
expect(res.data['pong'], isTrue);
|
||||
});
|
||||
|
||||
test('args.argv missing → userError', () async {
|
||||
final res = await d.dispatch(IpcRequest(id: 'y', cmd: argvSentinelCmd, args: const {}));
|
||||
expect(res.ok, isFalse);
|
||||
expect(res.error?.kind, IpcErrorKind.userError);
|
||||
expect(res.error?.message, contains('argv'));
|
||||
});
|
||||
|
||||
test('args.argv is not a list → userError', () async {
|
||||
final res = await d.dispatch(IpcRequest(id: 'z', cmd: argvSentinelCmd, args: {
|
||||
'argv': 'not a list',
|
||||
}));
|
||||
expect(res.ok, isFalse);
|
||||
expect(res.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('argv that fails parseArgv → that error flows back unmodified', () async {
|
||||
final res = await d.dispatch(IpcRequest(id: 'p', cmd: argvSentinelCmd, args: {
|
||||
'argv': const <String>[], // empty argv triggers parseArgv usage error
|
||||
}));
|
||||
expect(res.ok, isFalse);
|
||||
expect(res.error?.kind, IpcErrorKind.userError);
|
||||
expect(res.error?.message, contains('usage'));
|
||||
});
|
||||
|
||||
test('outer request id is preserved on the response', () async {
|
||||
final res = await d.dispatch(IpcRequest(id: 'unique-id-123', cmd: argvSentinelCmd, args: {
|
||||
'argv': ['ping'],
|
||||
}));
|
||||
expect(res.id, 'unique-id-123');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:clide/src/cli/argv_to_request.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
IpcRequest _expectOk(ArgvParseResult r) {
|
||||
expect(r, isA<ArgvParsed>(), reason: 'expected parse to succeed: $r');
|
||||
return (r as ArgvParsed).request;
|
||||
}
|
||||
|
||||
IpcResponse _expectErr(ArgvParseResult r) {
|
||||
expect(r, isA<ArgvError>(), reason: 'expected parse to error: $r');
|
||||
return (r as ArgvError).response;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('parseArgv — subsystem.verb shape', () {
|
||||
test('subsystem + verb maps to "subsystem.verb"', () {
|
||||
final req = _expectOk(parseArgv(['git', 'status'], requestId: '1'));
|
||||
expect(req.cmd, 'git.status');
|
||||
expect(req.id, '1');
|
||||
expect(req.args, isEmpty);
|
||||
});
|
||||
|
||||
test('positional args land in args.positional', () {
|
||||
final req = _expectOk(parseArgv(['files', 'read', 'lib/main.dart'], requestId: '2'));
|
||||
expect(req.cmd, 'files.read');
|
||||
expect(req.args['positional'], ['lib/main.dart']);
|
||||
expect(req.args.containsKey('flags'), isFalse);
|
||||
});
|
||||
|
||||
test('multiple positionals preserve order', () {
|
||||
final req = _expectOk(parseArgv(['git', 'stage', 'a.dart', 'b.dart', 'c.dart'], requestId: '3'));
|
||||
expect(req.args['positional'], ['a.dart', 'b.dart', 'c.dart']);
|
||||
});
|
||||
|
||||
test('--key=value flag', () {
|
||||
final req = _expectOk(parseArgv(['git', 'log', '--count=20'], requestId: '4'));
|
||||
expect(req.cmd, 'git.log');
|
||||
expect((req.args['flags'] as Map)['count'], '20');
|
||||
});
|
||||
|
||||
test('--key value flag (separated)', () {
|
||||
final req = _expectOk(parseArgv(['git', 'log', '--count', '20'], requestId: '5'));
|
||||
expect((req.args['flags'] as Map)['count'], '20');
|
||||
});
|
||||
|
||||
test('boolean flag at end of argv', () {
|
||||
final req = _expectOk(parseArgv(['git', 'push', '--force'], requestId: '6'));
|
||||
expect((req.args['flags'] as Map)['force'], isTrue);
|
||||
});
|
||||
|
||||
test('boolean flag followed by another flag', () {
|
||||
final req = _expectOk(parseArgv(['git', 'log', '--graph', '--count=10'], requestId: '7'));
|
||||
expect((req.args['flags'] as Map)['graph'], isTrue);
|
||||
expect((req.args['flags'] as Map)['count'], '10');
|
||||
});
|
||||
|
||||
test('bare -- terminates options; rest is passthrough', () {
|
||||
final req = _expectOk(parseArgv(['pane', 'spawn', '--name', 'foo', '--', 'bash', '-c', 'echo hi'], requestId: '8'));
|
||||
expect(req.cmd, 'pane.spawn');
|
||||
expect((req.args['flags'] as Map)['name'], 'foo');
|
||||
expect(req.args['passthrough'], ['bash', '-c', 'echo hi']);
|
||||
});
|
||||
|
||||
test('mixed positional + flag + passthrough', () {
|
||||
final req = _expectOk(parseArgv(['files', 'read', 'a.dart', '--limit=4096', '--', '--not-a-flag'], requestId: '9'));
|
||||
expect(req.args['positional'], ['a.dart']);
|
||||
expect((req.args['flags'] as Map)['limit'], '4096');
|
||||
expect(req.args['passthrough'], ['--not-a-flag']);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseArgv — umbrella commands', () {
|
||||
test('"status" maps to cmd "status" with no subsystem', () {
|
||||
final req = _expectOk(parseArgv(['status'], requestId: 's'));
|
||||
expect(req.cmd, 'status');
|
||||
expect(req.args, isEmpty);
|
||||
});
|
||||
|
||||
test('"tail --events" sets the events boolean flag', () {
|
||||
final req = _expectOk(parseArgv(['tail', '--events'], requestId: 't'));
|
||||
expect(req.cmd, 'tail');
|
||||
expect((req.args['flags'] as Map)['events'], isTrue);
|
||||
});
|
||||
|
||||
test('"tail --events --filter git" pairs the filter value', () {
|
||||
final req = _expectOk(parseArgv(['tail', '--events', '--filter', 'git'], requestId: 'tf'));
|
||||
expect((req.args['flags'] as Map)['events'], isTrue);
|
||||
expect((req.args['flags'] as Map)['filter'], 'git');
|
||||
});
|
||||
|
||||
test('ping + version are recognised as umbrella commands', () {
|
||||
expect(_expectOk(parseArgv(['ping'], requestId: 'p')).cmd, 'ping');
|
||||
expect(_expectOk(parseArgv(['version'], requestId: 'v')).cmd, 'version');
|
||||
});
|
||||
});
|
||||
|
||||
group('parseArgv — errors', () {
|
||||
test('empty argv → usage error (EX_USAGE)', () {
|
||||
final err = _expectErr(parseArgv(const [], requestId: 'e1'));
|
||||
expect(err.error?.code, IpcExitCode.userError);
|
||||
expect(err.error?.message, contains('usage'));
|
||||
});
|
||||
|
||||
test('subsystem without verb → usage error', () {
|
||||
final err = _expectErr(parseArgv(['git'], requestId: 'e2'));
|
||||
expect(err.error?.message, contains('usage'));
|
||||
expect(err.error?.message, contains('git'));
|
||||
});
|
||||
|
||||
test('garbage in the subsystem token is rejected', () {
|
||||
final err = _expectErr(parseArgv(['bad subsystem', 'verb'], requestId: 'e3'));
|
||||
expect(err.error?.message, contains('subsystem'));
|
||||
});
|
||||
|
||||
test('"--" with no name is a flag-shape error', () {
|
||||
// "git status -- --" — first -- starts passthrough so second is
|
||||
// not a flag; that's fine. Reproduce the failure with the
|
||||
// option-name-empty path via `git status --=foo` which gives an
|
||||
// empty key.
|
||||
final err = _expectErr(parseArgv(['git', 'status', '--=foo'], requestId: 'e4'));
|
||||
expect(err.error?.message, contains('flag'));
|
||||
});
|
||||
|
||||
test('empty flag body before passthrough is treated as bare --', () {
|
||||
// `git status -- bar` — bar is passthrough, not a positional.
|
||||
final req = _expectOk(parseArgv(['git', 'status', '--', 'bar'], requestId: 'e5'));
|
||||
expect(req.args['passthrough'], ['bar']);
|
||||
expect(req.args.containsKey('positional'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseArgv — wire round-trip', () {
|
||||
test('encoded request → decoded request matches', () {
|
||||
final req = _expectOk(parseArgv(['files', 'read', 'README.md'], requestId: 'rt'));
|
||||
final wire = req.encode();
|
||||
final decoded = IpcMessage.decode(wire);
|
||||
expect(decoded, isA<IpcRequest>());
|
||||
final r = decoded as IpcRequest;
|
||||
expect(r.cmd, 'files.read');
|
||||
expect(r.args['positional'], ['README.md']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/// End-to-end test for the C `clide` shell client (T-126).
|
||||
///
|
||||
/// Compiles native/clide-cli/clide.c via the host `cc` (skip if not
|
||||
/// available), starts an IpcServer with a controlled workspace root,
|
||||
/// and exercises the client as a child process. Verifies the
|
||||
/// cross-language FNV-1a hash agreement: if the C binary and the
|
||||
/// Dart server compute the same socket path for the same workspace,
|
||||
/// the round-trip works; if not, the connect fails.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/cli/argv_dispatch.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
// Build the binary once for the whole suite.
|
||||
late final String binaryPath;
|
||||
late final bool hasCC;
|
||||
late final Directory workspaceRoot;
|
||||
late final IpcServer server;
|
||||
late final DaemonDispatcher dispatcher;
|
||||
late final DaemonBus streamingBus;
|
||||
|
||||
setUpAll(() async {
|
||||
final repoRoot = Directory.current.path;
|
||||
final ccProbe = await Process.run('sh', ['-c', 'command -v cc']);
|
||||
hasCC = ccProbe.exitCode == 0;
|
||||
if (!hasCC) return;
|
||||
final src = '$repoRoot/native/clide-cli/clide.c';
|
||||
final out = '${Directory.systemTemp.createTempSync('clide-cli-test-').path}/clide';
|
||||
final build = await Process.run('cc', [
|
||||
'-std=c99',
|
||||
'-O2',
|
||||
'-Wall',
|
||||
src,
|
||||
'-o',
|
||||
out,
|
||||
]);
|
||||
expect(build.exitCode, 0, reason: 'cc failed: ${build.stderr}');
|
||||
binaryPath = out;
|
||||
|
||||
// Synthetic git workspace — the C client walks up looking for
|
||||
// `.git`, hashes whatever it lands on, and connects to the
|
||||
// matching socket. Match it by handing the same root to the
|
||||
// server.
|
||||
workspaceRoot = Directory.systemTemp.createTempSync('clide-ws-');
|
||||
Directory('${workspaceRoot.path}/.git').createSync();
|
||||
dispatcher = DaemonDispatcher();
|
||||
registerArgvUnwrap(dispatcher);
|
||||
streamingBus = DaemonBus();
|
||||
server = IpcServer(
|
||||
dispatcher: dispatcher,
|
||||
workspaceRoot: workspaceRoot.path,
|
||||
log: Logger(minLevel: LogLevel.error, sinks: const []),
|
||||
events: streamingBus,
|
||||
);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
if (!hasCC) return;
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (_) {}
|
||||
await streamingBus.dispose();
|
||||
if (workspaceRoot.existsSync()) {
|
||||
workspaceRoot.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
Future<ProcessResult> runCli(List<String> argv) {
|
||||
return Process.run(binaryPath, argv, workingDirectory: workspaceRoot.path);
|
||||
}
|
||||
|
||||
group('clide-cli (T-126)', () {
|
||||
test('no args → EX_USAGE (64) with a usage banner on stderr', () async {
|
||||
if (!hasCC) {
|
||||
markTestSkipped('cc not available');
|
||||
return;
|
||||
}
|
||||
final r = await runCli(const []);
|
||||
expect(r.exitCode, 64);
|
||||
expect(r.stderr.toString(), contains('usage'));
|
||||
});
|
||||
|
||||
test('outside a git repo → EX_USAGE', () async {
|
||||
if (!hasCC) {
|
||||
markTestSkipped('cc not available');
|
||||
return;
|
||||
}
|
||||
final outside = Directory.systemTemp.createTempSync('clide-no-git-');
|
||||
addTearDown(() => outside.deleteSync(recursive: true));
|
||||
final r = await Process.run(binaryPath, ['status'], workingDirectory: outside.path);
|
||||
expect(r.exitCode, 64);
|
||||
expect(r.stderr.toString(), contains('git repository'));
|
||||
});
|
||||
|
||||
test('ping returns ok JSON on stdout, exit 0', () async {
|
||||
if (!hasCC) {
|
||||
markTestSkipped('cc not available');
|
||||
return;
|
||||
}
|
||||
final r = await runCli(['ping']);
|
||||
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
|
||||
final data = jsonDecode(r.stdout.toString().trim()) as Map<String, Object?>;
|
||||
expect(data['pong'], isTrue);
|
||||
});
|
||||
|
||||
test('subsystem.verb routing through the dispatcher', () async {
|
||||
if (!hasCC) {
|
||||
markTestSkipped('cc not available');
|
||||
return;
|
||||
}
|
||||
// Stub handler that echoes the request's args back so we can
|
||||
// verify the wire shape end-to-end.
|
||||
dispatcher.register('probe.echo', (req) async => IpcResponse.ok(id: req.id, data: req.args));
|
||||
final r = await runCli(['probe', 'echo', 'first', '--flag=val', '--bool', '--', 'pass1']);
|
||||
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
|
||||
final data = jsonDecode(r.stdout.toString().trim()) as Map<String, Object?>;
|
||||
expect(data['positional'], ['first']);
|
||||
expect((data['flags'] as Map)['flag'], 'val');
|
||||
expect((data['flags'] as Map)['bool'], isTrue);
|
||||
expect(data['passthrough'], ['pass1']);
|
||||
});
|
||||
|
||||
test('unknown verb → notFound exit code', () async {
|
||||
if (!hasCC) {
|
||||
markTestSkipped('cc not available');
|
||||
return;
|
||||
}
|
||||
final r = await runCli(['nosuchsub', 'nosuchverb']);
|
||||
expect(r.exitCode, isNot(0));
|
||||
expect(r.stderr.toString(), isNotEmpty);
|
||||
});
|
||||
|
||||
test('tail --events streams bus events to stdout (T-129)', () async {
|
||||
if (!hasCC) {
|
||||
markTestSkipped('cc not available');
|
||||
return;
|
||||
}
|
||||
final proc = await Process.start(binaryPath, ['tail', '--events', '--filter', 'pane'], workingDirectory: workspaceRoot.path);
|
||||
addTearDown(() => proc.kill());
|
||||
final lines = <String>[];
|
||||
final sub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(lines.add);
|
||||
addTearDown(sub.cancel);
|
||||
// Wait for the ack so the server has registered us.
|
||||
var attempts = 0;
|
||||
while (lines.isEmpty && attempts < 50) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
attempts++;
|
||||
}
|
||||
expect(lines, isNotEmpty, reason: 'no ack received');
|
||||
// Emit two events.
|
||||
streamingBus.emit(DaemonEvent(subsystem: 'pane', kind: 'spawned', data: const {'id': 'p1'}, ts: DateTime.now().toUtc()));
|
||||
streamingBus.emit(DaemonEvent(subsystem: 'pane', kind: 'closed', data: const {'id': 'p1'}, ts: DateTime.now().toUtc()));
|
||||
attempts = 0;
|
||||
while (lines.length < 3 && attempts < 100) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
attempts++;
|
||||
}
|
||||
expect(lines.length, greaterThanOrEqualTo(3), reason: 'expected ack + 2 events, got: $lines');
|
||||
final concatenated = lines.skip(1).join('\n');
|
||||
expect(concatenated, contains('"kind":"spawned"'));
|
||||
expect(concatenated, contains('"kind":"closed"'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/// Tests for the `panel.*` command handlers (T-119).
|
||||
///
|
||||
/// Uses an in-memory [PanelResizer] fake so the dispatch surface can
|
||||
/// be exercised under `dart test` without pulling Flutter into the
|
||||
/// build (the live kernel resizer wraps `LayoutArrangement`, which is
|
||||
/// Flutter-bound; tests for that live in
|
||||
/// `test/kernel/src/panels/arrangement_test.dart`).
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/daemon/panel_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('panel.resize dispatch', () {
|
||||
late DaemonDispatcher dispatcher;
|
||||
late _FakeResizer resizer;
|
||||
|
||||
setUp(() {
|
||||
resizer = _FakeResizer(
|
||||
slots: {'sidebar': 200, 'context': 240, 'workspace': 800},
|
||||
editorRatio: 0.35,
|
||||
);
|
||||
dispatcher = DaemonDispatcher();
|
||||
registerPanelCommands(dispatcher, resizer);
|
||||
});
|
||||
|
||||
Future<IpcResponse> call(Map<String, Object?> args) {
|
||||
return dispatcher.dispatch(IpcRequest(id: '1', cmd: 'panel.resize', args: args));
|
||||
}
|
||||
|
||||
test('rejects a request with no slot', () async {
|
||||
final r = await call(const {'to': 220});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
expect(r.error!.message, contains('slot'));
|
||||
});
|
||||
|
||||
test('rejects a request with neither `to` nor `by`', () async {
|
||||
final r = await call(const {'slot': 'sidebar'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
expect(r.error!.message, contains('to'));
|
||||
expect(r.error!.message, contains('by'));
|
||||
});
|
||||
|
||||
test('rejects a request with both `to` and `by`', () async {
|
||||
final r = await call(const {'slot': 'sidebar', 'to': 200, 'by': 10});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
expect(r.error!.message, contains('only one'));
|
||||
});
|
||||
|
||||
test('rejects a non-numeric `to`', () async {
|
||||
final r = await call(const {'slot': 'sidebar', 'to': 'lots'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
expect(r.error!.message, contains('numeric'));
|
||||
});
|
||||
|
||||
test('rejects an unknown slot with not-found', () async {
|
||||
final r = await call(const {'slot': 'nonsense', 'to': 100});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'not_found');
|
||||
expect(r.error!.message, contains('nonsense'));
|
||||
});
|
||||
|
||||
test('absolute `to` sets the slot size and echoes the result', () async {
|
||||
final r = await call(const {'slot': 'sidebar', 'to': 320});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['slot'], 'sidebar');
|
||||
expect(r.data['size'], 320);
|
||||
expect(resizer.slots['sidebar'], 320);
|
||||
});
|
||||
|
||||
test('relative `by` bumps the slot through PanelResizer.bumpSlotSize', () async {
|
||||
final r = await call(const {'slot': 'sidebar', 'by': 25});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(resizer.slots['sidebar'], 200 + 25);
|
||||
expect(resizer.lastBumpSlot, 'sidebar');
|
||||
expect(resizer.lastBumpDelta, 25);
|
||||
});
|
||||
|
||||
test('editor slot routes to setEditorRatio', () async {
|
||||
final r = await call(const {'slot': 'editor', 'to': 0.55});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['ratio'], 0.55);
|
||||
expect(resizer.editorRatio, 0.55);
|
||||
});
|
||||
|
||||
test('editor slot with `by` routes to bumpEditorRatio', () async {
|
||||
final r = await call(const {'slot': 'editor', 'by': 0.1});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(resizer.editorRatio, closeTo(0.45, 1e-9));
|
||||
});
|
||||
|
||||
group('argv-translator shape', () {
|
||||
test('positional[0] supplies the slot and flags carry to/by as strings', () async {
|
||||
final r = await call(const {
|
||||
'positional': ['sidebar'],
|
||||
'flags': {'to': '275'},
|
||||
});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(resizer.slots['sidebar'], 275);
|
||||
});
|
||||
|
||||
test('argv shape `by` parses to a delta', () async {
|
||||
final r = await call(const {
|
||||
'positional': ['context'],
|
||||
'flags': {'by': '-30'},
|
||||
});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(resizer.lastBumpSlot, 'context');
|
||||
expect(resizer.lastBumpDelta, -30);
|
||||
});
|
||||
|
||||
test('argv shape with neither flag still surfaces a user error', () async {
|
||||
final r = await call(const {
|
||||
'positional': ['sidebar'],
|
||||
'flags': <String, Object?>{},
|
||||
});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeResizer implements PanelResizer {
|
||||
_FakeResizer({required this.slots, required this.editorRatio});
|
||||
|
||||
final Map<String, double> slots;
|
||||
double editorRatio;
|
||||
|
||||
String? lastBumpSlot;
|
||||
double? lastBumpDelta;
|
||||
|
||||
@override
|
||||
bool setSlotSize(String slot, double size) {
|
||||
if (!slots.containsKey(slot)) return false;
|
||||
slots[slot] = size;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
bool bumpSlotSize(String slot, double rawDelta) {
|
||||
if (!slots.containsKey(slot)) return false;
|
||||
lastBumpSlot = slot;
|
||||
lastBumpDelta = rawDelta;
|
||||
slots[slot] = slots[slot]! + rawDelta;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void setEditorRatio(double ratio) {
|
||||
editorRatio = ratio;
|
||||
}
|
||||
|
||||
@override
|
||||
void bumpEditorRatio(double delta) {
|
||||
editorRatio += delta;
|
||||
}
|
||||
|
||||
@override
|
||||
double? currentSlotSize(String slot) => slots[slot];
|
||||
|
||||
@override
|
||||
double get currentEditorRatio => editorRatio;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class KernelFixture {
|
||||
preloadNamespaces: catalogs.keys.toList(),
|
||||
defaultLocale: defaultLocale,
|
||||
initialLocale: initialLocale,
|
||||
daemonClientFactory: (log, events) {
|
||||
daemonClientFactory: (log, events, _) {
|
||||
fake = FakeDaemonClient(log: log, events: events);
|
||||
return fake!;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/// T-130 — MCP server over HTTP+SSE (per D-73). Tests the discovery
|
||||
/// file shape, JSON-RPC round-trip for initialize / tools/list /
|
||||
/// tools/call, and end-of-session cleanup.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/ipc/mcp_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
Logger _silent() => Logger(minLevel: LogLevel.error, sinks: const []);
|
||||
|
||||
void main() {
|
||||
late Directory discoveryDir;
|
||||
late McpServer server;
|
||||
|
||||
setUp(() async {
|
||||
discoveryDir = await Directory.systemTemp.createTemp('clide-mcp-disc-');
|
||||
server = McpServer(
|
||||
workspaceRoot: '/var/mnt/test/clide-fixture',
|
||||
log: _silent(),
|
||||
discoveryDirOverride: discoveryDir.path,
|
||||
);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (_) {}
|
||||
if (discoveryDir.existsSync()) discoveryDir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<HttpClientResponse> openSse() async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/sse'));
|
||||
return req.close();
|
||||
}
|
||||
|
||||
Future<(String sessionId, Stream<String> dataLines)> connectAndCaptureEndpoint() async {
|
||||
final resp = await openSse();
|
||||
// Broadcast so multiple consumers in a test can subscribe.
|
||||
final dataLines = resp
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.where((line) => line.startsWith('data: '))
|
||||
.map((line) => line.substring(6))
|
||||
.asBroadcastStream();
|
||||
final endpoint = Completer<String>();
|
||||
final endpointSub = dataLines.listen((data) {
|
||||
final match = RegExp(r'sessionId=([\w-]+)').firstMatch(data);
|
||||
if (match != null && !endpoint.isCompleted) {
|
||||
endpoint.complete(match.group(1)!);
|
||||
}
|
||||
});
|
||||
final id = await endpoint.future.timeout(const Duration(seconds: 2));
|
||||
await endpointSub.cancel();
|
||||
return (id, dataLines);
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> post(String sessionId, Map<String, Object?> body) async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId'));
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.write(jsonEncode(body));
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.accepted);
|
||||
return body;
|
||||
}
|
||||
|
||||
group('McpServer (T-130) lifecycle', () {
|
||||
test('start binds a port and writes a discovery lock file', () async {
|
||||
expect(server.isRunning, isTrue);
|
||||
expect(server.port, greaterThan(0));
|
||||
expect(server.lockFilePath, isNotNull);
|
||||
final lock = File(server.lockFilePath!);
|
||||
expect(lock.existsSync(), isTrue);
|
||||
final payload = jsonDecode(lock.readAsStringSync()) as Map<String, Object?>;
|
||||
expect(payload['workspace'], '/var/mnt/test/clide-fixture');
|
||||
expect(payload['transport'], 'sse');
|
||||
expect(payload['url'], startsWith('http://127.0.0.1:${server.port}'));
|
||||
});
|
||||
|
||||
test('stop removes the lock file', () async {
|
||||
final lock = server.lockFilePath!;
|
||||
await server.stop();
|
||||
expect(File(lock).existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('unknown path returns 404', () async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/no-such-thing'));
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
});
|
||||
|
||||
group('McpServer (T-130) JSON-RPC', () {
|
||||
test('SSE opens with an endpoint event carrying the session id', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
expect(sessionId, isNotEmpty);
|
||||
});
|
||||
|
||||
test('initialize returns server info + tool capability', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
final replyFuture = events.firstWhere((s) => s.contains('"id":1'));
|
||||
await post(sessionId, {'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'});
|
||||
final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map<String, Object?>;
|
||||
expect(reply['id'], 1);
|
||||
final result = reply['result'] as Map<String, Object?>;
|
||||
expect((result['serverInfo'] as Map)['name'], 'clide');
|
||||
expect((result['capabilities'] as Map).containsKey('tools'), isTrue);
|
||||
});
|
||||
|
||||
test('tools/list lists both /ide tools per D-68', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
final replyFuture = events.firstWhere((s) => s.contains('"id":2'));
|
||||
await post(sessionId, {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'});
|
||||
final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map<String, Object?>;
|
||||
final tools = ((reply['result'] as Map)['tools'] as List).cast<Map<String, Object?>>();
|
||||
final names = tools.map((t) => t['name']).toSet();
|
||||
expect(names, containsAll(['mcp__ide__getDiagnostics', 'mcp__ide__executeCode']));
|
||||
});
|
||||
|
||||
test('tools/call mcp__ide__getDiagnostics returns the stub content', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
final replyFuture = events.firstWhere((s) => s.contains('"id":3'));
|
||||
await post(sessionId, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 3,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': 'mcp__ide__getDiagnostics', 'arguments': {}},
|
||||
});
|
||||
final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map<String, Object?>;
|
||||
final result = reply['result'] as Map<String, Object?>;
|
||||
final content = (result['content'] as List).cast<Map<String, Object?>>();
|
||||
expect(content.first['type'], 'text');
|
||||
// Stub returns []
|
||||
expect(content.first['text'], '[]');
|
||||
});
|
||||
|
||||
test('tools/call mcp__ide__executeCode flags isError (stubbed)', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
final replyFuture = events.firstWhere((s) => s.contains('"id":4'));
|
||||
await post(sessionId, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 4,
|
||||
'method': 'tools/call',
|
||||
'params': {
|
||||
'name': 'mcp__ide__executeCode',
|
||||
'arguments': {'code': 'print(1)'}
|
||||
},
|
||||
});
|
||||
final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map<String, Object?>;
|
||||
final result = reply['result'] as Map<String, Object?>;
|
||||
expect(result['isError'], isTrue);
|
||||
});
|
||||
|
||||
test('unknown method surfaces as a JSON-RPC error', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
final replyFuture = events.firstWhere((s) => s.contains('"id":5'));
|
||||
await post(sessionId, {'jsonrpc': '2.0', 'id': 5, 'method': 'no.such.method'});
|
||||
final reply = jsonDecode(await replyFuture.timeout(const Duration(seconds: 2))) as Map<String, Object?>;
|
||||
expect(reply['error'], isNotNull);
|
||||
});
|
||||
|
||||
test('POST with unknown sessionId returns 404', () async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=ghost'));
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}');
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test('POST with malformed JSON returns 400', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId'));
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.write('{not json');
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.badRequest);
|
||||
});
|
||||
|
||||
test('notification (no id) is processed without a reply', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
final lines = <String>[];
|
||||
final sub = events.listen(lines.add);
|
||||
addTearDown(sub.cancel);
|
||||
await post(sessionId, {'jsonrpc': '2.0', 'method': 'initialize'});
|
||||
// Give the server a tick — we should NOT see a reply.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
expect(lines, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
+60
-17
@@ -1,28 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/ipc/paths.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('defaultSocketPath', () {
|
||||
final originalXdg = Platform.environment['XDG_RUNTIME_DIR'];
|
||||
final originalUser = Platform.environment['USER'];
|
||||
group('workspaceSocketPath (D-70)', () {
|
||||
test('returns the FNV-1a hashed path under the socket directory', () {
|
||||
final p = workspaceSocketPath('/home/me/projects/clide');
|
||||
expect(p, startsWith('${socketDirectory()}/'));
|
||||
expect(p, endsWith('.sock'));
|
||||
// 16-char hex hash.
|
||||
final hash = p.split('/').last.replaceAll('.sock', '');
|
||||
expect(hash, matches(RegExp(r'^[0-9a-f]{16}$')));
|
||||
});
|
||||
|
||||
test('uses XDG_RUNTIME_DIR when set', () {
|
||||
// We can't mutate Platform.environment from dart:io, so this test
|
||||
// just asserts the path shape for the current env. CI and dev
|
||||
// boxes both have meaningful USER values.
|
||||
final path = defaultSocketPath();
|
||||
expect(path, endsWith('.sock'));
|
||||
expect(path, contains('clide-'));
|
||||
if (originalXdg != null && originalXdg.isNotEmpty) {
|
||||
expect(path, startsWith(originalXdg));
|
||||
test('is deterministic for the same input', () {
|
||||
expect(
|
||||
workspaceSocketPath('/home/me/repo'),
|
||||
workspaceSocketPath('/home/me/repo'),
|
||||
);
|
||||
});
|
||||
|
||||
test('different workspace roots hash to different paths', () {
|
||||
expect(
|
||||
workspaceSocketPath('/home/me/repo-a'),
|
||||
isNot(workspaceSocketPath('/home/me/repo-b')),
|
||||
);
|
||||
});
|
||||
|
||||
test('socketDirectory uses XDG_RUNTIME_DIR on Linux when set', () {
|
||||
if (Platform.isMacOS) return;
|
||||
final xdg = Platform.environment['XDG_RUNTIME_DIR'];
|
||||
if (xdg != null && xdg.isNotEmpty) {
|
||||
expect(socketDirectory(), '$xdg/clide');
|
||||
} else {
|
||||
expect(path, startsWith('/tmp'));
|
||||
}
|
||||
if (originalUser != null && originalUser.isNotEmpty) {
|
||||
expect(path, contains('clide-$originalUser'));
|
||||
expect(socketDirectory(), '/tmp/clide');
|
||||
}
|
||||
});
|
||||
|
||||
test('socketDirectory uses ~/Library/Caches on macOS', () {
|
||||
if (!Platform.isMacOS) return;
|
||||
final home = Platform.environment['HOME']!;
|
||||
expect(socketDirectory(), '$home/Library/Caches/clide');
|
||||
});
|
||||
});
|
||||
|
||||
group('fnv1a64Hex (T-126 cross-check)', () {
|
||||
// Reference values from <http://isthe.com/chongo/tech/comp/fnv/>.
|
||||
// The C client in native/clide-cli/clide.c MUST produce the same
|
||||
// 16-char hex strings for the same inputs, or server + client see
|
||||
// different socket paths and the integration falls apart silently.
|
||||
test('empty string → offset basis', () {
|
||||
expect(fnv1a64Hex(''), 'cbf29ce484222325');
|
||||
});
|
||||
|
||||
test('"a" → reference value', () {
|
||||
expect(fnv1a64Hex('a'), 'af63dc4c8601ec8c');
|
||||
});
|
||||
|
||||
test('"foo" → reference value', () {
|
||||
expect(fnv1a64Hex('foo'), 'dcb27518fed9d577');
|
||||
});
|
||||
|
||||
test('"/home/me/projects/clide" → 16 hex chars, deterministic', () {
|
||||
final h = fnv1a64Hex('/home/me/projects/clide');
|
||||
expect(h, matches(RegExp(r'^[0-9a-f]{16}$')));
|
||||
expect(fnv1a64Hex('/home/me/projects/clide'), h);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/// T-129 — event streaming over the IPC socket. Tests the
|
||||
/// `tail --events` subscription branch on the server: subscriber
|
||||
/// registration, per-subsystem replay-buffer (D-6 / replayDepth=16),
|
||||
/// filter matching, fanout on bus events, and broken-subscriber
|
||||
/// cleanup.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
Logger _silent() => Logger(minLevel: LogLevel.error, sinks: const []);
|
||||
|
||||
Future<Socket> _connect(IpcServer s) async => Socket.connect(
|
||||
InternetAddress(s.socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
|
||||
/// Wrap a Socket in a line iterator backed by a single broadcast
|
||||
/// stream so the same connection can read multiple framed lines.
|
||||
({Stream<String> lines, Socket sock}) _lineReader(Socket s) {
|
||||
final stream = s.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
||||
return (lines: stream, sock: s);
|
||||
}
|
||||
|
||||
/// Thin wrapper over StreamIterator with a [next] convenience.
|
||||
class _Lines {
|
||||
_Lines(Stream<String> s) : _it = StreamIterator(s);
|
||||
final StreamIterator<String> _it;
|
||||
Future<String> next({Duration timeout = const Duration(seconds: 2)}) async {
|
||||
final ok = await _it.moveNext().timeout(timeout);
|
||||
if (!ok) throw StateError('stream ended before next line');
|
||||
return _it.current;
|
||||
}
|
||||
|
||||
Future<void> cancel() => _it.cancel();
|
||||
}
|
||||
|
||||
Future<void> _send(Socket s, IpcRequest req) async {
|
||||
s.write('${req.encode()}\n');
|
||||
await s.flush();
|
||||
}
|
||||
|
||||
IpcRequest _tailReq({String? filter, String id = 't'}) => IpcRequest(
|
||||
id: id,
|
||||
cmd: 'tail',
|
||||
args: {
|
||||
'flags': {
|
||||
'events': true,
|
||||
if (filter != null) 'filter': filter,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
void main() {
|
||||
late Directory ws;
|
||||
late DaemonDispatcher dispatcher;
|
||||
late DaemonBus bus;
|
||||
late IpcServer server;
|
||||
|
||||
setUp(() async {
|
||||
ws = await Directory.systemTemp.createTemp('clide-stream-test-');
|
||||
dispatcher = DaemonDispatcher();
|
||||
bus = DaemonBus();
|
||||
server =
|
||||
IpcServer(dispatcher: dispatcher, workspaceRoot: '${ws.path}/${DateTime.now().microsecondsSinceEpoch}', log: _silent(), events: bus, replayDepth: 4);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (_) {}
|
||||
await bus.dispose();
|
||||
if (ws.existsSync()) ws.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('subscribe → streaming ack with filter echoed', () async {
|
||||
final s = await _connect(server);
|
||||
addTearDown(s.close);
|
||||
final r = _lineReader(s);
|
||||
await _send(s, _tailReq(filter: 'pane'));
|
||||
final line = await r.lines.first.timeout(const Duration(seconds: 2));
|
||||
final ack = IpcMessage.decode(line) as IpcResponse;
|
||||
expect(ack.ok, isTrue);
|
||||
expect(ack.data['streaming'], isTrue);
|
||||
expect(ack.data['filter'], 'pane');
|
||||
});
|
||||
|
||||
test('subscribe with no filter → wildcard ack', () async {
|
||||
final s = await _connect(server);
|
||||
addTearDown(s.close);
|
||||
final r = _lineReader(s);
|
||||
await _send(s, _tailReq());
|
||||
final line = await r.lines.first.timeout(const Duration(seconds: 2));
|
||||
final ack = IpcMessage.decode(line) as IpcResponse;
|
||||
expect(ack.data['filter'], '*');
|
||||
});
|
||||
|
||||
test('events emitted post-subscribe land on the subscriber', () async {
|
||||
final s = await _connect(server);
|
||||
addTearDown(s.close);
|
||||
final r = _lineReader(s);
|
||||
final lineQ = _Lines(r.lines);
|
||||
await _send(s, _tailReq(filter: 'pane'));
|
||||
await lineQ.next(); // ack
|
||||
bus.emit(DaemonEvent(subsystem: 'pane', kind: 'spawned', data: const {'id': 'p1'}, ts: DateTime.now().toUtc()));
|
||||
final evLine = await lineQ.next();
|
||||
final ev = IpcMessage.decode(evLine) as IpcEvent;
|
||||
expect(ev.subsystem, 'pane');
|
||||
expect(ev.kind, 'spawned');
|
||||
expect(ev.data['id'], 'p1');
|
||||
await lineQ.cancel();
|
||||
});
|
||||
|
||||
test('filter excludes non-matching subsystems', () async {
|
||||
final s = await _connect(server);
|
||||
addTearDown(s.close);
|
||||
final r = _lineReader(s);
|
||||
final lineQ = _Lines(r.lines);
|
||||
await _send(s, _tailReq(filter: 'pane'));
|
||||
await lineQ.next(); // ack
|
||||
// Emit a non-matching event first, then a matching one. The
|
||||
// subscriber should only see the matching one.
|
||||
bus.emit(DaemonEvent(subsystem: 'git', kind: 'changed', data: const {}, ts: DateTime.now().toUtc()));
|
||||
bus.emit(DaemonEvent(subsystem: 'pane', kind: 'closed', data: const {'id': 'p1'}, ts: DateTime.now().toUtc()));
|
||||
final ev = IpcMessage.decode(await lineQ.next()) as IpcEvent;
|
||||
expect(ev.subsystem, 'pane');
|
||||
expect(ev.kind, 'closed');
|
||||
await lineQ.cancel();
|
||||
});
|
||||
|
||||
test('replay buffer surfaces pre-subscribe events on connect', () async {
|
||||
// Push three events before any subscriber exists.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
bus.emit(DaemonEvent(subsystem: 'pane', kind: 'spawned', data: {'i': i}, ts: DateTime.now().toUtc()));
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
final s = await _connect(server);
|
||||
addTearDown(s.close);
|
||||
final r = _lineReader(s);
|
||||
final lineQ = _Lines(r.lines);
|
||||
await _send(s, _tailReq(filter: 'pane'));
|
||||
await lineQ.next(); // ack
|
||||
final replayed = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final ev = IpcMessage.decode(await lineQ.next()) as IpcEvent;
|
||||
replayed.add(ev.data['i'] as int);
|
||||
}
|
||||
expect(replayed, [0, 1, 2]);
|
||||
await lineQ.cancel();
|
||||
});
|
||||
|
||||
test('replay ring is bounded to replayDepth (4 for this test)', () async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
bus.emit(DaemonEvent(subsystem: 'pane', kind: 'spawned', data: {'i': i}, ts: DateTime.now().toUtc()));
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
final s = await _connect(server);
|
||||
addTearDown(s.close);
|
||||
final r = _lineReader(s);
|
||||
final lineQ = _Lines(r.lines);
|
||||
await _send(s, _tailReq(filter: 'pane'));
|
||||
await lineQ.next(); // ack
|
||||
final replayed = <int>[];
|
||||
for (var i = 0; i < 4; i++) {
|
||||
final ev = IpcMessage.decode(await lineQ.next()) as IpcEvent;
|
||||
replayed.add(ev.data['i'] as int);
|
||||
}
|
||||
// Last 4 of 0..9 → 6,7,8,9.
|
||||
expect(replayed, [6, 7, 8, 9]);
|
||||
await lineQ.cancel();
|
||||
});
|
||||
|
||||
test('multiple subscribers each receive an event independently', () async {
|
||||
final sA = await _connect(server);
|
||||
addTearDown(sA.close);
|
||||
final sB = await _connect(server);
|
||||
addTearDown(sB.close);
|
||||
final rA = _lineReader(sA);
|
||||
final rB = _lineReader(sB);
|
||||
final qA = _Lines(rA.lines);
|
||||
final qB = _Lines(rB.lines);
|
||||
await _send(sA, _tailReq(filter: 'pane', id: 'A'));
|
||||
await _send(sB, _tailReq(filter: 'pane', id: 'B'));
|
||||
await qA.next(); // ack
|
||||
await qB.next(); // ack
|
||||
bus.emit(DaemonEvent(subsystem: 'pane', kind: 'event', data: const {'tag': 'broadcast'}, ts: DateTime.now().toUtc()));
|
||||
final evA = IpcMessage.decode(await qA.next()) as IpcEvent;
|
||||
final evB = IpcMessage.decode(await qB.next()) as IpcEvent;
|
||||
expect(evA.data['tag'], 'broadcast');
|
||||
expect(evB.data['tag'], 'broadcast');
|
||||
await qA.cancel();
|
||||
await qB.cancel();
|
||||
});
|
||||
|
||||
test('server-side _argv unwrap routes through the streaming check', () async {
|
||||
// Sends an _argv envelope carrying `tail --events --filter pane`.
|
||||
// Without server-side unwrap (T-129), the streaming check would
|
||||
// miss it and the server would dispatch _argv → notFound for tail.
|
||||
final s = await _connect(server);
|
||||
addTearDown(s.close);
|
||||
final r = _lineReader(s);
|
||||
final q = _Lines(r.lines);
|
||||
await _send(
|
||||
s,
|
||||
IpcRequest(id: 'a', cmd: '_argv', args: {
|
||||
'argv': ['tail', '--events', '--filter', 'pane'],
|
||||
}));
|
||||
final ack = IpcMessage.decode(await q.next()) as IpcResponse;
|
||||
expect(ack.ok, isTrue);
|
||||
expect(ack.data['streaming'], isTrue);
|
||||
expect(ack.data['filter'], 'pane');
|
||||
await q.cancel();
|
||||
});
|
||||
|
||||
test('server-side _argv with non-list args returns userError', () async {
|
||||
final s = await _connect(server);
|
||||
addTearDown(s.close);
|
||||
final r = _lineReader(s);
|
||||
await _send(s, IpcRequest(id: 'b', cmd: '_argv', args: {'argv': 'not a list'}));
|
||||
final resp = IpcMessage.decode(await r.lines.first.timeout(const Duration(seconds: 2))) as IpcResponse;
|
||||
expect(resp.ok, isFalse);
|
||||
expect(resp.error?.message, contains('argv'));
|
||||
});
|
||||
|
||||
test('subscriber going away removes itself from fanout (no crash on emit)', () async {
|
||||
final s = await _connect(server);
|
||||
final r = _lineReader(s);
|
||||
final q = _Lines(r.lines);
|
||||
await _send(s, _tailReq(filter: 'pane'));
|
||||
await q.next(); // ack
|
||||
await q.cancel();
|
||||
await s.close();
|
||||
// Give the server's onDone a tick.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
// Emitting should not throw or stall — covered by reaching the
|
||||
// next assertion.
|
||||
bus.emit(DaemonEvent(subsystem: 'pane', kind: 'orphan', data: const {}, ts: DateTime.now().toUtc()));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(server.isRunning, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/paths.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
import 'package:clide/src/ipc/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Tests run with `XDG_RUNTIME_DIR` overridden to a per-test tempdir
|
||||
/// so the production `socketDirectory()` resolves under our control.
|
||||
/// Workspace roots are arbitrary strings; we don't need a real git
|
||||
/// repo because the path resolver only hashes the string.
|
||||
|
||||
void main() {
|
||||
late Directory xdg;
|
||||
late DaemonDispatcher dispatcher;
|
||||
late IpcServer server;
|
||||
late String workRoot;
|
||||
|
||||
setUp(() async {
|
||||
xdg = await Directory.systemTemp.createTemp('clide-ipc-test-');
|
||||
workRoot = '${xdg.path}/workspace-${DateTime.now().microsecondsSinceEpoch}';
|
||||
dispatcher = DaemonDispatcher();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (_) {}
|
||||
if (xdg.existsSync()) xdg.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<T> withXdg<T>(Future<T> Function() body) async {
|
||||
// dart:io's Platform.environment is read-only at the language
|
||||
// level but readable. Tests can't mutate it, so we mutate the
|
||||
// process env via Process.environment-equivalent: spawn a child
|
||||
// process. That's overkill — the simpler path is to override the
|
||||
// env vars our function reads by setting them BEFORE the test
|
||||
// runs. flutter_test exposes nothing for that. Easiest: skip if
|
||||
// we can't influence the path.
|
||||
//
|
||||
// Instead, the paths.dart functions are pure — we pass the
|
||||
// workspace root in. The XDG_RUNTIME_DIR fallback only matters
|
||||
// for the directory side. We rely on whatever XDG_RUNTIME_DIR is
|
||||
// set in the test runner's env; tests assert relative shape, not
|
||||
// absolute paths.
|
||||
return body();
|
||||
}
|
||||
|
||||
group('IpcServer (T-124)', () {
|
||||
test('start binds the socket at the per-workspace path', () async {
|
||||
await withXdg(() async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
expect(server.isRunning, isTrue);
|
||||
expect(server.socketPath, endsWith('.sock'));
|
||||
expect(File(server.socketPath).statSync().type, FileSystemEntityType.unixDomainSock);
|
||||
});
|
||||
});
|
||||
|
||||
test('socket file has mode 0600 and parent dir has 0700', () async {
|
||||
await withXdg(() async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final sock = File(server.socketPath).statSync();
|
||||
final parent = Directory(File(server.socketPath).parent.path).statSync();
|
||||
// FileStat.mode masks to the low 9 bits we care about.
|
||||
expect(sock.mode & 0x1ff, 0x180, reason: 'socket mode != 0600');
|
||||
expect(parent.mode & 0x1ff, 0x1c0, reason: 'parent mode != 0700');
|
||||
});
|
||||
});
|
||||
|
||||
test('a connected client gets a JSON-line response to ping', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final reply = await _roundTrip(server.socketPath, IpcRequest(id: '1', cmd: 'ping'));
|
||||
expect(reply.ok, isTrue);
|
||||
expect(reply.id, '1');
|
||||
expect(reply.data['pong'], isTrue);
|
||||
});
|
||||
|
||||
test('unknown command returns a notFound IpcError', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final reply = await _roundTrip(server.socketPath, IpcRequest(id: '2', cmd: 'no.such.cmd'));
|
||||
expect(reply.ok, isFalse);
|
||||
expect(reply.error?.kind, IpcErrorKind.notFound);
|
||||
});
|
||||
|
||||
test('malformed JSON line surfaces a userError', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final c = await Socket.connect(
|
||||
InternetAddress(server.socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
c.write('{not json\n');
|
||||
await c.flush();
|
||||
final line = await c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2));
|
||||
await c.close();
|
||||
final reply = IpcMessage.decode(line) as IpcResponse;
|
||||
expect(reply.ok, isFalse);
|
||||
expect(reply.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('multi-connection accept loop: two simultaneous clients both get replies', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final results = await Future.wait([
|
||||
_roundTrip(server.socketPath, IpcRequest(id: 'a', cmd: 'ping')),
|
||||
_roundTrip(server.socketPath, IpcRequest(id: 'b', cmd: 'version')),
|
||||
]);
|
||||
expect(results[0].id, 'a');
|
||||
expect(results[0].ok, isTrue);
|
||||
expect(results[1].id, 'b');
|
||||
expect(results[1].ok, isTrue);
|
||||
});
|
||||
|
||||
test('stop removes the socket file and lets a fresh server bind the same path', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final path = server.socketPath;
|
||||
await server.stop();
|
||||
expect(File(path).existsSync(), isFalse);
|
||||
// Same path can be re-bound on a new server.
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
expect(server.socketPath, path);
|
||||
expect(File(path).existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('stale socket file left behind is unlinked on start', () async {
|
||||
final path = workspaceSocketPath(workRoot);
|
||||
Directory(File(path).parent.path).createSync(recursive: true);
|
||||
File(path).writeAsBytesSync([]); // stale node, not a live listener
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
expect(server.isRunning, isTrue);
|
||||
});
|
||||
|
||||
test('refuses to clobber a live listener on the same path', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final other = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
expect(() async => other.start(), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
test('start is idempotent: second call on the same instance is a no-op', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
await server.start();
|
||||
expect(server.isRunning, isTrue);
|
||||
});
|
||||
|
||||
test('stop on a never-started server is a no-op', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.stop();
|
||||
expect(server.isRunning, isFalse);
|
||||
});
|
||||
|
||||
test('a handler that throws surfaces as a toolError response', () async {
|
||||
dispatcher.register('boom', (_) async => throw StateError('handler crash'));
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final reply = await _roundTrip(server.socketPath, IpcRequest(id: 'x', cmd: 'boom'));
|
||||
expect(reply.ok, isFalse);
|
||||
expect(reply.error?.kind, IpcErrorKind.toolError);
|
||||
expect(reply.error?.message, contains('handler crash'));
|
||||
});
|
||||
|
||||
test('stop closes an in-flight client connection', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final c = await Socket.connect(
|
||||
InternetAddress(server.socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
c.write('${IpcRequest(id: 'q', cmd: 'ping').encode()}\n');
|
||||
await c.flush();
|
||||
await c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first;
|
||||
await server.stop();
|
||||
expect(server.isRunning, isFalse);
|
||||
try {
|
||||
await c.close();
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
test('multiple sequential requests on the same connection each get a reply', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final c = await Socket.connect(
|
||||
InternetAddress(server.socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
final replies = c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||
final iter = StreamIterator(replies);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
c.write('${IpcRequest(id: '$i', cmd: 'ping').encode()}\n');
|
||||
await c.flush();
|
||||
expect(await iter.moveNext().timeout(const Duration(seconds: 2)), isTrue);
|
||||
final reply = IpcMessage.decode(iter.current) as IpcResponse;
|
||||
expect(reply.id, '$i');
|
||||
expect(reply.ok, isTrue);
|
||||
}
|
||||
await iter.cancel();
|
||||
await c.close();
|
||||
});
|
||||
|
||||
test('socketPath returns the resolved path before start (no bind)', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
// Before start, the getter falls back to workspaceSocketPath; it
|
||||
// must return the same path the server WOULD bind, so callers
|
||||
// can pre-publish it to clients.
|
||||
expect(server.socketPath, workspaceSocketPath(workRoot));
|
||||
expect(server.isRunning, isFalse);
|
||||
});
|
||||
|
||||
test('prepareParentDir creates the parent directory if it does not exist', () async {
|
||||
// Remove the parent dir if it happens to exist (created by a
|
||||
// previous test or by other clide instances on this host).
|
||||
// The test asserts the create-if-missing branch fires.
|
||||
final parent = Directory(socketDirectory());
|
||||
if (parent.existsSync() && parent.listSync().isEmpty) {
|
||||
parent.deleteSync();
|
||||
} else if (parent.existsSync()) {
|
||||
// Can't safely delete a populated shared dir; skip the
|
||||
// create branch and at least exercise the chmod branch.
|
||||
}
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
expect(parent.existsSync(), isTrue);
|
||||
expect((parent.statSync().mode) & 0x1ff, 0x1c0);
|
||||
});
|
||||
|
||||
test('a non-request message (e.g. event) surfaces a userError', () async {
|
||||
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
|
||||
await server.start();
|
||||
final c = await Socket.connect(
|
||||
InternetAddress(server.socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
final evt = IpcEvent(subsystem: 'test', kind: 'wrong-shape', timestamp: DateTime.now().toUtc());
|
||||
c.write('${evt.encode()}\n');
|
||||
await c.flush();
|
||||
final line = await c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2));
|
||||
await c.close();
|
||||
final reply = IpcMessage.decode(line) as IpcResponse;
|
||||
expect(reply.ok, isFalse);
|
||||
expect(reply.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Logger _silentLog() => Logger(minLevel: LogLevel.error, sinks: const []);
|
||||
|
||||
Future<IpcResponse> _roundTrip(String socketPath, IpcRequest req) async {
|
||||
final c = await Socket.connect(
|
||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
c.write('${req.encode()}\n');
|
||||
await c.flush();
|
||||
final line = await c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2));
|
||||
await c.close();
|
||||
return IpcMessage.decode(line) as IpcResponse;
|
||||
}
|
||||
@@ -276,5 +276,45 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
expect(flips, contains(true));
|
||||
});
|
||||
|
||||
test('reconnectAt swaps socket paths and re-binds (T-127)', () async {
|
||||
final pathA = await _tmpSocket();
|
||||
final daemonA = _TestDaemon(pathA);
|
||||
await daemonA.start();
|
||||
addTearDown(daemonA.close);
|
||||
|
||||
final bus = DaemonBus();
|
||||
addTearDown(bus.dispose);
|
||||
final client = _build(pathA, bus);
|
||||
addTearDown(client.dispose);
|
||||
await client.start();
|
||||
await daemonA.waitForClient();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(client.isConnected, isTrue);
|
||||
expect(client.socketPath, pathA);
|
||||
|
||||
// Spin up a SECOND daemon on a different socket and move the
|
||||
// client over to it.
|
||||
final pathB = await _tmpSocket();
|
||||
final daemonB = _TestDaemon(pathB);
|
||||
await daemonB.start();
|
||||
addTearDown(daemonB.close);
|
||||
|
||||
await client.reconnectAt(pathB);
|
||||
await daemonB.waitForClient();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(client.socketPath, pathB);
|
||||
expect(client.isConnected, isTrue);
|
||||
|
||||
// A request goes to the NEW daemon — verify by reading the
|
||||
// line off daemonB.lines.
|
||||
final lineFuture = daemonB.lines.first;
|
||||
final responseFuture = client.request('ping');
|
||||
final reqLine = await lineFuture;
|
||||
final reqJson = jsonDecode(reqLine) as Map<String, Object?>;
|
||||
daemonB.send(IpcResponse.ok(id: reqJson['id'] as String, data: const {'pong': true}).encode());
|
||||
final resp = await responseFuture;
|
||||
expect(resp.ok, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('parseIntentId', () {
|
||||
test('returns null for an unknown id', () {
|
||||
expect(parseIntentId('not.a.real.intent'), isNull);
|
||||
});
|
||||
|
||||
test('returns ActivateIntent for "activate"', () {
|
||||
expect(parseIntentId('activate'), isA<ActivateIntent>());
|
||||
});
|
||||
|
||||
test('returns DismissIntent for "dismiss"', () {
|
||||
expect(parseIntentId('dismiss'), isA<DismissIntent>());
|
||||
});
|
||||
|
||||
test('returns the focus.* intents', () {
|
||||
expect(parseIntentId('focus.nextPanel'), isA<FocusNextPanelIntent>());
|
||||
expect(parseIntentId('focus.previousPanel'), isA<FocusPreviousPanelIntent>());
|
||||
});
|
||||
|
||||
test('returns the palette.* intents', () {
|
||||
expect(parseIntentId('palette.open'), isA<PaletteOpenIntent>());
|
||||
expect(parseIntentId('palette.selectNext'), isA<PaletteSelectNextIntent>());
|
||||
expect(parseIntentId('palette.selectPrevious'), isA<PaletteSelectPreviousIntent>());
|
||||
expect(parseIntentId('palette.accept'), isA<PaletteAcceptIntent>());
|
||||
});
|
||||
|
||||
test('returns the text.scale* intents', () {
|
||||
expect(parseIntentId('text.scaleIncrease'), isA<TextScaleIncreaseIntent>());
|
||||
expect(parseIntentId('text.scaleDecrease'), isA<TextScaleDecreaseIntent>());
|
||||
expect(parseIntentId('text.scaleReset'), isA<TextScaleResetIntent>());
|
||||
});
|
||||
|
||||
test('returns InvokeCommandIntent for "command:<id>" with the id stripped', () {
|
||||
final intent = parseIntentId('command:theme.pick');
|
||||
expect(intent, isA<InvokeCommandIntent>());
|
||||
expect((intent as InvokeCommandIntent).commandId, 'theme.pick');
|
||||
});
|
||||
|
||||
test('returns InvokeCommandIntent with an empty commandId for "command:"', () {
|
||||
final intent = parseIntentId('command:');
|
||||
expect(intent, isA<InvokeCommandIntent>());
|
||||
expect((intent as InvokeCommandIntent).commandId, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/// Tests the kernel-side bridge that connects the Flutter-free
|
||||
/// `panel.resize` handler (T-119) to the live [LayoutArrangement].
|
||||
/// The handler-side tests live in
|
||||
/// `test/daemon/panel_commands_test.dart` against an in-memory fake.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/daemon/panel_resizer_kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('ArrangementPanelResizer', () {
|
||||
late LayoutArrangement arrangement;
|
||||
late ArrangementPanelResizer r;
|
||||
|
||||
setUp(() {
|
||||
arrangement = LayoutArrangement()..applyPreset(classicPreset());
|
||||
r = ArrangementPanelResizer(arrangement);
|
||||
});
|
||||
|
||||
test('setSlotSize forwards to LayoutArrangement.setSize and clamps', () {
|
||||
final ok = r.setSlotSize('sidebar', 10000);
|
||||
expect(ok, isTrue);
|
||||
expect(arrangement.sizeOf(Slots.sidebar), arrangement.maxSizeOf(Slots.sidebar));
|
||||
expect(r.currentSlotSize('sidebar'), arrangement.sizeOf(Slots.sidebar));
|
||||
});
|
||||
|
||||
test('setSlotSize returns false for an unknown slot', () {
|
||||
expect(r.setSlotSize('does-not-exist', 250), isFalse);
|
||||
});
|
||||
|
||||
test('bumpSlotSize applies the T-111 sign-flip on context panel', () {
|
||||
final start = arrangement.sizeOf(Slots.contextPanel)!;
|
||||
final ok = r.bumpSlotSize('context', 40);
|
||||
expect(ok, isTrue);
|
||||
// Context sits on the right edge — positive delta shrinks it.
|
||||
expect(arrangement.sizeOf(Slots.contextPanel), lessThan(start));
|
||||
});
|
||||
|
||||
test('bumpSlotSize returns false for an unknown slot', () {
|
||||
expect(r.bumpSlotSize('nope', 5), isFalse);
|
||||
});
|
||||
|
||||
test('setEditorRatio + currentEditorRatio round-trip through arrangement', () {
|
||||
r.setEditorRatio(0.5);
|
||||
expect(arrangement.editorRatio, 0.5);
|
||||
expect(r.currentEditorRatio, 0.5);
|
||||
});
|
||||
|
||||
test('bumpEditorRatio adds to current ratio (kernel re-clamps)', () {
|
||||
r.setEditorRatio(0.4);
|
||||
r.bumpEditorRatio(0.2);
|
||||
expect(arrangement.editorRatio, closeTo(0.60, 1e-9));
|
||||
r.bumpEditorRatio(1.0); // out-of-range; kernel clamps to 0.70
|
||||
expect(arrangement.editorRatio, 0.70);
|
||||
});
|
||||
});
|
||||
}
|
||||
Binary file not shown.
@@ -156,6 +156,23 @@ void main() {
|
||||
expect(store.get<List>('app.list'), [1, 'two', null, false]);
|
||||
});
|
||||
|
||||
test('YAML emitter handles nested lists and empty maps', () async {
|
||||
// Nested list — forces _emitScalar's `v is List` recursive branch.
|
||||
await store.set<Object>('app.nested', [
|
||||
[1, 2],
|
||||
['a', 'b'],
|
||||
]);
|
||||
// Empty map under an app.* key — forces _emit's empty-map branch.
|
||||
// Use a key whose value is itself a Map.
|
||||
await store.set<Object>('app.empty', <String, Object?>{});
|
||||
// Round-trip.
|
||||
await store.load();
|
||||
expect(store.get<List>('app.nested'), [
|
||||
[1, 2],
|
||||
['a', 'b'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('load tolerates a malformed YAML file', () async {
|
||||
// Write garbage to the on-disk app settings, then load.
|
||||
final f = File('${tmp.path}/settings.yaml');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/// gesture / keyboard / scroll / render plumbing around a `Terminal`.
|
||||
library;
|
||||
|
||||
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
|
||||
import 'package:clide/src/terminal/src/terminal.dart';
|
||||
import 'package:clide/src/terminal/src/terminal_view.dart';
|
||||
import 'package:clide/src/terminal/src/ui/controller.dart';
|
||||
@@ -97,6 +98,32 @@ void main() {
|
||||
expect(r.outputs, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('PointerScrollEvent forwards as xterm wheel escapes when mouseMode.reportScroll (T-74)', (tester) async {
|
||||
final r = _OutputRecorder();
|
||||
final t = r.build();
|
||||
// Have the inner program declare ?1000h (upDownScroll) so
|
||||
// mouseMode.reportScroll becomes true.
|
||||
t.setMouseMode(MouseMode.upDownScroll);
|
||||
await tester.pumpWidget(_host(TerminalView(t)));
|
||||
final viewCenter = tester.getCenter(find.byType(TerminalView));
|
||||
final testGesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await testGesture.addPointer(location: viewCenter);
|
||||
await tester.sendEventToBinding(PointerScrollEvent(
|
||||
position: viewCenter,
|
||||
scrollDelta: const Offset(0, 100),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(r.outputs, isNotEmpty);
|
||||
// Wheel-down id is 64+5=69; normal-mode reporter encodes button
|
||||
// bytes as 32+id, but the reporter chunks across modes — the
|
||||
// load-bearing assertion is "no PgDn key escape was emitted".
|
||||
// PgDn under default keyboard sends ESC[6~. Scrolls in mouse
|
||||
// mode must NOT contain that — they contain CSI M / SGR mouse
|
||||
// sequences instead.
|
||||
final combined = r.outputs.join();
|
||||
expect(combined.contains('\x1b[6~'), isFalse, reason: 'expected wheel forwarded as mouse, not PgDn');
|
||||
});
|
||||
|
||||
testWidgets('non-scroll PointerSignalEvent is a no-op', (tester) async {
|
||||
final r = _OutputRecorder();
|
||||
final t = r.build();
|
||||
|
||||
Reference in New Issue
Block a user