Compare commits
53
Commits
v2.1.0
..
b52283b7c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b52283b7c0 | ||
|
|
b224502658 | ||
|
|
caf8fd95cf | ||
|
|
e1cd4653c0 | ||
|
|
24f68e7aaa | ||
|
|
4a59ea4137 | ||
|
|
fc55f58e6b | ||
|
|
cda289b8b1 | ||
|
|
9783e6bd2c | ||
|
|
ce5996f869 | ||
|
|
6910c79e5b | ||
|
|
0abc14ed1a | ||
|
|
8fd251ac48 | ||
|
|
b3fa69c982 | ||
|
|
0b942db251 | ||
|
|
a6ed22405f | ||
|
|
76c1d24904 | ||
|
|
3f2e5ad9a9 | ||
|
|
0eee456426 | ||
|
|
9fed749084 | ||
|
|
04aed1e851 | ||
|
|
cec36ff173 | ||
|
|
d7c935977e | ||
|
|
203f8e7681 | ||
|
|
f1f12e7d79 | ||
|
|
fd72940d2d | ||
|
|
498bbc1205 | ||
|
|
97c83b8fc9 | ||
|
|
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,22 @@
|
||||
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);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'T-138', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '006ad7aafbd995638eb556d4d2dad269', 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-134', 'T-138', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '4b3b69867b7ffc2875284c92ccf826e3', 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-139', 'T-140', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '4c13d1852912bb226d41db62f89a915d', 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-134', 'T-139', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '7d6ecc13b0edada978e05e19186a70a3', 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-137', 'T-140', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '8fdddd396b50b61c9a1802b7250b4a1c', 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-135', 'T-137', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, 'ae447ef31c40b8003cb8e372ec0b23b0', 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-136', 'T-137', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, 'cb44e9c64d3051a320f314e2d81ee552', 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-139', 'T-141', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, 'e2c7e7ea34fb4b007bd3d384ab788ae7', 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-145', 'T-141', '2026-05-23 06:08:24', '2026-05-23 06:08:24', NULL, '1426109288c2a7bbf5a3190940e09fbd', 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,384 @@ 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;
|
||||
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', 'in_progress', 'done', NULL, '2026-05-20 07:00:06', '2026-05-20 07:00:06', '2026-05-20 07:00:06', NULL, '1f2c2bcc722417d8f4c5bfa27faf0c93', 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.
|
||||
|
||||
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.', '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.
|
||||
|
||||
---
|
||||
Design resolved 2026-05-20 (see D-74). Co-registration model, NOT a central map:
|
||||
|
||||
- The argv parser (lib/src/cli/argv_to_request.dart) only knows syntactic shape (identifier/flag charset, -- terminator); it has NO per-command argument knowledge. So this is build-fresh, not a lift — each handler currently hand-reads its own keys.
|
||||
- Extend the DaemonDispatcher registration API to carry an optional typed schema per command. The dispatcher accumulates a cmd->schema registry as commands register (built-in register*Commands modules supply theirs; extension CommandContributions carry their own — central static map rejected because it cannot see extension-contributed commands, D-46).
|
||||
- DaemonDispatcher.dispatch validates req.args against schema[cmd] BEFORE invoking the handler (hook at dispatcher.dart line 39, the `return h(req)` call). Violation -> userError (sysexit 64). Commands with no schema dispatch unvalidated (opt-in per command) until migrated.
|
||||
- Constraint vocabulary is hand-rolled (prefer-zero-deps): per-arg required/optional, kind, charset/regex, numeric range. Collapses the _ResizeArgs-style hand-lifts (panel_commands.dart, T-119) into schema.
|
||||
- KEEP existing T-104 checks as defense-in-depth: validateGitRef (lib/src/git/operations.dart:46) and the count/path caps (lib/src/daemon/git_commands.dart:18,23). The git client is callable directly from the Flutter UI, not only via the dispatcher — do not remove them.
|
||||
- OUT OF SCOPE: MCP tools/list generation from the registry — deferred to the T-130 track.
|
||||
|
||||
Acceptance:
|
||||
1. Dispatcher validates req.args against a registered per-command schema before the handler runs; violations return userError.
|
||||
2. At least the security-relevant commands (git ref/remote/path args, panel.resize) carry schemas; argv-injection + caps still enforced (no regression vs T-104).
|
||||
3. validateGitRef + git caps retained as defense-in-depth.
|
||||
4. No third-party validation dep; no lint suppressions.
|
||||
5. Tests cover: schema accept/reject per constraint kind, dispatcher-level rejection, and that an unschema-d command still dispatches.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]" item 1. Decision: D-74.', NULL, '2026-05-20 15:54:35', '2026-05-20 15:54:35', '2026-05-20 15:54:35', NULL, '7f972739c82134897869432a3978d6ec', 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', 'status', 'backlog', 'in_progress', NULL, '2026-05-20 15:54:38', '2026-05-20 15:54:38', '2026-05-20 15:54:38', NULL, 'fa11190ac1bd9cd8f67e3041a8ca7236', 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', 'status', 'in_progress', 'done', NULL, '2026-05-20 16:02:35', '2026-05-20 16:02:35', '2026-05-20 16:02:35', NULL, 'efffdd07c7281977d37ce0ad4285a36a', 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-97', 'status', 'backlog', 'done', NULL, '2026-05-20 16:02:46', '2026-05-20 16:02:46', '2026-05-20 16:02:46', NULL, '9c36d26b5ac3702441a87c7f88dbf830', 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-122', 'description', '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).', 'T-115 follow-up: tap-driven widget test for sticky-startup toggle.
|
||||
|
||||
Adding a tap-driven (or even render-only) test that pumps WelcomeView with a non-empty recents list hangs the Flutter test runner until timeout.
|
||||
|
||||
Investigation 2026-05-22 (narrowed the cause):
|
||||
- The hang is SYNCHRONOUS: `flutter test --timeout 45s` never fires; only an external `timeout` kills it. So the Dart isolate event loop is blocked, not awaiting.
|
||||
- Reduced to a minimal repro: pumping two NESTED ClideTappable widgets under the shared test harness (test/helpers/widget_harness.dart) hangs. A SINGLE ClideTappable (as in _ActionRow) renders fine — that is why all existing WelcomeView tests pass (empty recents = no nested tappable). The recents path nests one: _RecentRow is a ClideTappable whose subtree contains _StickyToggle (a Semantics-wrapped ClideTappable).
|
||||
- The shared harness uses `Overlay(canSizeOverlay: true)` over a zero-size `MediaQuery`, which triggers an intrinsic-sizing pass. Strongly suspect the nested ClideTappable (Focus + MouseRegion + GestureDetector + DecoratedBox stack) diverges under intrinsic dimension computation.
|
||||
- Workarounds that did NOT help: setting tester.view.physicalSize; wrapping in SizedBox; wrapping in Center+SizedBox; a custom bounded MediaQuery harness (that custom harness hung even on a single tappable — likely its own confound, do not reuse it).
|
||||
- The prior ClideTooltip/MouseRegion-timer hypothesis is WRONG: the recents widgets pass no tooltip to ClideTappable, so no ClideTooltip is built, and _StickyToggle uses Semantics(tooltip:) (metadata only, no widget/timer).
|
||||
|
||||
Next steps:
|
||||
- Decide whether this is a real ClideTappable bug (nested tappables would also hang the live app welcome screen with recents) or strictly a canSizeOverlay-intrinsic-sizing test artifact. Check the running app: open welcome with >=1 recent project and confirm it does NOT hang. If the app is fine, the fix is harness-side (give WelcomeView tight constraints so no intrinsic pass), and the shared harness or a welcome-specific harness needs adjusting. If the app DOES hang, ClideTappable has a real nested-layout bug — fix it (likely in lib/widgets/src/clide_tappable.dart intrinsic/layout handling) and this becomes higher priority.
|
||||
- Once unblocked, this also unblocks lib/builtin/welcome/src/welcome_view.dart coverage (74 uncovered lines, the dominant lib/builtin/ gap toward the T-89 95% target).
|
||||
|
||||
ProjectManager sticky-startup logic is already covered by test/kernel/src/project_test.dart (14 cases).', NULL, '2026-05-22 06:55:02', '2026-05-22 06:55:02', '2026-05-22 06:55:02', NULL, 'b7c13548cef5d962c7b5b4500ea39b9e', 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-122', 'priority', 'low', 'medium', NULL, '2026-05-22 06:55:02', '2026-05-22 06:55:02', '2026-05-22 06:55:02', NULL, 'e7d5dd1b89eded661de7f6d64edea04c', 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-89', 'description', 'Today: total line coverage **34.9%** (2984/8549 lines). Per area:
|
||||
|
||||
| area | hit / total | % |
|
||||
|---|---|---|
|
||||
| `lib/src/terminal/` | 12 / 3167 | **0.4%** |
|
||||
| `lib/widgets/` | 537 / 1184 | 45.4% |
|
||||
| `lib/src/pql/` | 45 / 94 | 47.9% |
|
||||
| `lib/builtin/` | 269 / 550 | 48.9% |
|
||||
| `lib/kernel/` | 889 / 1691 | 52.6% |
|
||||
| `lib/src/daemon/` | 320 / 584 | 54.8% |
|
||||
| `lib/src/pty/` | 130 / 216 | 60.2% |
|
||||
| `lib/src/git/` | 356 / 518 | 68.7% |
|
||||
| `lib/src/ipc/` | 110 / 156 | 70.5% |
|
||||
| `lib/src/files/` | 98 / 133 | 73.7% |
|
||||
| `lib/extension/` | 41 / 54 | 75.9% |
|
||||
| `lib/src/editor/` | 107 / 129 | 82.9% |
|
||||
| `lib/src/panes/` | 70 / 73 | 95.9% |
|
||||
|
||||
The dominant hole is `lib/src/terminal/` — the terminal emulator code. Header still credits xterm.dart upstream, but per CLAUDE.md ("Own the rendering stack") and the explicit guidance attached to this epic: in-tree code is owned, no carve-outs, same coverage bar.
|
||||
|
||||
**End state:**
|
||||
- Total line coverage ≥ 95% (no per-area carve-outs).
|
||||
- Pre-push gate hard-fails on coverage drops via a committed floor (`coverage/floor.txt`); floor ratchets up only.
|
||||
- Per D-66 the gate lands at the *current* floor in the first child ticket; subsequent floor bumps come with the test-writing children.
|
||||
|
||||
**Acceptance criteria for closing this epic:**
|
||||
1. `coverage/floor.txt` reads `95` (or higher).
|
||||
2. `flutter test --coverage --exclude-tags forkpty` produces ≥95% line coverage.
|
||||
3. The pre-push gate computes coverage and rejects pushes below the floor.
|
||||
4. No new `// ignore:` / `// ignore_for_file:` / analysis-options excludes were added to dodge the gate. Any suppression added during this campaign needs explicit approval and an inline reason at the suppression site.
|
||||
|
||||
**Strategy / child shape:**
|
||||
The gate plumbing lands first so future test additions are visible as floor bumps. Then per-area sweeps — biggest holes first because they move the global number fastest, but each area is its own child ticket with its own scope.
|
||||
|
||||
1. **Gate plumbing** (first child) — install the ratchet, lock in current floor, expose `make coverage-gate` and wire it into `make push-check`.
|
||||
2. **`lib/src/terminal/` sweep** (second child, by far the largest) — likely splits into sub-children once the area''s structure is mapped (parser, buffer, painter, gesture handling, mouse reporting, escape sequences, cell-grid measurement, etc.).
|
||||
3. **Per-area sweeps** — claim a child ticket when starting an area; close when the area hits 95%. Order by impact: terminal → widgets → pql → builtin → kernel → daemon → pty → git → ipc → files → extension → editor.
|
||||
4. **Final ratchet** — once total ≥ 95%, set floor to 95 and close the epic.
|
||||
|
||||
**Out of scope:**
|
||||
- Branch coverage (lcov data is weak for branch on Dart; line coverage is the contract).
|
||||
- Integration / E2E coverage (only widget + unit feed lcov).
|
||||
- `lib/test_app.dart` exclusion: verify in the gate child whether Dart''s coverage tooling already excludes it; if not, decide explicitly rather than carve it out silently.
|
||||
|
||||
**Cross-references:** D-66 (this epic''s decision record), D-29 (pre-push gate scope), "Own the rendering stack" guardrail (CLAUDE.md).
|
||||
', '## OUTCOME (closed 2026-05-18)
|
||||
|
||||
Epic met. Final measured line coverage **95.27%** (9551 / 10025), floor `coverage_floor: 95` in `pubspec.yaml`, gate wired into `make push-check`.
|
||||
|
||||
Final per-area (vs. opening 34.9% total):
|
||||
|
||||
| area | hit / total | % |
|
||||
|---|---|---|
|
||||
| `lib/src/terminal/` | 3185 / 3188 | **99.9%** (was 0.4%) |
|
||||
| `lib/extension/` | 54 / 54 | 100.0% |
|
||||
| `lib/src/editor/` | 129 / 129 | 100.0% |
|
||||
| `lib/src/files/` | 139 / 140 | 99.3% |
|
||||
| `lib/src/pql/` | 91 / 92 | 98.9% |
|
||||
| `lib/src/panes/` | 75 / 76 | 98.7% |
|
||||
| `lib/src/git/` | 517 / 531 | 97.4% |
|
||||
| `lib/src/daemon/` | 616 / 644 | 95.7% |
|
||||
| `lib/kernel/` | 2122 / 2233 | 95.0% |
|
||||
| `lib/src/ipc/` | 429 / 452 | 94.9% |
|
||||
| `lib/widgets/` | 1178 / 1261 | 93.4% |
|
||||
| `lib/builtin/` | 754 / 875 | 86.2% |
|
||||
| `lib/src/pty/` | 177 / 262 | 67.6% (forkpty paths excluded from gate) |
|
||||
|
||||
Acceptance: (1) floor reads 95 done (2) total >=95% done (3) gate hard-fails below floor done (4) no coverage-dodging suppressions done. The strategy''s tail per-area sweeps (widgets/builtin/pty) didn''t all individually reach 95%, but the contract is *total* line coverage, which is satisfied; remaining per-area headroom can be its own tickets if desired.
|
||||
|
||||
---
|
||||
|
||||
Today: total line coverage **34.9%** (2984/8549 lines). Per area:
|
||||
|
||||
| area | hit / total | % |
|
||||
|---|---|---|
|
||||
| `lib/src/terminal/` | 12 / 3167 | **0.4%** |
|
||||
| `lib/widgets/` | 537 / 1184 | 45.4% |
|
||||
| `lib/src/pql/` | 45 / 94 | 47.9% |
|
||||
| `lib/builtin/` | 269 / 550 | 48.9% |
|
||||
| `lib/kernel/` | 889 / 1691 | 52.6% |
|
||||
| `lib/src/daemon/` | 320 / 584 | 54.8% |
|
||||
| `lib/src/pty/` | 130 / 216 | 60.2% |
|
||||
| `lib/src/git/` | 356 / 518 | 68.7% |
|
||||
| `lib/src/ipc/` | 110 / 156 | 70.5% |
|
||||
| `lib/src/files/` | 98 / 133 | 73.7% |
|
||||
| `lib/extension/` | 41 / 54 | 75.9% |
|
||||
| `lib/src/editor/` | 107 / 129 | 82.9% |
|
||||
| `lib/src/panes/` | 70 / 73 | 95.9% |
|
||||
|
||||
The dominant hole is `lib/src/terminal/` — the terminal emulator code. Header still credits xterm.dart upstream, but per CLAUDE.md ("Own the rendering stack") and the explicit guidance attached to this epic: in-tree code is owned, no carve-outs, same coverage bar.
|
||||
|
||||
**End state:**
|
||||
- Total line coverage ≥ 95% (no per-area carve-outs).
|
||||
- Pre-push gate hard-fails on coverage drops via a committed floor (`coverage/floor.txt`); floor ratchets up only.
|
||||
- Per D-66 the gate lands at the *current* floor in the first child ticket; subsequent floor bumps come with the test-writing children.
|
||||
|
||||
**Acceptance criteria for closing this epic:**
|
||||
1. `coverage/floor.txt` reads `95` (or higher).
|
||||
2. `flutter test --coverage --exclude-tags forkpty` produces ≥95% line coverage.
|
||||
3. The pre-push gate computes coverage and rejects pushes below the floor.
|
||||
4. No new `// ignore:` / `// ignore_for_file:` / analysis-options excludes were added to dodge the gate. Any suppression added during this campaign needs explicit approval and an inline reason at the suppression site.
|
||||
|
||||
**Strategy / child shape:**
|
||||
The gate plumbing lands first so future test additions are visible as floor bumps. Then per-area sweeps — biggest holes first because they move the global number fastest, but each area is its own child ticket with its own scope.
|
||||
|
||||
1. **Gate plumbing** (first child) — install the ratchet, lock in current floor, expose `make coverage-gate` and wire it into `make push-check`.
|
||||
2. **`lib/src/terminal/` sweep** (second child, by far the largest) — likely splits into sub-children once the area''s structure is mapped (parser, buffer, painter, gesture handling, mouse reporting, escape sequences, cell-grid measurement, etc.).
|
||||
3. **Per-area sweeps** — claim a child ticket when starting an area; close when the area hits 95%. Order by impact: terminal → widgets → pql → builtin → kernel → daemon → pty → git → ipc → files → extension → editor.
|
||||
4. **Final ratchet** — once total ≥ 95%, set floor to 95 and close the epic.
|
||||
|
||||
**Out of scope:**
|
||||
- Branch coverage (lcov data is weak for branch on Dart; line coverage is the contract).
|
||||
- Integration / E2E coverage (only widget + unit feed lcov).
|
||||
- `lib/test_app.dart` exclusion: verify in the gate child whether Dart''s coverage tooling already excludes it; if not, decide explicitly rather than carve it out silently.
|
||||
|
||||
**Cross-references:** D-66 (this epic''s decision record), D-29 (pre-push gate scope), "Own the rendering stack" guardrail (CLAUDE.md).
|
||||
', NULL, '2026-05-22 13:37:43', '2026-05-22 13:37:43', '2026-05-22 13:37:43', NULL, 'b83aa45596e35a95e689b922c66c27ef', 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-89', 'status', 'in_progress', 'done', NULL, '2026-05-22 13:37:46', '2026-05-22 13:37:46', '2026-05-22 13:37:46', NULL, '5698b8e8cbd5113a84791ae2dad26c6d', 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-91', 'status', 'in_progress', 'done', NULL, '2026-05-22 13:37:46', '2026-05-22 13:37:46', '2026-05-22 13:37:46', NULL, 'dad314c05e04d8f17357ac2ea054a9bd', 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-134', 'description', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7.', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review):
|
||||
- Environment confirmed: tmux 3.6a; claude 2.1.148 (the version an-idea.md verified for tmux teammate spawning) -- PIN findings to 2.1.148. teammateMode absent from ~/.claude/settings.json (default auto) -> set teammateMode:tmux explicitly OR launch the lead inside tmux and rely on auto.
|
||||
- Check 3 ALREADY answered from a real ~/.claude/teams/*/config.json: {name,description,createdAt,leadAgentId,leadSessionId,members[]}; member = {agentId,name,agentType,model,joinedAt,tmuxPaneId (empty for lead, e.g. %154 for teammates),cwd,subscriptions,backendType:''tmux'',isActive,mode}. tmuxPaneId DOES populate -- spike just re-verifies on the pinned version.
|
||||
- Subagent path: <munged>/<session-uuid>/subagents/agent-<id>.jsonl (session UUID is a directory).
|
||||
- Check 5 (session-id) resolved by T-136: newest *.jsonl by mtime. Spike confirms only.
|
||||
- tmux -L clide socket may not exist unless the app is running; spike creates its own: tmux -L clide new-session -d -s teamhost ... claude.
|
||||
- Check 4 (paste): scope to @path file references (verifiable via send-keys headlessly). Clipboard-image needs an interactive display ($DISPLAY) -- note as interactive-only.
|
||||
- ACP directionality (an-idea.md s5) is OUT OF SCOPE: Zed-client concern; clide owns its GUI and does not use ACP.
|
||||
- Deliverable: findings note at docs/spikes/cc-team-internals-2.1.148.md (in-repo), feeding D-75 when authored (T-133). D-75 is claimed-but-unauthored (T-133''s job) -- the reference is forward-looking, not dangling.', NULL, '2026-05-22 16:41:09', '2026-05-22 16:41:09', '2026-05-22 16:41:09', NULL, '05456ee9ae72721893f6850158d59791', 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-135', 'description', 'Make clide''s native text widgets selectable so a SelectionArea can select across them. Change lib/widgets/src/clide_markdown.dart RichText (line ~68) -> Text.rich; same for lib/widgets/src/clide_code_block.dart; add a selectionBackground token to SurfaceTokens (lib/kernel/src/theme/tokens.dart + resolver + bundled theme YAMLs); verify copy via existing ClideClipboard (lib/kernel/src/clipboard.dart). Acceptance: a SelectionArea-wrapped column of markdown + code blocks supports select-across-blocks + Ctrl+C copy; widget tests; a11y unaffected; coverage >= floor. Reusable foundation. Blocks T5.', 'Make clide''s native text widgets selectable so a SelectionArea can select across them. Change lib/widgets/src/clide_markdown.dart RichText (line ~68) -> Text.rich; same for lib/widgets/src/clide_code_block.dart; add a selectionBackground token to SurfaceTokens (lib/kernel/src/theme/tokens.dart + resolver + bundled theme YAMLs); verify copy via existing ClideClipboard (lib/kernel/src/clipboard.dart). Acceptance: a SelectionArea-wrapped column of markdown + code blocks supports select-across-blocks + Ctrl+C copy; widget tests; a11y unaffected; coverage >= floor. Reusable foundation. Blocks T5.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review):
|
||||
- Convert ALL RichText sites, not one: clide_markdown.dart lines 68, 238, 242 + clide_code_block.dart:79 -> Text.rich (Text.rich wires the SelectionArea selectionRegistrar; same underlying render object).
|
||||
- Tables (clide_markdown.dart:230) and tappable WidgetSpan links (ClideTappable ~293-310) do NOT participate in SelectionArea. v1 scope: text + code blocks are the selectable surface; tables/link-spans are known non-selectable islands (revisit later). Acceptance = select+copy across text/code blocks.
|
||||
- selectionBackground token: add to SurfaceTokens (field + TokenKeys + TokenKeys.all) + resolver.dart (_defaultSurfaceMap default + constructor). Bundled theme YAMLs do NOT need it (default map covers it); per-theme override only if the default reads wrong. Do NOT retune existing palettes (published themes are user contracts).
|
||||
- Add a selection contrast pair (globalForeground on selectionBackground) to lib/kernel/src/theme/contrast.dart canonicalPairs; re-run test/a11y. (Note: summer-night.yaml is missing from contrast_test bundledPaths -- pre-existing inconsistency, out of scope.)
|
||||
- COPY PATH CORRECTION: SelectionArea Ctrl+C goes through Flutter services.Clipboard directly, NOT ClideClipboard. Test verifies Clipboard.getData returns the selection after copy.
|
||||
- Coverage floor is 95%.', NULL, '2026-05-22 16:41:09', '2026-05-22 16:41:09', '2026-05-22 16:41:09', NULL, '5e218db621577a309b493611dfb72b63', 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-136', 'description', 'New lib/builtin/claude/src/transcript_reader.dart. Resolve munged project path (/ -> -) + session-id; tail the append-only JSONL; parse records (type: user/assistant/attachment/tool; message; agentId; isSidechain) into ConversationItem models; drift-guard on the version field (log + degrade on unknown schema). Paths validated this session: ~/.claude/projects/<munged>/<sid>.jsonl (lead) and <sid>/subagents/agent-<id>.jsonl (teammates). Acceptance: unit tests against a snapshotted JSONL fixture (assistant text, tool_use, tool_result); appended lines stream in; unknown version degrades gracefully + logs. Blocks T5.', 'New lib/builtin/claude/src/transcript_reader.dart. Resolve munged project path (/ -> -) + session-id; tail the append-only JSONL; parse records (type: user/assistant/attachment/tool; message; agentId; isSidechain) into ConversationItem models; drift-guard on the version field (log + degrade on unknown schema). Paths validated this session: ~/.claude/projects/<munged>/<sid>.jsonl (lead) and <sid>/subagents/agent-<id>.jsonl (teammates). Acceptance: unit tests against a snapshotted JSONL fixture (assistant text, tool_use, tool_result); appended lines stream in; unknown version degrades gracefully + logs. Blocks T5.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review): READY. Verified against real transcripts.
|
||||
- Munge = absolutePath.replaceAll(''/'',''-'') (KEEP the leading ''-''; dirs start with -var-).
|
||||
- Envelope fields: type, uuid, parentUuid, isSidechain, sessionId, version, timestamp. Render: user (message.content = String OR array of {type:text|tool_result}); assistant (message.content = array of {type:thinking|text|tool_use}). Skip attachment/system/last-prompt/permission-mode/file-history-snapshot/queue-operation (bookkeeping).
|
||||
- SESSION-ID: claude does not expose its session UUID via argv/env (verified in claude_pane spawn). Approach: newest *.jsonl by mtime in ~/.claude/projects/<munged>/; poll mtime and switch the tail when a newer session appears.
|
||||
- Subagent transcripts: <munged>/<session-uuid>/subagents/agent-<id>.jsonl + sibling .meta.json ({agentType,description}); each record carries agentId.
|
||||
- Tail: dart:io File.openRead with a byte-position cursor (File.length + RandomAccessFile.setPosition on poll). Do NOT use lib/src/files/watcher.dart. Zero new deps.
|
||||
- Logger: lib/kernel/src/log.dart for the version drift-guard. Coverage floor 95%.', NULL, '2026-05-22 16:41:09', '2026-05-22 16:41:09', '2026-05-22 16:41:09', NULL, 'c040ed1bf6bad363c3d199bead879374', 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-136', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 16:46:34', '2026-05-22 16:46:34', '2026-05-22 16:46:34', NULL, '176db3f23c4a758fb601c50c9ae2644b', 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-134', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 16:46:34', '2026-05-22 16:46:34', '2026-05-22 16:46:34', NULL, '5e5f91eab17e4acefcc0c9e286787742', 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-135', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 16:46:34', '2026-05-22 16:46:34', '2026-05-22 16:46:34', NULL, '79aed523589f14481c115538ae8b357c', 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-133', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 16:46:34', '2026-05-22 16:46:34', '2026-05-22 16:46:34', NULL, 'e11fb2f37bf99262989f1881a7deed9c', 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-133', 'status', 'in_progress', 'done', NULL, '2026-05-22 17:37:23', '2026-05-22 17:37:23', '2026-05-22 17:37:23', NULL, '010ab0fda58e4be2ea699c102e1b2695', 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-135', 'status', 'in_progress', 'done', NULL, '2026-05-22 17:37:23', '2026-05-22 17:37:23', '2026-05-22 17:37:23', NULL, '1bfd08b75a99bbf3c46408b0c5f799f2', 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-136', 'status', 'in_progress', 'done', NULL, '2026-05-22 17:37:23', '2026-05-22 17:37:23', '2026-05-22 17:37:23', NULL, 'eea74c531ac4796fe6dba221018e80dd', 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-134', 'status', 'in_progress', 'done', NULL, '2026-05-22 17:55:11', '2026-05-22 17:55:11', '2026-05-22 17:55:11', NULL, '13385e9b6de3d3979b03c22de9db73f6', 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-134', 'description', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review):
|
||||
- Environment confirmed: tmux 3.6a; claude 2.1.148 (the version an-idea.md verified for tmux teammate spawning) -- PIN findings to 2.1.148. teammateMode absent from ~/.claude/settings.json (default auto) -> set teammateMode:tmux explicitly OR launch the lead inside tmux and rely on auto.
|
||||
- Check 3 ALREADY answered from a real ~/.claude/teams/*/config.json: {name,description,createdAt,leadAgentId,leadSessionId,members[]}; member = {agentId,name,agentType,model,joinedAt,tmuxPaneId (empty for lead, e.g. %154 for teammates),cwd,subscriptions,backendType:''tmux'',isActive,mode}. tmuxPaneId DOES populate -- spike just re-verifies on the pinned version.
|
||||
- Subagent path: <munged>/<session-uuid>/subagents/agent-<id>.jsonl (session UUID is a directory).
|
||||
- Check 5 (session-id) resolved by T-136: newest *.jsonl by mtime. Spike confirms only.
|
||||
- tmux -L clide socket may not exist unless the app is running; spike creates its own: tmux -L clide new-session -d -s teamhost ... claude.
|
||||
- Check 4 (paste): scope to @path file references (verifiable via send-keys headlessly). Clipboard-image needs an interactive display ($DISPLAY) -- note as interactive-only.
|
||||
- ACP directionality (an-idea.md s5) is OUT OF SCOPE: Zed-client concern; clide owns its GUI and does not use ACP.
|
||||
- Deliverable: findings note at docs/spikes/cc-team-internals-2.1.148.md (in-repo), feeding D-75 when authored (T-133). D-75 is claimed-but-unauthored (T-133''s job) -- the reference is forward-looking, not dangling.', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review):
|
||||
- Environment confirmed: tmux 3.6a; claude 2.1.148 (the version an-idea.md verified for tmux teammate spawning) -- PIN findings to 2.1.148. teammateMode absent from ~/.claude/settings.json (default auto) -> set teammateMode:tmux explicitly OR launch the lead inside tmux and rely on auto.
|
||||
- Check 3 ALREADY answered from a real ~/.claude/teams/*/config.json: {name,description,createdAt,leadAgentId,leadSessionId,members[]}; member = {agentId,name,agentType,model,joinedAt,tmuxPaneId (empty for lead, e.g. %154 for teammates),cwd,subscriptions,backendType:''tmux'',isActive,mode}. tmuxPaneId DOES populate -- spike just re-verifies on the pinned version.
|
||||
- Subagent path: <munged>/<session-uuid>/subagents/agent-<id>.jsonl (session UUID is a directory).
|
||||
- Check 5 (session-id) resolved by T-136: newest *.jsonl by mtime. Spike confirms only.
|
||||
- tmux -L clide socket may not exist unless the app is running; spike creates its own: tmux -L clide new-session -d -s teamhost ... claude.
|
||||
- Check 4 (paste): scope to @path file references (verifiable via send-keys headlessly). Clipboard-image needs an interactive display ($DISPLAY) -- note as interactive-only.
|
||||
- ACP directionality (an-idea.md s5) is OUT OF SCOPE: Zed-client concern; clide owns its GUI and does not use ACP.
|
||||
- Deliverable: findings note at docs/spikes/cc-team-internals-2.1.148.md (in-repo), feeding D-75 when authored (T-133). D-75 is claimed-but-unauthored (T-133''s job) -- the reference is forward-looking, not dangling.
|
||||
|
||||
---
|
||||
Findings (2026-05-22): docs/spikes/cc-team-internals-2.1.148.md. All 5 checks answered from real artifacts + tmux manual + synthetic control-mode test (no live run/quota). Key risk for T-139: pane->teammate->transcript identity joins only on agentType (config <-> .meta.json), ambiguous for same-type teammates -> needs joinedAt/mtime or lead-transcript tiebreaker. tmux 3.6a has no %pane-died; poll list-panes -a. Teammate transcripts at <munged>/<sid>/subagents/agent-<hex>.jsonl + .meta.json{agentType,description}.', NULL, '2026-05-22 17:55:11', '2026-05-22 17:55:11', '2026-05-22 17:55:11', NULL, 'dbf4f56d1ec9d2234f255826c5f88f68', 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-137', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 17:58:07', '2026-05-22 17:58:07', '2026-05-22 17:58:07', NULL, '4e67262d4b04f8541e71fe8f03106fbf', 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-137', 'status', 'in_progress', 'done', NULL, '2026-05-22 18:14:32', '2026-05-22 18:14:32', '2026-05-22 18:14:32', NULL, 'a284c7249dda3c63a52a202bad07f105', 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-138', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 21:08:05', '2026-05-22 21:08:05', '2026-05-22 21:08:05', NULL, 'c3dfefe24d912081cd89ac0be066b641', 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-138', 'status', 'in_progress', 'done', NULL, '2026-05-22 21:49:09', '2026-05-22 21:49:09', '2026-05-22 21:49:09', NULL, 'e4be02e339d638da70a4e7ce0f850691', 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-142', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:09:09', '2026-05-22 22:09:09', '2026-05-22 22:09:09', NULL, 'b799657bf436bc44e306ef71658c80e5', 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-142', 'status', 'in_progress', 'done', NULL, '2026-05-22 22:18:02', '2026-05-22 22:18:02', '2026-05-22 22:18:02', NULL, '374be08610920e404c8394bc10b1e12a', 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-143', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:31:25', '2026-05-22 22:31:25', '2026-05-22 22:31:25', NULL, 'e2a16eb8af0afc30a5d0fc88c523e507', 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-143', 'status', 'in_progress', 'done', NULL, '2026-05-22 22:36:09', '2026-05-22 22:36:09', '2026-05-22 22:36:09', NULL, '9b8859ea2e8db7083bfb9d2ab17c1c57', 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-144', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:41:28', '2026-05-22 22:41:28', '2026-05-22 22:41:28', NULL, 'eda6f75e88401ee5b445e7edfc312f82', 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-144', 'status', 'in_progress', 'done', NULL, '2026-05-22 22:43:45', '2026-05-22 22:43:45', '2026-05-22 22:43:45', NULL, 'da17b2710e17b1821882d47724790cbb', 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-139', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:50:41', '2026-05-22 22:50:41', '2026-05-22 22:50:41', NULL, 'ad063f0c3c65e5b35d3dcea036412ad9', 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-139', 'status', 'in_progress', 'done', NULL, '2026-05-22 23:16:28', '2026-05-22 23:16:28', '2026-05-22 23:16:28', NULL, 'add82dc118e2081500f19cbb321272d4', 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-140', 'status', 'backlog', 'done', NULL, '2026-05-22 23:31:46', '2026-05-22 23:31:46', '2026-05-22 23:31:46', NULL, 'e6204c6f0a0dbd38396a69dfef2c7080', 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-145', 'description', NULL, 'A compact per-session status strip in the lead Claude pane header AND each teammate tile header (T-140), showing: current model, permission mode (accept-edits / plan / default / bypass), and context-window usage %.
|
||||
|
||||
Shared status source (this ticket owns it): extract per-session status in the drift-contained reader/observer (D-75) — do NOT parse CC internals in the widgets.
|
||||
- Model: team config member.model, overridden by the latest assistant message.model in the transcript when present.
|
||||
- Permission mode: the transcript''s `permission-mode` records (TranscriptReader currently SKIPS these — surface the latest instead). Also honor config member.planModeRequired for teammates.
|
||||
- Context %: latest assistant message.usage (input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens) over the model''s context limit.
|
||||
|
||||
Delivery: emit a per-session status to the UI via the bus/events (e.g. a TeamMemberStatus event or an extra MessageBus channel) so BOTH the header strip and the T-141 sidebar render from one source without re-tailing.
|
||||
|
||||
Applies to the lead session and every teammate. Re-validate field names against a live team run / CC version bump (undocumented, per T-134).
|
||||
|
||||
Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done).
|
||||
', NULL, '2026-05-23 06:07:59', '2026-05-23 06:07:59', '2026-05-23 06:07:59', NULL, '26bb1c89f3fef8d916f3eb6a1ba9d065', 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-141', 'description', 'New sidebar panel contributed to Slots.sidebar: roster from ~/.claude/teams config; live status verb / elapsed / down+up tokens / edit-mode from transcript tailing; team token budget pulled from the Anthropic usage API on a timer. Acceptance: roster matches list-panes/config; budget refreshes on the timer; appears in team mode. Blocked by T7.', 'New sidebar panel (contributed to Slots.sidebar), visible only in team mode: a roster row per team member that MIRRORS the per-session status surfaced by T-145, plus team-wide meta.
|
||||
|
||||
Per-member row:
|
||||
- Identity from the team config + observer: name, agentType, color; membership/lifecycle from TeamMemberJoined/Left.
|
||||
- Mirror of T-145''s shared status: current model, permission-mode badge (accept-edits / plan / default / bypass), context-window usage %. Consume the SAME status source/event T-145 emits — do NOT re-tail transcripts or re-parse CC internals here (D-75).
|
||||
- Live status verb + elapsed-since-last-activity + down/up token deltas from that same status feed.
|
||||
|
||||
Team-wide:
|
||||
- Team/account token budget pulled from the Anthropic usage API on a timer (distinct from per-conversation context above).
|
||||
|
||||
Acceptance: roster matches list-panes/config; each row mirrors the member''s model + accept-edits/plan badge + context-usage; status verb and token deltas update live; account budget refreshes on its timer; panel appears only when a team is active. Depends on T-145 (status source) + T-139 (done).
|
||||
', NULL, '2026-05-23 06:08:24', '2026-05-23 06:08:24', '2026-05-23 06:08:24', NULL, 'cfa1ce349be286d89a1f8e169605f04d', 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-146', 'description', NULL, 'Regression from T-137: every Claude pane renders the newest .jsonl in the workspace''s munged dir, so concurrent sessions in one workspace (primary + secondaries) all converge on the same transcript — a secondary tab shows the primary''s conversation (and the primary follows whichever session is newest). The pre-T-137 terminal pane rendered each tab''s own PTY, so tabs were independent.
|
||||
|
||||
Fix: bind each pane to a specific Claude session id.
|
||||
- claude CLI supports `--session-id <uuid>` (verified via --help). Spawn each pane''s claude with its own UUID; a transcript is named <session-id>.jsonl, so the pane''s transcript is <munged-cwd>/<uuid>.jsonl.
|
||||
- Reader targets that exact file via TranscriptReader''s `file:` param (added in T-139). Guard _tail against the file not existing yet (claude writes it shortly after spawn).
|
||||
- Each pane publishes/subscribes on a per-session MessageBus channel (conversation/<uuid>) so primary and secondary controllers don''t cross-talk.
|
||||
- UUID per pane, seeded by the tmux session name (the identity we already have): primary = deterministic from its stable session name (clide-claude-<slug>) so it RESUMES across restarts (D-41 continuity); secondary = random per spawn so each is a clean/fresh session (a deterministic secondary id would make starting a clean session impossible).
|
||||
|
||||
Migration caveat: an existing tmux session created by the OLD code (no --session-id, claude picked its own id) won''t match the new deterministic id — it must be killed (claude.kill-all-sessions) once so the next spawn binds the controlled id.
|
||||
|
||||
Open question to validate live: whether `claude --session-id <existing-uuid>` resumes vs. errors when re-spawned after the tmux session was killed (display works either way since the file is <uuid>.jsonl, but worth confirming claude starts).
|
||||
|
||||
Acceptance: a secondary tab shows its OWN fresh conversation, independent of the primary; the primary keeps its conversation; both update live. Regression of T-137; relates to D-41.
|
||||
', NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, '9e46b8eec7cc5e42132249a81d4925d1', 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-146', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, 'dd4ec6cfbbc3118cd4cf02604b91108b', 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-146', 'status', 'in_progress', 'done', NULL, '2026-05-23 06:30:47', '2026-05-23 06:30:47', '2026-05-23 06:30:47', NULL, '93f9c6814b9e82e5b60ac0e57fff5afe', 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-147', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 07:03:18', '2026-05-23 07:03:18', '2026-05-23 07:03:18', NULL, 'b0bc1949f98ef05a05717cef80ba194f', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -1895,3 +1895,660 @@ 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);
|
||||
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.', 'done', 'low', NULL, NULL, NULL, '2026-05-18 07:43:50', '2026-05-20 07:00:06', NULL, 'ba93872d695800697af1654a8bfd6e93', 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.
|
||||
|
||||
---
|
||||
Design resolved 2026-05-20 (see D-74). Co-registration model, NOT a central map:
|
||||
|
||||
- The argv parser (lib/src/cli/argv_to_request.dart) only knows syntactic shape (identifier/flag charset, -- terminator); it has NO per-command argument knowledge. So this is build-fresh, not a lift — each handler currently hand-reads its own keys.
|
||||
- Extend the DaemonDispatcher registration API to carry an optional typed schema per command. The dispatcher accumulates a cmd->schema registry as commands register (built-in register*Commands modules supply theirs; extension CommandContributions carry their own — central static map rejected because it cannot see extension-contributed commands, D-46).
|
||||
- DaemonDispatcher.dispatch validates req.args against schema[cmd] BEFORE invoking the handler (hook at dispatcher.dart line 39, the `return h(req)` call). Violation -> userError (sysexit 64). Commands with no schema dispatch unvalidated (opt-in per command) until migrated.
|
||||
- Constraint vocabulary is hand-rolled (prefer-zero-deps): per-arg required/optional, kind, charset/regex, numeric range. Collapses the _ResizeArgs-style hand-lifts (panel_commands.dart, T-119) into schema.
|
||||
- KEEP existing T-104 checks as defense-in-depth: validateGitRef (lib/src/git/operations.dart:46) and the count/path caps (lib/src/daemon/git_commands.dart:18,23). The git client is callable directly from the Flutter UI, not only via the dispatcher — do not remove them.
|
||||
- OUT OF SCOPE: MCP tools/list generation from the registry — deferred to the T-130 track.
|
||||
|
||||
Acceptance:
|
||||
1. Dispatcher validates req.args against a registered per-command schema before the handler runs; violations return userError.
|
||||
2. At least the security-relevant commands (git ref/remote/path args, panel.resize) carry schemas; argv-injection + caps still enforced (no regression vs T-104).
|
||||
3. validateGitRef + git caps retained as defense-in-depth.
|
||||
4. No third-party validation dep; no lint suppressions.
|
||||
5. Tests cover: schema accept/reject per constraint kind, dispatcher-level rejection, and that an unschema-d command still dispatches.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]" item 1. Decision: D-74.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-18 07:53:08', '2026-05-20 15:54:38', NULL, '3c33fccd4b7442444ca598d430fab7f8', 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.
|
||||
|
||||
---
|
||||
Design resolved 2026-05-20 (see D-74). Co-registration model, NOT a central map:
|
||||
|
||||
- The argv parser (lib/src/cli/argv_to_request.dart) only knows syntactic shape (identifier/flag charset, -- terminator); it has NO per-command argument knowledge. So this is build-fresh, not a lift — each handler currently hand-reads its own keys.
|
||||
- Extend the DaemonDispatcher registration API to carry an optional typed schema per command. The dispatcher accumulates a cmd->schema registry as commands register (built-in register*Commands modules supply theirs; extension CommandContributions carry their own — central static map rejected because it cannot see extension-contributed commands, D-46).
|
||||
- DaemonDispatcher.dispatch validates req.args against schema[cmd] BEFORE invoking the handler (hook at dispatcher.dart line 39, the `return h(req)` call). Violation -> userError (sysexit 64). Commands with no schema dispatch unvalidated (opt-in per command) until migrated.
|
||||
- Constraint vocabulary is hand-rolled (prefer-zero-deps): per-arg required/optional, kind, charset/regex, numeric range. Collapses the _ResizeArgs-style hand-lifts (panel_commands.dart, T-119) into schema.
|
||||
- KEEP existing T-104 checks as defense-in-depth: validateGitRef (lib/src/git/operations.dart:46) and the count/path caps (lib/src/daemon/git_commands.dart:18,23). The git client is callable directly from the Flutter UI, not only via the dispatcher — do not remove them.
|
||||
- OUT OF SCOPE: MCP tools/list generation from the registry — deferred to the T-130 track.
|
||||
|
||||
Acceptance:
|
||||
1. Dispatcher validates req.args against a registered per-command schema before the handler runs; violations return userError.
|
||||
2. At least the security-relevant commands (git ref/remote/path args, panel.resize) carry schemas; argv-injection + caps still enforced (no regression vs T-104).
|
||||
3. validateGitRef + git caps retained as defense-in-depth.
|
||||
4. No third-party validation dep; no lint suppressions.
|
||||
5. Tests cover: schema accept/reject per constraint kind, dispatcher-level rejection, and that an unschema-d command still dispatches.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]" item 1. Decision: D-74.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 07:53:08', '2026-05-20 16:02:35', NULL, 'f52928536cfeb0f08091ec5d55bda3db', 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-97', 'epic', NULL, 'Address 2026-05-14 consultant review', 'Six-reviewer external assessment found four critical gaps (workspace RCE, IPC server unimplemented, keyboard not operable, onboarding docs describe a dissolved architecture) plus ten major items and a handful of quick wins. Source: consultants.md (committed alongside this epic). Children are filed individually so they can land independently.
|
||||
|
||||
Acceptance: all critical findings resolved or formally rejected with a D-record; all major findings either shipped or moved to a follow-up epic with rationale; quick-win batches closed.', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:08', '2026-05-20 16:02:46', NULL, 'bfc0abf31809a8e7afbb4bff62118d06', 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 follow-up: tap-driven widget test for sticky-startup toggle.
|
||||
|
||||
Adding a tap-driven (or even render-only) test that pumps WelcomeView with a non-empty recents list hangs the Flutter test runner until timeout.
|
||||
|
||||
Investigation 2026-05-22 (narrowed the cause):
|
||||
- The hang is SYNCHRONOUS: `flutter test --timeout 45s` never fires; only an external `timeout` kills it. So the Dart isolate event loop is blocked, not awaiting.
|
||||
- Reduced to a minimal repro: pumping two NESTED ClideTappable widgets under the shared test harness (test/helpers/widget_harness.dart) hangs. A SINGLE ClideTappable (as in _ActionRow) renders fine — that is why all existing WelcomeView tests pass (empty recents = no nested tappable). The recents path nests one: _RecentRow is a ClideTappable whose subtree contains _StickyToggle (a Semantics-wrapped ClideTappable).
|
||||
- The shared harness uses `Overlay(canSizeOverlay: true)` over a zero-size `MediaQuery`, which triggers an intrinsic-sizing pass. Strongly suspect the nested ClideTappable (Focus + MouseRegion + GestureDetector + DecoratedBox stack) diverges under intrinsic dimension computation.
|
||||
- Workarounds that did NOT help: setting tester.view.physicalSize; wrapping in SizedBox; wrapping in Center+SizedBox; a custom bounded MediaQuery harness (that custom harness hung even on a single tappable — likely its own confound, do not reuse it).
|
||||
- The prior ClideTooltip/MouseRegion-timer hypothesis is WRONG: the recents widgets pass no tooltip to ClideTappable, so no ClideTooltip is built, and _StickyToggle uses Semantics(tooltip:) (metadata only, no widget/timer).
|
||||
|
||||
Next steps:
|
||||
- Decide whether this is a real ClideTappable bug (nested tappables would also hang the live app welcome screen with recents) or strictly a canSizeOverlay-intrinsic-sizing test artifact. Check the running app: open welcome with >=1 recent project and confirm it does NOT hang. If the app is fine, the fix is harness-side (give WelcomeView tight constraints so no intrinsic pass), and the shared harness or a welcome-specific harness needs adjusting. If the app DOES hang, ClideTappable has a real nested-layout bug — fix it (likely in lib/widgets/src/clide_tappable.dart intrinsic/layout handling) and this becomes higher priority.
|
||||
- Once unblocked, this also unblocks lib/builtin/welcome/src/welcome_view.dart coverage (74 uncovered lines, the dominant lib/builtin/ gap toward the T-89 95% target).
|
||||
|
||||
ProjectManager sticky-startup logic is already covered by test/kernel/src/project_test.dart (14 cases).', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 09:06:59', '2026-05-22 06:55:02', NULL, 'bcb79420d9e359813ad5f75dfb89cc05', 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-91', 'task', 'T-89', 'test sweep: drive lib/src/terminal/ to 95%', 'Second child of T-89. The dominant coverage hole — `lib/src/terminal/` is 12 / 3167 lines (0.4%), about 56% of the global gap.
|
||||
|
||||
**Today''s distribution within `lib/src/terminal/src/`:**
|
||||
|
||||
| sub-area | hit / total | % | shape |
|
||||
|---|---|---|---|
|
||||
| `core/buffer/` | 0 / 581 | 0.0% | pure data structures |
|
||||
| `core/escape/` | 1 / 473 | 0.2% | pure parser logic |
|
||||
| `core/input/` | 4 / 292 | 1.4% | key + keytab tables |
|
||||
| `core/(root)` | 0 / 188 | 0.0% | callback wiring |
|
||||
| `core/mouse/` | 3 / 51 | 5.9% | encoder logic |
|
||||
| `ui/` | 3 / 823 | 0.4% | painters + render objects |
|
||||
| `(root)` | 0 / 471 | 0.0% | `Terminal` + `TerminalView` |
|
||||
| `utils/` | 1 / 251 | 0.4% | circular buffer etc. |
|
||||
|
||||
**Strategy — pure logic first, then UI:**
|
||||
|
||||
1. **`core/buffer/`** (581 lines, biggest single chunk, all pure Dart) — line/segment/cell models, scrollback math, range queries. Unit tests exercise the data structure directly.
|
||||
2. **`core/escape/`** (473 lines, pure state machine) — feed ANSI/VT byte streams in, assert the resulting buffer/cursor state. Reusable corpus from xterm.js / xterm.dart upstream is fair game.
|
||||
3. **`core/input/`** (292 lines) — key + keytab tables; assert mapping from logical key to escape bytes per terminal mode.
|
||||
4. **`core/mouse/`** (51 lines) — encoder for SGR-1006 / X11 mouse protocols. Small, fast.
|
||||
5. **`utils/`** (251 lines) — circular buffer + helpers; trivial unit tests.
|
||||
6. **`(root)`** (`terminal.dart`, `terminal_view.dart`, 471 lines) — orchestration; some unit-testable, some needs widget tests.
|
||||
7. **`ui/`** (823 lines) — painters and render objects; widget + golden tests. Hardest, most pixel-sensitive.
|
||||
|
||||
**Acceptance:**
|
||||
- `lib/src/terminal/` line coverage ≥ 95%.
|
||||
- All tests fast (<5 s for the area''s test files combined; no real PTY shell-out).
|
||||
- Helper fixtures live in `test/terminal/_helpers/` if they get reused across files.
|
||||
- Per D-66, no `// ignore:` directives or analysis_options excludes added to dodge coverage.
|
||||
- `coverage_floor:` in `pubspec.yaml` is bumped in lockstep — every commit that adds covering tests bumps the floor by the measured delta (rounded down to the integer below the new percentage).
|
||||
|
||||
**Strategy on commit cadence:**
|
||||
Land tests in batches per sub-area (one commit per `core/buffer/`, `core/escape/`, etc.). Each batch:
|
||||
- Adds tests under `test/terminal/<sub-area>/`.
|
||||
- Re-measures coverage, bumps `coverage_floor:` to the new integer (or as high as the gate currently allows).
|
||||
- Documents in CHANGELOG under "Changed" (test-only changes can also fold into one CHANGELOG entry per area).
|
||||
|
||||
**Out of scope:**
|
||||
- Refactoring `lib/src/terminal/` for testability — if a piece is genuinely untestable, surface it (don''t silence it). Code under `lib/` is owned regardless of upstream attribution; refactor to make it testable rather than carving it out.
|
||||
- Branch coverage (D-66 explicitly excludes).
|
||||
- Mouse / keyboard *protocol* round-trips against real TUIs (that''s T-74 and integration work).
|
||||
|
||||
**Cross-references:** D-66 (coverage gate), T-89 (epic), T-90 (gate plumbing).
|
||||
', 'done', 'high', NULL, NULL, 'D-66', '2026-05-06 20:49:47', '2026-05-22 13:37:46', NULL, '076081a76e2301e97262f230c5cd5516', 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-89', 'epic', NULL, 'drive line coverage from 35% to 95%', '## OUTCOME (closed 2026-05-18)
|
||||
|
||||
Epic met. Final measured line coverage **95.27%** (9551 / 10025), floor `coverage_floor: 95` in `pubspec.yaml`, gate wired into `make push-check`.
|
||||
|
||||
Final per-area (vs. opening 34.9% total):
|
||||
|
||||
| area | hit / total | % |
|
||||
|---|---|---|
|
||||
| `lib/src/terminal/` | 3185 / 3188 | **99.9%** (was 0.4%) |
|
||||
| `lib/extension/` | 54 / 54 | 100.0% |
|
||||
| `lib/src/editor/` | 129 / 129 | 100.0% |
|
||||
| `lib/src/files/` | 139 / 140 | 99.3% |
|
||||
| `lib/src/pql/` | 91 / 92 | 98.9% |
|
||||
| `lib/src/panes/` | 75 / 76 | 98.7% |
|
||||
| `lib/src/git/` | 517 / 531 | 97.4% |
|
||||
| `lib/src/daemon/` | 616 / 644 | 95.7% |
|
||||
| `lib/kernel/` | 2122 / 2233 | 95.0% |
|
||||
| `lib/src/ipc/` | 429 / 452 | 94.9% |
|
||||
| `lib/widgets/` | 1178 / 1261 | 93.4% |
|
||||
| `lib/builtin/` | 754 / 875 | 86.2% |
|
||||
| `lib/src/pty/` | 177 / 262 | 67.6% (forkpty paths excluded from gate) |
|
||||
|
||||
Acceptance: (1) floor reads 95 done (2) total >=95% done (3) gate hard-fails below floor done (4) no coverage-dodging suppressions done. The strategy''s tail per-area sweeps (widgets/builtin/pty) didn''t all individually reach 95%, but the contract is *total* line coverage, which is satisfied; remaining per-area headroom can be its own tickets if desired.
|
||||
|
||||
---
|
||||
|
||||
Today: total line coverage **34.9%** (2984/8549 lines). Per area:
|
||||
|
||||
| area | hit / total | % |
|
||||
|---|---|---|
|
||||
| `lib/src/terminal/` | 12 / 3167 | **0.4%** |
|
||||
| `lib/widgets/` | 537 / 1184 | 45.4% |
|
||||
| `lib/src/pql/` | 45 / 94 | 47.9% |
|
||||
| `lib/builtin/` | 269 / 550 | 48.9% |
|
||||
| `lib/kernel/` | 889 / 1691 | 52.6% |
|
||||
| `lib/src/daemon/` | 320 / 584 | 54.8% |
|
||||
| `lib/src/pty/` | 130 / 216 | 60.2% |
|
||||
| `lib/src/git/` | 356 / 518 | 68.7% |
|
||||
| `lib/src/ipc/` | 110 / 156 | 70.5% |
|
||||
| `lib/src/files/` | 98 / 133 | 73.7% |
|
||||
| `lib/extension/` | 41 / 54 | 75.9% |
|
||||
| `lib/src/editor/` | 107 / 129 | 82.9% |
|
||||
| `lib/src/panes/` | 70 / 73 | 95.9% |
|
||||
|
||||
The dominant hole is `lib/src/terminal/` — the terminal emulator code. Header still credits xterm.dart upstream, but per CLAUDE.md ("Own the rendering stack") and the explicit guidance attached to this epic: in-tree code is owned, no carve-outs, same coverage bar.
|
||||
|
||||
**End state:**
|
||||
- Total line coverage ≥ 95% (no per-area carve-outs).
|
||||
- Pre-push gate hard-fails on coverage drops via a committed floor (`coverage/floor.txt`); floor ratchets up only.
|
||||
- Per D-66 the gate lands at the *current* floor in the first child ticket; subsequent floor bumps come with the test-writing children.
|
||||
|
||||
**Acceptance criteria for closing this epic:**
|
||||
1. `coverage/floor.txt` reads `95` (or higher).
|
||||
2. `flutter test --coverage --exclude-tags forkpty` produces ≥95% line coverage.
|
||||
3. The pre-push gate computes coverage and rejects pushes below the floor.
|
||||
4. No new `// ignore:` / `// ignore_for_file:` / analysis-options excludes were added to dodge the gate. Any suppression added during this campaign needs explicit approval and an inline reason at the suppression site.
|
||||
|
||||
**Strategy / child shape:**
|
||||
The gate plumbing lands first so future test additions are visible as floor bumps. Then per-area sweeps — biggest holes first because they move the global number fastest, but each area is its own child ticket with its own scope.
|
||||
|
||||
1. **Gate plumbing** (first child) — install the ratchet, lock in current floor, expose `make coverage-gate` and wire it into `make push-check`.
|
||||
2. **`lib/src/terminal/` sweep** (second child, by far the largest) — likely splits into sub-children once the area''s structure is mapped (parser, buffer, painter, gesture handling, mouse reporting, escape sequences, cell-grid measurement, etc.).
|
||||
3. **Per-area sweeps** — claim a child ticket when starting an area; close when the area hits 95%. Order by impact: terminal → widgets → pql → builtin → kernel → daemon → pty → git → ipc → files → extension → editor.
|
||||
4. **Final ratchet** — once total ≥ 95%, set floor to 95 and close the epic.
|
||||
|
||||
**Out of scope:**
|
||||
- Branch coverage (lcov data is weak for branch on Dart; line coverage is the contract).
|
||||
- Integration / E2E coverage (only widget + unit feed lcov).
|
||||
- `lib/test_app.dart` exclusion: verify in the gate child whether Dart''s coverage tooling already excludes it; if not, decide explicitly rather than carve it out silently.
|
||||
|
||||
**Cross-references:** D-66 (this epic''s decision record), D-29 (pre-push gate scope), "Own the rendering stack" guardrail (CLAUDE.md).
|
||||
', 'done', 'high', NULL, NULL, 'D-66', '2026-05-06 20:36:42', '2026-05-22 13:37:46', NULL, '294d10b6817279f2c76e147f3066db50', 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-132', 'epic', NULL, 'Native Claude integration: transcript-rendered conversation + tmux agent-team panels', 'Replace the terminal-based Claude pane with native rendering driven by Claude Code''s transcript JSONL (with cross-widget text selection), and surface experimental tmux agent teams (lead + teammates) as tiled native panels plus a team-meta sidebar. The terminal is retained only as a general IDE tool.
|
||||
|
||||
Why:
|
||||
1. No cross-widget text selection/copy in clide''s native widgets today (regression vs a terminal).
|
||||
2. PTY/TUI rendering is the fragile, OS-variant part of the Claude pane.
|
||||
3. Claude Code''s experimental tmux team mode (lead + N teammates, each a tmux pane) is the flagship capability to surface as real GUI panels, not scraped TUI.
|
||||
|
||||
Direction: run Claude headless in tmux; render its conversation natively from the transcript JSONL; lead panel (left, with composer) + teammate tiles (right) in a responsive auto-wrap grid (1->2->3 cols); team-meta sidebar with roster + token budget.
|
||||
|
||||
Principle: Claude-centric first, CLI-first (D-6) a strong second. Accept isolated, version-pinned coupling to Claude Code internal contracts where it serves the Claude integration; preserve CLI/event surfaces where sensible.
|
||||
|
||||
Acceptance: all child tickets done; D-record landed; Claude pane renders natively with working select+copy and no terminal; a tmux team surfaces as lead + auto-wrapping teammate tiles + meta sidebar; all fragile CC-internals parsing isolated and version-pinned.
|
||||
|
||||
Source: plan iridescent-tinkering-umbrella (2026-05-22), grounded in an-idea.md + read-only validation of the transcript schema this session.', 'backlog', 'high', NULL, NULL, NULL, '2026-05-22 15:45:15', '2026-05-22 15:45:15', NULL, '0812d909e01decc3a5e8ce2b1b4f00d6', 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-140', 'story', 'T-132', 'Teammate tile grid (faithful tiling, responsive auto-wrap)', 'Tiling host: lead ConversationView (left, with composer) + teammate ConversationViews (right) in a responsive auto-wrap grid (1->2->3 columns by count); resizable lead/teammate split. Workspace currently only vertical-splits, so extend _WorkspaceSlot (lib/app.dart) or add team slots in lib/kernel/src/panels/layout_preset.dart; reuse DragResizeHandle (lib/kernel/src/panels/drag_resize.dart). Driven by TeamMemberBorn/Died from the observer. Acceptance: tiles appear/disappear with lifecycle; grid wraps 1->2->3 by count; split resizes; select-across-tiles works. Blocked by T5, T7.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 15:59:34', NULL, '0896a53c07b866a7deab8bbcfc2f95aa', 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-141', 'task', 'T-132', 'Team-meta sidebar (roster + token budget)', 'New sidebar panel contributed to Slots.sidebar: roster from ~/.claude/teams config; live status verb / elapsed / down+up tokens / edit-mode from transcript tailing; team token budget pulled from the Anthropic usage API on a timer. Acceptance: roster matches list-panes/config; budget refreshes on the timer; appears in team mode. Blocked by T7.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 15:59:34', NULL, '47e4d36a5ae60ba83a2f566f01e30dda', 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-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t <pane> (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 15:59:34', NULL, '7c2c380a808455658a034d6f84721ec8', 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-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'backlog', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 15:59:34', NULL, '811c3f22582742212e7fe7d7b417e80d', 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-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'backlog', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 15:59:34', NULL, 'ac8ce4f7f76378f6cdec1544aa5390c9', 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-135', 'task', 'T-132', 'Native text-selection foundation (SelectionArea-compatible widgets)', 'Make clide''s native text widgets selectable so a SelectionArea can select across them. Change lib/widgets/src/clide_markdown.dart RichText (line ~68) -> Text.rich; same for lib/widgets/src/clide_code_block.dart; add a selectionBackground token to SurfaceTokens (lib/kernel/src/theme/tokens.dart + resolver + bundled theme YAMLs); verify copy via existing ClideClipboard (lib/kernel/src/clipboard.dart). Acceptance: a SelectionArea-wrapped column of markdown + code blocks supports select-across-blocks + Ctrl+C copy; widget tests; a11y unaffected; coverage >= floor. Reusable foundation. Blocks T5.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review):
|
||||
- Convert ALL RichText sites, not one: clide_markdown.dart lines 68, 238, 242 + clide_code_block.dart:79 -> Text.rich (Text.rich wires the SelectionArea selectionRegistrar; same underlying render object).
|
||||
- Tables (clide_markdown.dart:230) and tappable WidgetSpan links (ClideTappable ~293-310) do NOT participate in SelectionArea. v1 scope: text + code blocks are the selectable surface; tables/link-spans are known non-selectable islands (revisit later). Acceptance = select+copy across text/code blocks.
|
||||
- selectionBackground token: add to SurfaceTokens (field + TokenKeys + TokenKeys.all) + resolver.dart (_defaultSurfaceMap default + constructor). Bundled theme YAMLs do NOT need it (default map covers it); per-theme override only if the default reads wrong. Do NOT retune existing palettes (published themes are user contracts).
|
||||
- Add a selection contrast pair (globalForeground on selectionBackground) to lib/kernel/src/theme/contrast.dart canonicalPairs; re-run test/a11y. (Note: summer-night.yaml is missing from contrast_test bundledPaths -- pre-existing inconsistency, out of scope.)
|
||||
- COPY PATH CORRECTION: SelectionArea Ctrl+C goes through Flutter services.Clipboard directly, NOT ClideClipboard. Test verifies Clipboard.getData returns the selection after copy.
|
||||
- Coverage floor is 95%.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 16:46:34', NULL, '0bea86c9ee213c0c7ef3c47f10232b77', 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-134', 'task', 'T-132', 'Spike: validate Claude Code team/transcript internals (pin a CC version)', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review):
|
||||
- Environment confirmed: tmux 3.6a; claude 2.1.148 (the version an-idea.md verified for tmux teammate spawning) -- PIN findings to 2.1.148. teammateMode absent from ~/.claude/settings.json (default auto) -> set teammateMode:tmux explicitly OR launch the lead inside tmux and rely on auto.
|
||||
- Check 3 ALREADY answered from a real ~/.claude/teams/*/config.json: {name,description,createdAt,leadAgentId,leadSessionId,members[]}; member = {agentId,name,agentType,model,joinedAt,tmuxPaneId (empty for lead, e.g. %154 for teammates),cwd,subscriptions,backendType:''tmux'',isActive,mode}. tmuxPaneId DOES populate -- spike just re-verifies on the pinned version.
|
||||
- Subagent path: <munged>/<session-uuid>/subagents/agent-<id>.jsonl (session UUID is a directory).
|
||||
- Check 5 (session-id) resolved by T-136: newest *.jsonl by mtime. Spike confirms only.
|
||||
- tmux -L clide socket may not exist unless the app is running; spike creates its own: tmux -L clide new-session -d -s teamhost ... claude.
|
||||
- Check 4 (paste): scope to @path file references (verifiable via send-keys headlessly). Clipboard-image needs an interactive display ($DISPLAY) -- note as interactive-only.
|
||||
- ACP directionality (an-idea.md s5) is OUT OF SCOPE: Zed-client concern; clide owns its GUI and does not use ACP.
|
||||
- Deliverable: findings note at docs/spikes/cc-team-internals-2.1.148.md (in-repo), feeding D-75 when authored (T-133). D-75 is claimed-but-unauthored (T-133''s job) -- the reference is forward-looking, not dangling.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 16:46:34', NULL, '1ca53c263efc8f5d5d49af23569b51b0', 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-136', 'task', 'T-132', 'TranscriptReader: tail + parse Claude transcript JSONL', 'New lib/builtin/claude/src/transcript_reader.dart. Resolve munged project path (/ -> -) + session-id; tail the append-only JSONL; parse records (type: user/assistant/attachment/tool; message; agentId; isSidechain) into ConversationItem models; drift-guard on the version field (log + degrade on unknown schema). Paths validated this session: ~/.claude/projects/<munged>/<sid>.jsonl (lead) and <sid>/subagents/agent-<id>.jsonl (teammates). Acceptance: unit tests against a snapshotted JSONL fixture (assistant text, tool_use, tool_result); appended lines stream in; unknown version degrades gracefully + logs. Blocks T5.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review): READY. Verified against real transcripts.
|
||||
- Munge = absolutePath.replaceAll(''/'',''-'') (KEEP the leading ''-''; dirs start with -var-).
|
||||
- Envelope fields: type, uuid, parentUuid, isSidechain, sessionId, version, timestamp. Render: user (message.content = String OR array of {type:text|tool_result}); assistant (message.content = array of {type:thinking|text|tool_use}). Skip attachment/system/last-prompt/permission-mode/file-history-snapshot/queue-operation (bookkeeping).
|
||||
- SESSION-ID: claude does not expose its session UUID via argv/env (verified in claude_pane spawn). Approach: newest *.jsonl by mtime in ~/.claude/projects/<munged>/; poll mtime and switch the tail when a newer session appears.
|
||||
- Subagent transcripts: <munged>/<session-uuid>/subagents/agent-<id>.jsonl + sibling .meta.json ({agentType,description}); each record carries agentId.
|
||||
- Tail: dart:io File.openRead with a byte-position cursor (File.length + RandomAccessFile.setPosition on poll). Do NOT use lib/src/files/watcher.dart. Zero new deps.
|
||||
- Logger: lib/kernel/src/log.dart for the version drift-guard. Coverage floor 95%.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 16:46:34', NULL, '54d91e3d25e042cdf931610197a12f0e', 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-133', 'task', 'T-132', 'Author D-75: native transcript rendering + accepted CC-internals coupling', 'Author the D-75 architecture record (id already claimed via pql decisions claim): Claude is rendered natively from transcripts, not the terminal; accept isolated, version-pinned coupling to Claude Code internal contracts (transcript JSONL schema, ~/.claude/teams config, tmux control mode); terminal retained as a general IDE tool only. Record Claude-centric > CLI-first ordering with D-6 surfaces preserved. Acceptance: D-75 written in governance/decisions/architecture.md with Decision/Rationale/Cost/Cross-reference/Raised-by; governance README synced (pql decisions sync) + pql decisions validate ok; cited by T5/T7. Do early — frames the epic.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:50:06', '2026-05-22 16:46:34', NULL, '7ebcaf6b78bf011e1b52edcd91a568a4', 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-133', 'task', 'T-132', 'Author D-75: native transcript rendering + accepted CC-internals coupling', 'Author the D-75 architecture record (id already claimed via pql decisions claim): Claude is rendered natively from transcripts, not the terminal; accept isolated, version-pinned coupling to Claude Code internal contracts (transcript JSONL schema, ~/.claude/teams config, tmux control mode); terminal retained as a general IDE tool only. Record Claude-centric > CLI-first ordering with D-6 surfaces preserved. Acceptance: D-75 written in governance/decisions/architecture.md with Decision/Rationale/Cost/Cross-reference/Raised-by; governance README synced (pql decisions sync) + pql decisions validate ok; cited by T5/T7. Do early — frames the epic.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:50:06', '2026-05-22 17:37:23', NULL, '4b4b15c450f6791a4b41b85ad8817980', 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-135', 'task', 'T-132', 'Native text-selection foundation (SelectionArea-compatible widgets)', 'Make clide''s native text widgets selectable so a SelectionArea can select across them. Change lib/widgets/src/clide_markdown.dart RichText (line ~68) -> Text.rich; same for lib/widgets/src/clide_code_block.dart; add a selectionBackground token to SurfaceTokens (lib/kernel/src/theme/tokens.dart + resolver + bundled theme YAMLs); verify copy via existing ClideClipboard (lib/kernel/src/clipboard.dart). Acceptance: a SelectionArea-wrapped column of markdown + code blocks supports select-across-blocks + Ctrl+C copy; widget tests; a11y unaffected; coverage >= floor. Reusable foundation. Blocks T5.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review):
|
||||
- Convert ALL RichText sites, not one: clide_markdown.dart lines 68, 238, 242 + clide_code_block.dart:79 -> Text.rich (Text.rich wires the SelectionArea selectionRegistrar; same underlying render object).
|
||||
- Tables (clide_markdown.dart:230) and tappable WidgetSpan links (ClideTappable ~293-310) do NOT participate in SelectionArea. v1 scope: text + code blocks are the selectable surface; tables/link-spans are known non-selectable islands (revisit later). Acceptance = select+copy across text/code blocks.
|
||||
- selectionBackground token: add to SurfaceTokens (field + TokenKeys + TokenKeys.all) + resolver.dart (_defaultSurfaceMap default + constructor). Bundled theme YAMLs do NOT need it (default map covers it); per-theme override only if the default reads wrong. Do NOT retune existing palettes (published themes are user contracts).
|
||||
- Add a selection contrast pair (globalForeground on selectionBackground) to lib/kernel/src/theme/contrast.dart canonicalPairs; re-run test/a11y. (Note: summer-night.yaml is missing from contrast_test bundledPaths -- pre-existing inconsistency, out of scope.)
|
||||
- COPY PATH CORRECTION: SelectionArea Ctrl+C goes through Flutter services.Clipboard directly, NOT ClideClipboard. Test verifies Clipboard.getData returns the selection after copy.
|
||||
- Coverage floor is 95%.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:37:23', NULL, '8f5c2e3763cd186de3d0744f800a7980', 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-136', 'task', 'T-132', 'TranscriptReader: tail + parse Claude transcript JSONL', 'New lib/builtin/claude/src/transcript_reader.dart. Resolve munged project path (/ -> -) + session-id; tail the append-only JSONL; parse records (type: user/assistant/attachment/tool; message; agentId; isSidechain) into ConversationItem models; drift-guard on the version field (log + degrade on unknown schema). Paths validated this session: ~/.claude/projects/<munged>/<sid>.jsonl (lead) and <sid>/subagents/agent-<id>.jsonl (teammates). Acceptance: unit tests against a snapshotted JSONL fixture (assistant text, tool_use, tool_result); appended lines stream in; unknown version degrades gracefully + logs. Blocks T5.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review): READY. Verified against real transcripts.
|
||||
- Munge = absolutePath.replaceAll(''/'',''-'') (KEEP the leading ''-''; dirs start with -var-).
|
||||
- Envelope fields: type, uuid, parentUuid, isSidechain, sessionId, version, timestamp. Render: user (message.content = String OR array of {type:text|tool_result}); assistant (message.content = array of {type:thinking|text|tool_use}). Skip attachment/system/last-prompt/permission-mode/file-history-snapshot/queue-operation (bookkeeping).
|
||||
- SESSION-ID: claude does not expose its session UUID via argv/env (verified in claude_pane spawn). Approach: newest *.jsonl by mtime in ~/.claude/projects/<munged>/; poll mtime and switch the tail when a newer session appears.
|
||||
- Subagent transcripts: <munged>/<session-uuid>/subagents/agent-<id>.jsonl + sibling .meta.json ({agentType,description}); each record carries agentId.
|
||||
- Tail: dart:io File.openRead with a byte-position cursor (File.length + RandomAccessFile.setPosition on poll). Do NOT use lib/src/files/watcher.dart. Zero new deps.
|
||||
- Logger: lib/kernel/src/log.dart for the version drift-guard. Coverage floor 95%.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:37:23', NULL, 'f68fd700a3487296ac6a00632b050543', 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-134', 'task', 'T-132', 'Spike: validate Claude Code team/transcript internals (pin a CC version)', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7.
|
||||
|
||||
---
|
||||
Refinement (2026-05-22, refinement-agent review):
|
||||
- Environment confirmed: tmux 3.6a; claude 2.1.148 (the version an-idea.md verified for tmux teammate spawning) -- PIN findings to 2.1.148. teammateMode absent from ~/.claude/settings.json (default auto) -> set teammateMode:tmux explicitly OR launch the lead inside tmux and rely on auto.
|
||||
- Check 3 ALREADY answered from a real ~/.claude/teams/*/config.json: {name,description,createdAt,leadAgentId,leadSessionId,members[]}; member = {agentId,name,agentType,model,joinedAt,tmuxPaneId (empty for lead, e.g. %154 for teammates),cwd,subscriptions,backendType:''tmux'',isActive,mode}. tmuxPaneId DOES populate -- spike just re-verifies on the pinned version.
|
||||
- Subagent path: <munged>/<session-uuid>/subagents/agent-<id>.jsonl (session UUID is a directory).
|
||||
- Check 5 (session-id) resolved by T-136: newest *.jsonl by mtime. Spike confirms only.
|
||||
- tmux -L clide socket may not exist unless the app is running; spike creates its own: tmux -L clide new-session -d -s teamhost ... claude.
|
||||
- Check 4 (paste): scope to @path file references (verifiable via send-keys headlessly). Clipboard-image needs an interactive display ($DISPLAY) -- note as interactive-only.
|
||||
- ACP directionality (an-idea.md s5) is OUT OF SCOPE: Zed-client concern; clide owns its GUI and does not use ACP.
|
||||
- Deliverable: findings note at docs/spikes/cc-team-internals-2.1.148.md (in-repo), feeding D-75 when authored (T-133). D-75 is claimed-but-unauthored (T-133''s job) -- the reference is forward-looking, not dangling.
|
||||
|
||||
---
|
||||
Findings (2026-05-22): docs/spikes/cc-team-internals-2.1.148.md. All 5 checks answered from real artifacts + tmux manual + synthetic control-mode test (no live run/quota). Key risk for T-139: pane->teammate->transcript identity joins only on agentType (config <-> .meta.json), ambiguous for same-type teammates -> needs joinedAt/mtime or lead-transcript tiebreaker. tmux 3.6a has no %pane-died; poll list-panes -a. Teammate transcripts at <munged>/<sid>/subagents/agent-<hex>.jsonl + .meta.json{agentType,description}.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:55:11', NULL, '2f9a445df8c60beafe126aff339410b8', 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-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:58:07', NULL, 'e8244b90386314574545f0c1a7bd012c', 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-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 18:14:32', NULL, '6651a2c3c9099f7d38553f6cf9e41e98', 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-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t <pane> (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:08:05', NULL, '2e6b932ad78ac78d83c86c5e08290ed3', 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-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t <pane> (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:49:09', NULL, '8b247ce6f9fc63c6f56030303aece2e8', 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-142', 'task', 'T-132', 'Composer attachment chips (thumbnail/icon on paste)', 'Show pasted files/images as chips above the composer input instead of inserting the @path as text. Image chips render a thumbnail (Image.file of the cache/temp file); other types show a Phosphor file icon + basename; each chip has a remove ×. On submit, the typed text plus the chips'' @path tokens are assembled into the message sent via tmux.sendMessage, then text + chips clear. Typed @path text still works. resolveClipboardAttachment changes to return a list of attachment descriptors (path + isImage). No new package deps (Image.file + existing Phosphor icons). Builds on T-138.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 22:06:59', '2026-05-22 22:09:09', NULL, 'da746aa3a523e3b351eaa13f19fa51e1', 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-142', 'task', 'T-132', 'Composer attachment chips (thumbnail/icon on paste)', 'Show pasted files/images as chips above the composer input instead of inserting the @path as text. Image chips render a thumbnail (Image.file of the cache/temp file); other types show a Phosphor file icon + basename; each chip has a remove ×. On submit, the typed text plus the chips'' @path tokens are assembled into the message sent via tmux.sendMessage, then text + chips clear. Typed @path text still works. resolveClipboardAttachment changes to return a list of attachment descriptors (path + isImage). No new package deps (Image.file + existing Phosphor icons). Builds on T-138.', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 22:06:59', '2026-05-22 22:18:02', NULL, '384c57378557c6f32f2cf93af99873c4', 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-143', 'task', 'T-132', 'Distinguish user message cards in the conversation', 'User messages render flat (label + text on the canvas) like Claude''s responses, so it''s hard to scan what you said vs what Claude said. Give UserMessage a distinct card: a filled background (globalBackground vs the panelBackground canvas), a left accent stripe (globalFocus), padding and rounded corners. Claude''s text responses stay flat markdown (better for long answers). conversation_view.dart _ConversationTurn.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-22 22:31:22', '2026-05-22 22:31:25', NULL, 'efa90d878f79235f81d54d00c04014c8', 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-143', 'task', 'T-132', 'Distinguish user message cards in the conversation', 'User messages render flat (label + text on the canvas) like Claude''s responses, so it''s hard to scan what you said vs what Claude said. Give UserMessage a distinct card: a filled background (globalBackground vs the panelBackground canvas), a left accent stripe (globalFocus), padding and rounded corners. Claude''s text responses stay flat markdown (better for long answers). conversation_view.dart _ConversationTurn.', 'done', 'low', NULL, NULL, NULL, '2026-05-22 22:31:22', '2026-05-22 22:36:09', NULL, '11ebd7f43b4371b15acb0729010cdec9', 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-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:41:28', NULL, '4c0eebba4edb4394fb47ba2d81ef42ad', 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-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'done', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:43:45', NULL, '1ce891624631502279b0356c5cdffae2', 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-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 22:50:41', NULL, 'a5f4b20a0a37332ab35de8fa07e22298', 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-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 23:16:28', NULL, 'bd5e9cc5ae0e8bdbac4ad8cc57b33b58', 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-140', 'story', 'T-132', 'Teammate tile grid (faithful tiling, responsive auto-wrap)', 'Tiling host: lead ConversationView (left, with composer) + teammate ConversationViews (right) in a responsive auto-wrap grid (1->2->3 columns by count); resizable lead/teammate split. Workspace currently only vertical-splits, so extend _WorkspaceSlot (lib/app.dart) or add team slots in lib/kernel/src/panels/layout_preset.dart; reuse DragResizeHandle (lib/kernel/src/panels/drag_resize.dart). Driven by TeamMemberBorn/Died from the observer. Acceptance: tiles appear/disappear with lifecycle; grid wraps 1->2->3 by count; split resizes; select-across-tiles works. Blocked by T5, T7.', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 23:31:46', NULL, '9c4254dcfca0f81367a0d909c72e9dd4', 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-145', 'task', 'T-132', 'Per-session status strip: model / permission-mode / context%', 'A compact per-session status strip in the lead Claude pane header AND each teammate tile header (T-140), showing: current model, permission mode (accept-edits / plan / default / bypass), and context-window usage %.
|
||||
|
||||
Shared status source (this ticket owns it): extract per-session status in the drift-contained reader/observer (D-75) — do NOT parse CC internals in the widgets.
|
||||
- Model: team config member.model, overridden by the latest assistant message.model in the transcript when present.
|
||||
- Permission mode: the transcript''s `permission-mode` records (TranscriptReader currently SKIPS these — surface the latest instead). Also honor config member.planModeRequired for teammates.
|
||||
- Context %: latest assistant message.usage (input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens) over the model''s context limit.
|
||||
|
||||
Delivery: emit a per-session status to the UI via the bus/events (e.g. a TeamMemberStatus event or an extra MessageBus channel) so BOTH the header strip and the T-141 sidebar render from one source without re-tailing.
|
||||
|
||||
Applies to the lead session and every teammate. Re-validate field names against a live team run / CC version bump (undocumented, per T-134).
|
||||
|
||||
Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done).
|
||||
', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-23 06:07:59', '2026-05-23 06:07:59', NULL, '5047ae730e9065c684fcecf557353de7', 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-141', 'task', 'T-132', 'Team-meta sidebar (roster + token budget)', 'New sidebar panel (contributed to Slots.sidebar), visible only in team mode: a roster row per team member that MIRRORS the per-session status surfaced by T-145, plus team-wide meta.
|
||||
|
||||
Per-member row:
|
||||
- Identity from the team config + observer: name, agentType, color; membership/lifecycle from TeamMemberJoined/Left.
|
||||
- Mirror of T-145''s shared status: current model, permission-mode badge (accept-edits / plan / default / bypass), context-window usage %. Consume the SAME status source/event T-145 emits — do NOT re-tail transcripts or re-parse CC internals here (D-75).
|
||||
- Live status verb + elapsed-since-last-activity + down/up token deltas from that same status feed.
|
||||
|
||||
Team-wide:
|
||||
- Team/account token budget pulled from the Anthropic usage API on a timer (distinct from per-conversation context above).
|
||||
|
||||
Acceptance: roster matches list-panes/config; each row mirrors the member''s model + accept-edits/plan badge + context-usage; status verb and token deltas update live; account budget refreshes on its timer; panel appears only when a team is active. Depends on T-145 (status source) + T-139 (done).
|
||||
', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-23 06:08:24', NULL, '54edba4b89b8926c3527b4f1174c2a4c', 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-146', 'bug', 'T-132', 'Secondary Claude panes render the primary''s transcript', 'Regression from T-137: every Claude pane renders the newest .jsonl in the workspace''s munged dir, so concurrent sessions in one workspace (primary + secondaries) all converge on the same transcript — a secondary tab shows the primary''s conversation (and the primary follows whichever session is newest). The pre-T-137 terminal pane rendered each tab''s own PTY, so tabs were independent.
|
||||
|
||||
Fix: bind each pane to a specific Claude session id.
|
||||
- claude CLI supports `--session-id <uuid>` (verified via --help). Spawn each pane''s claude with its own UUID; a transcript is named <session-id>.jsonl, so the pane''s transcript is <munged-cwd>/<uuid>.jsonl.
|
||||
- Reader targets that exact file via TranscriptReader''s `file:` param (added in T-139). Guard _tail against the file not existing yet (claude writes it shortly after spawn).
|
||||
- Each pane publishes/subscribes on a per-session MessageBus channel (conversation/<uuid>) so primary and secondary controllers don''t cross-talk.
|
||||
- UUID per pane, seeded by the tmux session name (the identity we already have): primary = deterministic from its stable session name (clide-claude-<slug>) so it RESUMES across restarts (D-41 continuity); secondary = random per spawn so each is a clean/fresh session (a deterministic secondary id would make starting a clean session impossible).
|
||||
|
||||
Migration caveat: an existing tmux session created by the OLD code (no --session-id, claude picked its own id) won''t match the new deterministic id — it must be killed (claude.kill-all-sessions) once so the next spawn binds the controlled id.
|
||||
|
||||
Open question to validate live: whether `claude --session-id <existing-uuid>` resumes vs. errors when re-spawned after the tmux session was killed (display works either way since the file is <uuid>.jsonl, but worth confirming claude starts).
|
||||
|
||||
Acceptance: a secondary tab shows its OWN fresh conversation, independent of the primary; the primary keeps its conversation; both update live. Regression of T-137; relates to D-41.
|
||||
', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, 'c47ada73a71c18d1998cc6fdc36d1b22', 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-146', 'bug', 'T-132', 'Secondary Claude panes render the primary''s transcript', 'Regression from T-137: every Claude pane renders the newest .jsonl in the workspace''s munged dir, so concurrent sessions in one workspace (primary + secondaries) all converge on the same transcript — a secondary tab shows the primary''s conversation (and the primary follows whichever session is newest). The pre-T-137 terminal pane rendered each tab''s own PTY, so tabs were independent.
|
||||
|
||||
Fix: bind each pane to a specific Claude session id.
|
||||
- claude CLI supports `--session-id <uuid>` (verified via --help). Spawn each pane''s claude with its own UUID; a transcript is named <session-id>.jsonl, so the pane''s transcript is <munged-cwd>/<uuid>.jsonl.
|
||||
- Reader targets that exact file via TranscriptReader''s `file:` param (added in T-139). Guard _tail against the file not existing yet (claude writes it shortly after spawn).
|
||||
- Each pane publishes/subscribes on a per-session MessageBus channel (conversation/<uuid>) so primary and secondary controllers don''t cross-talk.
|
||||
- UUID per pane, seeded by the tmux session name (the identity we already have): primary = deterministic from its stable session name (clide-claude-<slug>) so it RESUMES across restarts (D-41 continuity); secondary = random per spawn so each is a clean/fresh session (a deterministic secondary id would make starting a clean session impossible).
|
||||
|
||||
Migration caveat: an existing tmux session created by the OLD code (no --session-id, claude picked its own id) won''t match the new deterministic id — it must be killed (claude.kill-all-sessions) once so the next spawn binds the controlled id.
|
||||
|
||||
Open question to validate live: whether `claude --session-id <existing-uuid>` resumes vs. errors when re-spawned after the tmux session was killed (display works either way since the file is <uuid>.jsonl, but worth confirming claude starts).
|
||||
|
||||
Acceptance: a secondary tab shows its OWN fresh conversation, independent of the primary; the primary keeps its conversation; both update live. Regression of T-137; relates to D-41.
|
||||
', 'done', 'high', NULL, NULL, NULL, '2026-05-23 06:19:51', '2026-05-23 06:30:47', NULL, '2e1843c9b540a3d7ced03052d9efcbc4', 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-147', 'bug', 'T-132', 'Claude pane gets stuck waiting on a stale/legacy session', 'When the bound transcript (<munged-cwd>/<session-id>.jsonl) never appears — e.g. attaching via new-session -A to a session created before --session-id binding (T-146), or any unconnectable session — the pane shows ''Waiting for Claude…'' forever. Fix: before spawning, if the bound transcript for our deterministic session id does not exist, kill the clide tmux session of that exact name so new-session creates a clean session with our --session-id. SAFETY (hard requirement): only ever kill clide-initiated sessions — by exact clide-claude-<slug> name, on the private -L clide socket (a user''s terminal claude is never on that socket); never delete any transcript file. Also makes clean-install/first-run robust. Live-verify the stuck primary self-heals on relaunch and a secondary still gets a fresh independent session.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 07:03:18', NULL, '21f7ebec0ddcab48a3ea7e8be0c0e979', 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-148', 'task', 'T-132', 'Claude transcript disk-usage indicator + cleanup button', 'Surface transcript/session disk usage in the Claude panel with a cleanup affordance so the user can pick and choose what to remove. Per-session sizes (the <munged-cwd>/*.jsonl files + subagents/), a total, and a button to delete selected sessions'' transcripts. User-driven only — clide never auto-deletes transcript content. Future/''at some point'' per user. Relates to T-146 (session ids) and D-75.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 07:03:18', NULL, '7bbe7f4baf80027516cc46cff93a8dfe', 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);
|
||||
|
||||
+142
@@ -18,14 +18,126 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- tmux agent teams surface as native teammate tiles (T-139, T-140) —
|
||||
when a Claude team is running, each teammate shows as a live
|
||||
conversation tile beside the lead in a grid that wraps 1→2→3 columns,
|
||||
with a resizable split. Identity and lifecycle come from the team
|
||||
config; per-teammate content streams over the MessageBus.
|
||||
- Native composer in the Claude pane (T-138) — type below the
|
||||
conversation and press Enter to send (Shift+Enter for a newline).
|
||||
Submits via the tmux server (bracketed paste + Enter), so input
|
||||
reaches Claude even when no tmux client is attached; multi-line goes
|
||||
as one message.
|
||||
- File and image paste in the composer (T-138, T-142) — Ctrl/Cmd+V of a
|
||||
copied file or clipboard image adds a removable chip (image thumbnail
|
||||
or file icon) above the input; on send its `@path` is appended to the
|
||||
message. Plain text pastes inline. Backed by a native `clide/clipboard`
|
||||
channel (GTK + macOS).
|
||||
- Claude pane renders natively from the transcript (T-137, D-75) — the
|
||||
conversation shows as native cards (user / assistant markdown /
|
||||
thinking / tool-use / result) instead of a terminal, with text
|
||||
selection + copy across cards. Claude still runs in tmux; the terminal
|
||||
builtin stays for general use.
|
||||
- Multi-file editor tabs — the editor pane now shows one tab per open
|
||||
buffer (filename + a dot when unsaved) via the shared tab strip;
|
||||
opening a second file no longer replaces the first. Click a tab to
|
||||
switch, × to close. Backed by the daemon's existing multi-buffer
|
||||
model.
|
||||
- Typed IPC command-schema framework (T-119/T-120, D-74) — commands
|
||||
register an argument schema beside their handler; the dispatcher
|
||||
normalises argv into named args, coerces types, and validates
|
||||
(charset, leading-dash, ranges, caps) before the handler runs.
|
||||
- `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
|
||||
|
||||
- User and Claude turns in the conversation now render as distinct
|
||||
accent-striped cards over a filled background — your prompts in the
|
||||
theme focus colour, Claude's replies in Claude's brand orange
|
||||
(T-143, T-144).
|
||||
- Claude conversation content now flows through the kernel MessageBus —
|
||||
a reader tails the transcript and publishes items; the pane subscribes.
|
||||
Decouples reading from rendering so the upcoming team panels can show
|
||||
one lead plus a tile per teammate (T-137).
|
||||
- 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
|
||||
|
||||
- Claude pane no longer gets stuck on "Waiting for Claude…" when its
|
||||
session can't be bound (T-147) — e.g. a session left over from before
|
||||
session-id binding, or a fresh machine. It now retires that stale
|
||||
clide session and starts a clean one. Only clide's own `-L clide`
|
||||
sessions are touched (never a terminal Claude, never transcript files).
|
||||
- Secondary Claude tabs showed the primary's conversation instead of
|
||||
their own (T-146). Each pane now binds to its own session via
|
||||
`claude --session-id`, so concurrent sessions in one workspace stop
|
||||
colliding on the newest transcript — the primary keeps a stable id
|
||||
(resumes), secondaries get a fresh one (clean session).
|
||||
- Claude pane no longer freezes the app on open — the transcript reader
|
||||
caps its initial read to the recent tail, parses off the UI isolate,
|
||||
and coalesces view notifications into one rebuild per burst (T-137).
|
||||
- Daemon-not-connected on startup — panels and the Claude pane raced
|
||||
the socket loopback. Requests now wait briefly for an in-flight
|
||||
connection, the server isn't restarted for the same workspace, and
|
||||
the client connects once instead of twice.
|
||||
|
||||
### Security
|
||||
|
||||
## [2.1.0] — 2026-05-18
|
||||
@@ -91,12 +203,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 +301,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
-3
@@ -11,8 +11,8 @@ flutter analyze
|
||||
echo "==> dart format (whole tree)"
|
||||
dart format --set-exit-if-changed .
|
||||
|
||||
echo "==> dart test (forkpty — incompatible with flutter test runner)"
|
||||
dart test --tags forkpty test/pty/session_test.dart test/panes/registry_test.dart
|
||||
echo "==> dart test (pty — unreliable under the flutter test runner)"
|
||||
dart test --tags pty test/pty/session_test.dart test/panes/registry_test.dart
|
||||
|
||||
echo "==> flutter test --coverage (unit + widget + golden)"
|
||||
flutter test --coverage --exclude-tags forkpty
|
||||
flutter test --coverage --exclude-tags pty
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-5
@@ -3,8 +3,10 @@
|
||||
# both honor them. Undeclared tags are ignored by the runners, which
|
||||
# silently breaks selective excludes.
|
||||
tags:
|
||||
# Tests that call `forkpty()` via Dart FFI. Must run under `dart test`,
|
||||
# not `flutter test` — the latter's runner hosts a multi-threaded
|
||||
# Flutter engine in which forkpty produces a master fd that never
|
||||
# delivers output. See `test/pty/session_test.dart`.
|
||||
forkpty:
|
||||
# Native-PTY tests (posix_openpt + posix_spawn via Dart FFI). Must run
|
||||
# under `dart test`, not `flutter test`: the flutter-test runner hosts a
|
||||
# multi-threaded engine in which the PTY master fd doesn't reliably
|
||||
# deliver output, so these flake there (verified). They are reliable
|
||||
# under `dart test`. (Tag was historically named `forkpty`, before
|
||||
# T-96 replaced forkpty with posix_spawn.) See `test/pty/session_test.dart`.
|
||||
pty:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Spike: Claude Code team / transcript internals (T-134)
|
||||
|
||||
**Pinned to:** claude **2.1.148**, tmux **3.6a**. These are undocumented, version-drifting
|
||||
internal contracts (per D-75) — re-validate on a CC bump.
|
||||
|
||||
**Method:** validated from real on-disk artifacts (42 past team `config.json`s, real
|
||||
team + Task-tool subagent transcripts, `.meta.json` written by the current version) plus
|
||||
a synthetic `tmux -L clide` control-mode test and the tmux manual. No live team run was
|
||||
needed to answer the questions — the existing artifacts are conclusive and cost no quota.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. Teammates get tmux panes; transcripts live under `subagents/`
|
||||
- **Confirmed:** across real teams, **42 teammate members carry a populated `tmuxPaneId`**
|
||||
(e.g. `%5`, `%120`); the lead's `tmuxPaneId` is `""`. So teammates spawn as panes and the
|
||||
config records the pane id.
|
||||
- **Teammate transcript location:** `~/.claude/projects/<munged-cwd>/<session-id>/subagents/agent-<hex>.jsonl`,
|
||||
with a sibling `agent-<hex>.meta.json`. `<munged-cwd> = absolutePath.replaceAll('/','-')`
|
||||
(leading `-` kept). Note: a team's `<session-id>` dir held **0 top-level `*.jsonl`** and
|
||||
**44 `subagents/agent-*.jsonl`** — teammate content is the subagent files, not top-level sessions.
|
||||
- Each subagent record carries `agentId` (the **hex**, e.g. `a2a3530` — matches the filename),
|
||||
`sessionId` (the dir), `isSidechain: true`, `slug` (a random codename), `type`
|
||||
(`user`/`assistant`/…). This is the same JSONL schema `TranscriptReader` (T-136) already parses.
|
||||
|
||||
### 2. Lifecycle signal — control mode vs. polling
|
||||
- **tmux 3.6a control-mode notifications** (from `man tmux`): `%window-add`, `%window-close`,
|
||||
`%window-pane-changed`, `%layout-change`, `%unlinked-window-add`, `%unlinked-window-close`,
|
||||
`%session-changed`, `%sessions-changed`, `%pane-mode-changed`, `%exit`, `%output`/`%extended-output`, …
|
||||
**There is NO `%pane-died`** (an-idea.md assumed one). Pane/teammate exit surfaces via
|
||||
`%window-close` / `%layout-change` / `%window-pane-changed`.
|
||||
- **Driving control mode from code is finicky:** a `tmux -L clide -C attach` captured
|
||||
`%session-changed`/`%exit` but the attach exited early under non-interactive Bash; reliably
|
||||
consuming the stream needs a long-lived managed client.
|
||||
- **Polling `tmux -L clide list-panes -a -F '...'` works and is reliable** (validated: it
|
||||
enumerated panes with pane-id/pid/title). **Recommendation for T-139: use polling as the
|
||||
baseline lifecycle source**; treat control mode as a later optimization.
|
||||
|
||||
### 3. Team config schema (`~/.claude/teams/<team>/config.json`)
|
||||
- Top keys: `name`, `description`, `createdAt`, `leadAgentId`, `leadSessionId`, `members[]`.
|
||||
- Member keys: `name`, `agentId` (=`<name>@<team>`), `agentType`, `model`, `cwd`,
|
||||
`joinedAt`, `subscriptions`, `tmuxPaneId`. (Some runs also carry `backendType`/`isActive`/`mode` —
|
||||
optional, version-varying.) The lead member has empty `tmuxPaneId`.
|
||||
|
||||
### 4. ⚠️ Identity linkage — the real risk for T-139
|
||||
The pane/teammate identity in **config** does NOT share a key with the **transcript file**:
|
||||
- Config: `{name: gestalt, agentId: gestalt@control-interaction, agentType: gestalt, tmuxPaneId: %120}`.
|
||||
- Transcript: `agent-<hex>.jsonl` (records `agentId = <hex>`, `slug = <random>`) +
|
||||
`agent-<hex>.meta.json = {agentType, description}`.
|
||||
- **The only join key is `agentType`** (config.member.agentType ↔ `.meta.json.agentType`).
|
||||
This is **unambiguous only when teammates have distinct agentTypes** (e.g. `control-interaction`:
|
||||
gestalt/ozzie/tyre/…). For **same-type teammates** (e.g. `art-requirements`: 3× `general-purpose`)
|
||||
agentType is ambiguous → need a disambiguator: spawn order / `joinedAt` timestamp vs. file mtime,
|
||||
or parse the **lead transcript's** teammate-spawn records (likely carry both ids). **T-139 must
|
||||
handle this**; recommend: join on agentType, fall back to ordering by `joinedAt`/mtime, and
|
||||
investigate the lead transcript's spawn events for an explicit hex↔name link.
|
||||
|
||||
### 5. Resolved elsewhere
|
||||
- **Session-id discovery (check 5):** Claude doesn't expose its session id; pick newest `*.jsonl`
|
||||
by mtime — already implemented in `TranscriptReader` (T-136).
|
||||
- **Paste (check 4):** scope to `@path` file references over `send-keys` (text channel). Clipboard
|
||||
image paste needs an interactive display ($DISPLAY) and is out of scope for headless validation;
|
||||
decide the composer's image handling in T-138/T-006.
|
||||
|
||||
## Recommendations for the team tickets
|
||||
- **T-139 (observer):** poll `list-panes -a` for lifecycle; read team `config.json` for the roster
|
||||
+ pane ids; tail teammate transcripts at `<munged>/<sid>/subagents/agent-<hex>.jsonl`; resolve
|
||||
pane→transcript via `agentType` join with a `joinedAt`/mtime tiebreaker (and confirm whether the
|
||||
lead transcript gives an explicit link). Isolate all of this behind the one module (D-75).
|
||||
- **Re-validate on any CC version bump** — key off the transcript `version` field.
|
||||
|
||||
## Not done
|
||||
- A **live, real-time** team run (watching a pane + transcript appear live) was not executed —
|
||||
the static artifacts answer every question and a live run costs quota without adding certainty.
|
||||
The one item a fresh run would pin precisely: whether the **lead transcript** records an explicit
|
||||
teammate hex↔name mapping (would remove the same-type ambiguity). Worth a short observed run when
|
||||
T-139 is implemented.
|
||||
@@ -111,6 +111,12 @@ 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_
|
||||
- [D-74: IPC command schema is co-registered with the handler, validated at dispatch](decisions/architecture.md#d-74-ipc-command-schema-is-co-registered-with-the-handler-validated-at-dispatch) — _architecture_
|
||||
- [D-75: Claude rendered natively from transcripts; terminal retained as general tool only](decisions/architecture.md#d-75-claude-rendered-natively-from-transcripts-terminal-retained-as-general-tool-only) — _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,60 @@ 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.
|
||||
|
||||
### D-74: IPC command schema is co-registered with the handler, validated at dispatch
|
||||
- **Date:** 2026-05-20
|
||||
- **Decision:** Each IPC command may declare a **typed argument schema** (per-arg charset/regex/range constraints, required/optional, kind). The schema is **registered alongside the handler**, not authored in a central file: the `DaemonDispatcher` registration API carries an optional schema per command, and the dispatcher accumulates a `cmd → schema` registry as commands register. `DaemonDispatcher.dispatch` validates `req.args` against the command's schema **before** invoking the handler (`lib/src/daemon/dispatcher.dart`), returning a `userError` (sysexit 64) on violation so no handler sees malformed input. Built-in command modules (`registerPaneCommands`, `registerGitCommands`, …) supply their schemas at registration; extension-contributed commands ([`CommandContribution`](../../lib/extension/src/contribution.dart), Tier 6 / [D-46](tooling.md)) carry their own. The pre-existing T-104 spot-checks — `validateGitRef` in `lib/src/git/operations.dart` and the count/path caps in `lib/src/daemon/git_commands.dart` — stay in place as **defense-in-depth** below the dispatcher, because the git client is also callable directly from the Flutter UI (not only through the dispatcher). MCP `tools/list` generation from this registry is **out of scope** here — deferred to the T-130 track.
|
||||
- **Rationale:** clide's command surface is open, not fixed: every `register*Commands()` module already contributes a *set* of commands, and the extension framework lets plugins contribute more at runtime. A central static schema map (`command_schema.dart` keyed by every known cmd) cannot see extension-contributed commands and would fight the plugin model — it was considered and rejected for exactly that reason. Co-registration keeps the schema next to the code that owns the command's meaning, supports any number of independent registrants (the normal case, not an edge case), and still yields a single validation lookup at dispatch time. Validating at the dispatcher (rather than per-handler) is what makes the constraint a contract instead of a convention: a new command can't forget to validate, and the `_ResizeArgs`-style hand-lifts scattered through handlers (see `panel_commands.dart`, T-119) collapse into the schema. No third-party validation library — the constraint vocabulary is hand-rolled per the prefer-zero-deps guardrail.
|
||||
- **Cost:** A schema must be authored for each command (build-fresh — the argv parser at `lib/src/cli/argv_to_request.dart` only knows syntactic shape, never per-command argument names, so there is nothing to "lift"). Keeping `validateGitRef` + caps as well as the schema is deliberate redundancy on the git path; the alternative (single gate at the dispatcher) would leave the UI→client call path unvalidated. Until every command has a schema, validation is opt-in per command — commands with no registered schema dispatch unvalidated, same as today.
|
||||
- **Cross-reference:** [D-6](#d-6-cli-and-event-surface-contract) (CLI/Claude parity the schema enforces), [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) (the dispatch path being gated), [D-46](tooling.md) (extension model the central-map alternative would have broken), `lib/src/daemon/dispatcher.dart` (the validation hook), `lib/src/git/operations.dart` + `lib/src/daemon/git_commands.dart` (the T-104 checks kept as defense-in-depth).
|
||||
- **Raised by:** 2026-05-20 — T-120 design pass; user rejected the central registry as misaligned with the extensions/plugins model and confirmed co-registration with multiple command-set registrants.
|
||||
|
||||
### D-75: Claude rendered natively from transcripts; terminal retained as general tool only
|
||||
- **Date:** 2026-05-22
|
||||
- **Decision:** clide renders the Claude conversation as **native Flutter widgets** driven by Claude Code's transcript JSONL (`~/.claude/projects/<munged-cwd>/<session-id>.jsonl`; teammate agents under `<session-id>/subagents/agent-<id>.jsonl`). The PTY/terminal emulator is **not** used to scrape or render Claude's TUI output. Claude still runs under tmux (`-L clide`) for process and session persistence per [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), but its *content* is sourced from the transcript file, not from the PTY stream. The terminal emulator (`builtin.terminal`) is retained and fully functional as a general-purpose IDE tool — shell sessions, build output, REPL — but it is not the Claude rendering surface. All transcript parsing is isolated behind a single reader/observer module (`lib/builtin/claude/src/transcript/`) keyed off the transcript `version` field; coupling to Claude Code internal contracts (transcript JSONL schema, `~/.claude/teams/*/config.json`, tmux control mode) is **isolated and version-pinned** (initially claude 2.1.148). **Principle ordering:** Claude-centric first; CLI-first (D-1, D-6) is a strong second. D-6 CLI/event surfaces are preserved where sensible; CLI-first no longer blocks the Claude integration.
|
||||
- **Rationale:** (a) Native Flutter rendering gives cross-widget text selection and copy — the one meaningful advantage of a terminal emulator for plain-text output, recovered in a more flexible form. (b) Removes OS-variant PTY/TUI rendering fragility (xterm escape sequences, tmux passthrough, font-metric alignment) from the Claude display path. (c) It is the substrate for surfacing tmux agent teams as real GUI panels: once the transcript reader exists, each subagent's `.jsonl` file feeds its own panel with no extra IPC. (d) Structured transcript data enables features that scraped terminal text cannot: message-level copy, per-message actions, cost/token annotation, search, diff highlighting. Accepting version-drifting coupling to CC internals is mitigated by isolation + version-pin + fixture tests: the reader module is the only file that knows the JSONL shape; bumping Claude means updating one module and running the fixture suite.
|
||||
- **Cost:** Depends on undocumented and potentially version-drifting Claude Code internals (transcript JSONL schema, subagent path conventions, `~/.claude/teams/*/config.json`). Mitigated by: (1) isolation — all parsing behind `lib/builtin/claude/src/transcript/`, nothing else touches the schema; (2) version-pinning — initial target claude 2.1.148, bumps are deliberate and tested; (3) fixture tests — golden JSONL snapshots at each pinned version exercise the reader. If the schema drifts beyond the reader's tolerance, the Claude pane degrades gracefully (shows a parse-error banner with the raw transcript path) rather than crashing.
|
||||
- **Cross-reference:** [D-1](#d-1-cli-first-not-mcp) (CLI-first preserved as strong second), [D-6](#d-6-cli-and-event-surface-contract) (CLI/event surfaces preserved), [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) (tmux process persistence unchanged), [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server) (Flutter app hosts in-process). Implemented by epic T-132.
|
||||
- **Raised by:** 2026-05-22 — native Claude integration epic (T-132); user set Claude-centric > CLI-first ordering.
|
||||
|
||||
---
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/// Native input composer for the Claude pane (epic T-132, T-138).
|
||||
///
|
||||
/// A no-Material [EditableText] (D-7) below the [ConversationView].
|
||||
/// Enter submits; Shift+Enter inserts a newline. The composed message
|
||||
/// (typed text plus any attachment `@path` tokens) is handed to
|
||||
/// [ClaudeComposer.onSubmit]; the pane delivers it to Claude. Pasted
|
||||
/// files/images show as removable chips (T-142); plain text paste falls
|
||||
/// through to the default.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/clipboard_paste.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Encodes [text] for delivery to Claude's PTY via `pane.write`.
|
||||
///
|
||||
/// Single-line input is sent literally followed by a carriage return
|
||||
/// (submit). Multi-line input is wrapped in bracketed-paste markers so
|
||||
/// the embedded newlines are treated as pasted content rather than a
|
||||
/// stream of submits, then a trailing CR submits the whole block.
|
||||
String encodeClaudeInput(String text) {
|
||||
if (text.contains('\n')) {
|
||||
return '\x1b[200~$text\x1b[201~\r';
|
||||
}
|
||||
return '$text\r';
|
||||
}
|
||||
|
||||
/// Intent fired when the user presses Enter (no modifiers) to submit.
|
||||
class SubmitComposerIntent extends Intent {
|
||||
const SubmitComposerIntent();
|
||||
}
|
||||
|
||||
class ClaudeComposer extends StatefulWidget {
|
||||
const ClaudeComposer({
|
||||
super.key,
|
||||
required this.onSubmit,
|
||||
this.enabled = true,
|
||||
this.hint = 'Message Claude… (Enter to send · Shift+Enter for newline)',
|
||||
this.pasteResolver,
|
||||
});
|
||||
|
||||
/// Called with the composed message (typed text plus attachment `@path`
|
||||
/// tokens) when the user submits. The pane delivers it to Claude.
|
||||
final void Function(String text) onSubmit;
|
||||
|
||||
final bool enabled;
|
||||
final String hint;
|
||||
|
||||
/// Optional override of paste handling: returns the attachments on the
|
||||
/// clipboard (files / images), or an empty list to fall back to the
|
||||
/// default plain-text paste. Injected so the pane can wire in native
|
||||
/// file/image clipboard support and tests can fake it.
|
||||
final Future<List<ComposerAttachment>> Function()? pasteResolver;
|
||||
|
||||
@override
|
||||
State<ClaudeComposer> createState() => _ClaudeComposerState();
|
||||
}
|
||||
|
||||
class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focus = FocusNode();
|
||||
final List<ComposerAttachment> _attachments = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onTextChanged);
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTextChanged() => setState(() {});
|
||||
|
||||
void _submit() {
|
||||
if (!widget.enabled) return;
|
||||
final text = _controller.text;
|
||||
final tokens = _attachments.map((a) => a.pathToken);
|
||||
if (text.trim().isEmpty && _attachments.isEmpty) return;
|
||||
// Typed text first, then the attachment @path references.
|
||||
final message = [
|
||||
if (text.trim().isNotEmpty) text,
|
||||
...tokens,
|
||||
].join(' ');
|
||||
widget.onSubmit(message);
|
||||
_controller.clear();
|
||||
setState(() => _attachments.clear());
|
||||
}
|
||||
|
||||
Future<void> _handlePaste() async {
|
||||
final resolver = widget.pasteResolver;
|
||||
if (resolver != null) {
|
||||
final attachments = await resolver();
|
||||
if (attachments.isNotEmpty) {
|
||||
if (!mounted) return;
|
||||
setState(() => _attachments.addAll(attachments));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fall back to the default plain-text paste.
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final clip = data?.text;
|
||||
if (clip != null && clip.isNotEmpty && mounted) {
|
||||
_insertAtCursor(clip);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeAttachment(ComposerAttachment attachment) {
|
||||
setState(() => _attachments.remove(attachment));
|
||||
}
|
||||
|
||||
void _insertAtCursor(String insertion) {
|
||||
final value = _controller.value;
|
||||
final sel = value.selection;
|
||||
final base = sel.isValid ? sel : TextSelection.collapsed(offset: value.text.length);
|
||||
final newText = value.text.replaceRange(base.start, base.end, insertion);
|
||||
final caret = base.start + insertion.length;
|
||||
_controller.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: caret),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideTheme.of(context).surface;
|
||||
final hasText = _controller.text.isNotEmpty;
|
||||
final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 6, 10, 10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.globalBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_attachments.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [for (final a in _attachments) _chip(theme, a)],
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
label: widget.hint,
|
||||
textField: true,
|
||||
child: Shortcuts(
|
||||
shortcuts: const {
|
||||
SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
SubmitComposerIntent: CallbackAction<SubmitComposerIntent>(
|
||||
onInvoke: (_) {
|
||||
_submit();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
PasteTextIntent: CallbackAction<PasteTextIntent>(
|
||||
onInvoke: (_) {
|
||||
unawaited(_handlePaste());
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!hasText)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
|
||||
),
|
||||
EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
readOnly: !widget.enabled,
|
||||
style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4),
|
||||
cursorColor: theme.globalFocus,
|
||||
backgroundCursorColor: theme.globalTextMuted,
|
||||
maxLines: 8,
|
||||
minLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One attachment chip: a thumbnail (images) or file icon (other types),
|
||||
/// the filename, and a remove × that cancels the attachment before send.
|
||||
Widget _chip(SurfaceTokens theme, ComposerAttachment a) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.panelBackground,
|
||||
border: Border.all(color: theme.globalBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(6, 4, 4, 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_chipLeading(theme, a),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: ClideText(
|
||||
a.fileName,
|
||||
fontSize: clideFontSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Remove ${a.fileName}',
|
||||
child: GestureDetector(
|
||||
key: ValueKey('composer-remove-${a.path}'),
|
||||
onTap: () => _removeAttachment(a),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideIcon(PhosphorIcons.xMark, size: 12, color: theme.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chipLeading(SurfaceTokens theme, ComposerAttachment a) {
|
||||
const dim = 28.0;
|
||||
if (a.isImage) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.file(
|
||||
File(a.path),
|
||||
width: dim,
|
||||
height: dim,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => ClideIcon(PhosphorIcons.image, size: 18, color: theme.globalTextMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ClideIcon(PhosphorIcons.fileText, size: 18, color: theme.globalTextMuted);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
@@ -7,10 +6,15 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:clide/src/terminal/terminal.dart';
|
||||
|
||||
import 'claude_composer.dart';
|
||||
import 'clipboard_paste.dart';
|
||||
import 'conversation_controller.dart';
|
||||
import 'conversation_view.dart';
|
||||
import 'session_naming.dart';
|
||||
import 'tmux_session.dart' as tmux;
|
||||
import 'transcript_publisher.dart';
|
||||
import 'transcript_reader.dart';
|
||||
|
||||
class ClaudePane extends StatefulWidget {
|
||||
const ClaudePane({
|
||||
@@ -29,32 +33,42 @@ class ClaudePane extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
static const _maxLines = 50000;
|
||||
// Fixed tmux window size — Claude's TUI is no longer rendered (we read
|
||||
// its transcript instead, T-137/D-75), so a sane default is enough to
|
||||
// keep claude's layout happy inside the headless tmux session.
|
||||
static const _cols = 120;
|
||||
static const _rows = 40;
|
||||
static String? _tmuxConfPath;
|
||||
|
||||
late final Terminal _terminal;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
ConversationController? _conversation;
|
||||
TranscriptPublisher? _feed;
|
||||
String? _paneId;
|
||||
String? _sessionName;
|
||||
String? _sessionId;
|
||||
String? _error;
|
||||
String _statusLine = 'attaching…';
|
||||
|
||||
bool _spawned = false;
|
||||
bool _usingTmux = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminal = Terminal(maxLines: _maxLines);
|
||||
_terminal.onOutput = _onTerminalOutput;
|
||||
_terminal.onResize = _onTerminalResize;
|
||||
// Don't spawn here — wait for the first onResize from TerminalView
|
||||
// so the PTY gets real dimensions, not 80x24 defaults.
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Spawn once, after the kernel is available. The conversation renders
|
||||
// from the transcript, so we no longer wait on a terminal resize.
|
||||
if (!_spawned) {
|
||||
_spawned = true;
|
||||
unawaited(_spawnWhenReady());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resizeTimer?.cancel();
|
||||
_flushTimer?.cancel();
|
||||
_conversation?.dispose();
|
||||
_conversation = null;
|
||||
unawaited(_feed?.dispose());
|
||||
_feed = null;
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
final id = _paneId;
|
||||
@@ -130,10 +144,33 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
|
||||
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
// Bind this pane to a specific Claude session id so concurrent
|
||||
// sessions in one workspace don't collide on the newest transcript
|
||||
// (T-146). Primary: deterministic → resumes across restarts.
|
||||
// Secondary: fresh → always a clean session.
|
||||
_sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId();
|
||||
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl';
|
||||
|
||||
// Self-heal (T-147): if no transcript is bound to our session id, any
|
||||
// clide tmux session of this name is stale (created before --session-id
|
||||
// binding, or otherwise unconnectable) and `new-session -A` would
|
||||
// attach to it and leave the pane stuck waiting forever. Kill it so a
|
||||
// clean session is created with our --session-id.
|
||||
//
|
||||
// Safe by construction: this only ever kills clide's OWN session — by
|
||||
// its exact `clide-claude-<slug>` name, on the private `-L clide`
|
||||
// socket the user's terminal claude never runs on — and never deletes
|
||||
// any transcript file. A healthy session's transcript already exists,
|
||||
// so re-attach (D-41 continuity) is preserved.
|
||||
if (!await File(transcriptFile).exists()) {
|
||||
await tmux.killSession(_sessionName!);
|
||||
}
|
||||
|
||||
final tmuxConf = await _ensureTmuxConf();
|
||||
final cols = _terminal.viewWidth;
|
||||
final rows = _terminal.viewHeight;
|
||||
const cols = _cols;
|
||||
const rows = _rows;
|
||||
|
||||
var argv = <String>[
|
||||
'tmux',
|
||||
@@ -149,6 +186,8 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
'-y',
|
||||
'$rows',
|
||||
'claude',
|
||||
'--session-id',
|
||||
_sessionId!,
|
||||
];
|
||||
|
||||
// CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode:
|
||||
@@ -167,7 +206,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
argv = ['claude'];
|
||||
argv = ['claude', '--session-id', _sessionId!];
|
||||
resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
@@ -181,41 +220,61 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
setState(() => _error = resp.error?.message ?? 'spawn failed');
|
||||
return;
|
||||
}
|
||||
_usingTmux = false;
|
||||
setState(() => _statusLine = 'no-tmux · fresh every launch');
|
||||
} else {
|
||||
_usingTmux = true;
|
||||
setState(() => _statusLine = 'tmux · $_sessionName');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_paneId = resp.data['id'] as String?;
|
||||
// Render the conversation natively from the transcript (T-137/D-75)
|
||||
// rather than the PTY's TUI output. claude runs in tmux; a reader
|
||||
// tails its transcript JSONL and a publisher fans the items onto the
|
||||
// kernel MessageBus, which the view's controller subscribes to. The
|
||||
// subscription is wired before the reader's first poll so the initial
|
||||
// tail is never missed.
|
||||
// Tail this session's own transcript (<munged-cwd>/<sessionId>.jsonl),
|
||||
// not just the newest in the workspace — that's what kept secondaries
|
||||
// showing the primary's conversation (T-146). Each pane gets its own
|
||||
// bus channel so their controllers don't cross-talk.
|
||||
final messages = _kernel()!.messages;
|
||||
final channel = ClaudeConversation.sessionChannel(_sessionId!);
|
||||
_feed = TranscriptPublisher(
|
||||
messages: messages,
|
||||
reader: TranscriptReader(repoRoot, file: transcriptFile),
|
||||
channel: channel,
|
||||
);
|
||||
_conversation = ConversationController.fromBus(messages: messages, channel: channel);
|
||||
_subscribe();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// -- output batching ------------------------------------------------------
|
||||
|
||||
final _outputBuf = StringBuffer();
|
||||
Timer? _flushTimer;
|
||||
|
||||
void _flushOutput() {
|
||||
_flushTimer = null;
|
||||
if (_outputBuf.isEmpty) return;
|
||||
_terminal.write(_outputBuf.toString());
|
||||
_outputBuf.clear();
|
||||
// Send composed text to Claude. On the tmux path, submit via the tmux
|
||||
// server (paste-buffer + Enter) — it reaches Claude even with no client
|
||||
// attached, unlike pane.write to the (now-detached) spawned client PTY.
|
||||
// The no-tmux fallback runs claude directly in our PTY, where pane.write
|
||||
// does reach it.
|
||||
void _send(String text) {
|
||||
if (_usingTmux) {
|
||||
final session = _sessionName;
|
||||
if (session != null) unawaited(tmux.sendMessage(session, text));
|
||||
return;
|
||||
}
|
||||
final id = _paneId;
|
||||
final ipc = _ipc();
|
||||
if (id == null || ipc == null) return;
|
||||
unawaited(ipc.request('pane.write', args: {'id': id, 'text': encodeClaudeInput(text)}));
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
final kernel = _kernel();
|
||||
if (kernel == null) return;
|
||||
// Lifecycle only — content comes from the transcript, not pane.output.
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((e) {
|
||||
if (e.subsystem != 'pane' || e.data['id'] != _paneId) return;
|
||||
switch (e.kind) {
|
||||
case 'pane.output':
|
||||
final b64 = e.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
_outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
_flushTimer ??= Timer(Duration.zero, _flushOutput);
|
||||
}
|
||||
case 'pane.exit':
|
||||
setState(() => _statusLine = widget.isPrimary ? 'session exited — restart clide to retry' : 'session exited');
|
||||
case 'pane.closed':
|
||||
@@ -224,43 +283,6 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
});
|
||||
}
|
||||
|
||||
// -- terminal callbacks ---------------------------------------------------
|
||||
|
||||
void _onTerminalOutput(String text) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
}
|
||||
|
||||
Timer? _resizeTimer;
|
||||
|
||||
void _onTerminalResize(int cols, int rows, int _, int __) {
|
||||
if (!_spawned) {
|
||||
_spawned = true;
|
||||
_spawnWhenReady();
|
||||
return;
|
||||
}
|
||||
_resizeTimer?.cancel();
|
||||
_resizeTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
if (_sessionName != null) {
|
||||
Process.run('tmux', [
|
||||
'-L',
|
||||
'clide',
|
||||
'resize-window',
|
||||
'-t',
|
||||
_sessionName!,
|
||||
'-x',
|
||||
'$cols',
|
||||
'-y',
|
||||
'$rows',
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- helpers --------------------------------------------------------------
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
@@ -279,12 +301,26 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||
|
||||
final body = _error != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClideText(_error!, muted: true),
|
||||
)
|
||||
: ClidePtyView(terminal: _terminal, label: title, autofocus: true);
|
||||
final Widget body;
|
||||
if (_error != null) {
|
||||
body = Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClideText(_error!, muted: true),
|
||||
);
|
||||
} else if (_conversation != null) {
|
||||
body = Column(
|
||||
children: [
|
||||
Expanded(child: ConversationView(controller: _conversation!)),
|
||||
ClaudeComposer(
|
||||
enabled: _paneId != null,
|
||||
onSubmit: _send,
|
||||
pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
body = const Center(child: ClideText('attaching…', muted: true));
|
||||
}
|
||||
|
||||
if (!widget.showChrome) return body;
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/// File/image clipboard paste for the Claude composer (T-138).
|
||||
///
|
||||
/// `tmux send-keys` / `pane.write` carry text only, and Claude reads
|
||||
/// files via `@path` references — so a pasted file or image is always
|
||||
/// delivered as an `@/absolute/path` token, never as bytes (see the
|
||||
/// T-134 spike). Flutter's built-in clipboard is text-only, so image
|
||||
/// and file reads go through a native [MethodChannel] (`clide/clipboard`,
|
||||
/// implemented per-OS in the GTK and macOS runners). Plain-text paste
|
||||
/// stays on the Flutter clipboard and is handled by the composer.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A pasted file or image the composer shows as a chip and sends to
|
||||
/// Claude as an `@path` reference.
|
||||
class ComposerAttachment {
|
||||
const ComposerAttachment({required this.path, required this.isImage});
|
||||
|
||||
/// Absolute path on disk (a real file, or a temp file for a pasted
|
||||
/// raw image).
|
||||
final String path;
|
||||
|
||||
/// Whether [path] is a raster image — chips render a thumbnail for
|
||||
/// these and a file icon otherwise.
|
||||
final bool isImage;
|
||||
|
||||
/// The token inserted into the message Claude receives.
|
||||
String get pathToken => '@$path';
|
||||
|
||||
/// Last path segment, for the chip label.
|
||||
String get fileName => path.split('/').where((s) => s.isNotEmpty).lastOrNull ?? path;
|
||||
}
|
||||
|
||||
bool _looksLikeImage(String path) {
|
||||
final p = path.toLowerCase();
|
||||
return p.endsWith('.png') || p.endsWith('.jpg') || p.endsWith('.jpeg') || p.endsWith('.gif') || p.endsWith('.webp') || p.endsWith('.bmp');
|
||||
}
|
||||
|
||||
/// Read side of the OS clipboard for the non-text content the composer
|
||||
/// turns into `@path` tokens. Abstracted so the resolver is testable
|
||||
/// without the platform channel.
|
||||
abstract interface class ClipboardSource {
|
||||
/// Absolute paths of files currently on the clipboard (copied in a
|
||||
/// file manager). Empty when there are none.
|
||||
Future<List<String>> readFiles();
|
||||
|
||||
/// PNG bytes of an image on the clipboard (e.g. a screenshot), or null
|
||||
/// when there is no image.
|
||||
Future<Uint8List?> readImage();
|
||||
}
|
||||
|
||||
/// [ClipboardSource] backed by the native `clide/clipboard` channel.
|
||||
/// Degrades to "nothing on the clipboard" when no platform handler is
|
||||
/// registered (e.g. tests, unsupported platforms).
|
||||
class NativeClipboard implements ClipboardSource {
|
||||
const NativeClipboard();
|
||||
|
||||
static const _channel = MethodChannel('clide/clipboard');
|
||||
|
||||
@override
|
||||
Future<List<String>> readFiles() async {
|
||||
try {
|
||||
final r = await _channel.invokeListMethod<String>('readFiles');
|
||||
return r ?? const [];
|
||||
} on MissingPluginException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> readImage() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<Uint8List>('readImage');
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory pasted-image temp files are written to. Mirrors the D-70
|
||||
/// socket-path convention: macOS `~/Library/Caches/clide/pasted`, else
|
||||
/// `$XDG_CACHE_HOME` (or `~/.cache`) `/clide/pasted`.
|
||||
String pasteCacheDir() {
|
||||
final home = Platform.environment['HOME'] ?? '/tmp';
|
||||
if (Platform.isMacOS) {
|
||||
return '$home/Library/Caches/clide/pasted';
|
||||
}
|
||||
final xdg = Platform.environment['XDG_CACHE_HOME'];
|
||||
final base = (xdg != null && xdg.isNotEmpty) ? xdg : '$home/.cache';
|
||||
return '$base/clide/pasted';
|
||||
}
|
||||
|
||||
/// Resolve a paste into composer attachments, or an empty list to fall
|
||||
/// back to plain-text paste.
|
||||
///
|
||||
/// Files already on disk become attachments directly. A raw image is
|
||||
/// written to [tempDir] (default [pasteCacheDir]) and attached by its
|
||||
/// path. Returns an empty list when the clipboard holds neither, so the
|
||||
/// composer pastes text instead.
|
||||
Future<List<ComposerAttachment>> resolveClipboardAttachment(
|
||||
ClipboardSource source, {
|
||||
Directory? tempDir,
|
||||
DateTime Function() now = DateTime.now,
|
||||
}) async {
|
||||
final files = await source.readFiles();
|
||||
if (files.isNotEmpty) {
|
||||
return [
|
||||
for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p)),
|
||||
];
|
||||
}
|
||||
|
||||
final image = await source.readImage();
|
||||
if (image != null && image.isNotEmpty) {
|
||||
final dir = tempDir ?? Directory(pasteCacheDir());
|
||||
await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/paste-${now().millisecondsSinceEpoch}.png');
|
||||
await file.writeAsBytes(image);
|
||||
return [ComposerAttachment(path: file.path, isImage: true)];
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/// Accumulates [ConversationItem]s from a transcript stream for the
|
||||
/// native Claude conversation view (epic T-132, D-75).
|
||||
///
|
||||
/// Thin [ChangeNotifier] over a `Stream<ConversationItem>` (normally
|
||||
/// [TranscriptReader.stream]). Kept separate from the widget so it can
|
||||
/// be unit-tested with a plain stream and reused per teammate panel
|
||||
/// when the team work (T-139/T-140) lands.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ConversationController extends ChangeNotifier {
|
||||
/// Listens to [stream] and accumulates items. [onDispose] is invoked
|
||||
/// from [dispose] — wire it to the reader's `dispose` so cancelling
|
||||
/// the view tears down the underlying tail.
|
||||
ConversationController({
|
||||
required Stream<ConversationItem> stream,
|
||||
Future<void> Function()? onDispose,
|
||||
}) : _onDispose = onDispose {
|
||||
_sub = stream.listen(_onItem);
|
||||
}
|
||||
|
||||
/// Build a controller fed from the kernel [MessageBus] — it consumes
|
||||
/// the [ConversationItem]s a [TranscriptPublisher] writes onto
|
||||
/// [publisher]/[channel]. Decouples the view from the reader so several
|
||||
/// panels can render the same conversation (team work, T-139/T-140).
|
||||
factory ConversationController.fromBus({
|
||||
required MessageBus messages,
|
||||
String channel = ClaudeConversation.leadChannel,
|
||||
Future<void> Function()? onDispose,
|
||||
}) {
|
||||
final stream =
|
||||
messages.subscribe(publisher: ClaudeConversation.publisher, channel: channel).map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
|
||||
return ConversationController(stream: stream, onDispose: onDispose);
|
||||
}
|
||||
|
||||
final Future<void> Function()? _onDispose;
|
||||
late final StreamSubscription<ConversationItem> _sub;
|
||||
final List<ConversationItem> _items = [];
|
||||
Timer? _notifyTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Items in arrival (transcript) order.
|
||||
List<ConversationItem> get items => List.unmodifiable(_items);
|
||||
|
||||
bool get isEmpty => _items.isEmpty;
|
||||
|
||||
void _onItem(ConversationItem item) {
|
||||
_items.add(item);
|
||||
// Coalesce notifications: the reader emits a burst (the initial tail
|
||||
// read), and a notify-per-item would thrash the view's rebuild +
|
||||
// auto-scroll. A zero-duration Timer fires only after the microtask
|
||||
// queue drains — the stream delivers one event per microtask, so a
|
||||
// microtask-scheduled notify would interleave between deliveries and
|
||||
// fire per item. The timer collapses a whole burst into one rebuild.
|
||||
_notifyTimer ??= Timer(Duration.zero, () {
|
||||
_notifyTimer = null;
|
||||
if (!_disposed) notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_notifyTimer?.cancel();
|
||||
unawaited(_sub.cancel());
|
||||
unawaited(_onDispose?.call());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/// Native render of a Claude conversation from the transcript (epic
|
||||
/// T-132, D-75) — replaces the terminal as the Claude display surface.
|
||||
///
|
||||
/// Renders [ConversationItem]s (from a [ConversationController]) as
|
||||
/// native cards: user messages, assistant markdown, thinking blocks,
|
||||
/// tool-use and tool-result cards. The whole list sits under a single
|
||||
/// [SelectionArea] so text selects + copies across cards (the one
|
||||
/// terminal affordance we keep — see T-135). Input/composer is a
|
||||
/// separate concern (T-138).
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ConversationView extends StatefulWidget {
|
||||
const ConversationView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.wrapInSelectionArea = true,
|
||||
});
|
||||
|
||||
final ConversationController controller;
|
||||
|
||||
/// Whether to wrap the list in its own [ClideSelectionArea]. The team
|
||||
/// grid sets this false and wraps all tiles in one shared area so
|
||||
/// selection spans tiles — nesting SelectionAreas is illegal (T-140).
|
||||
final bool wrapInSelectionArea;
|
||||
|
||||
@override
|
||||
State<ConversationView> createState() => _ConversationViewState();
|
||||
}
|
||||
|
||||
class _ConversationViewState extends State<ConversationView> {
|
||||
final ScrollController _scroll = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ConversationView old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.controller != widget.controller) {
|
||||
old.controller.removeListener(_onChanged);
|
||||
widget.controller.addListener(_onChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onChanged);
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
// Follow the tail — jump to the bottom after the new item lays out.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scroll.hasClients) {
|
||||
_scroll.jumpTo(_scroll.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final items = widget.controller.items;
|
||||
|
||||
if (items.isEmpty) {
|
||||
return ColoredBox(
|
||||
color: tokens.panelBackground,
|
||||
child: const Center(
|
||||
child: ClideText('Waiting for Claude…', muted: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final list = ClideScrollbar(
|
||||
controller: _scroll,
|
||||
child: ListView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) => _ConversationTurn(item: items[i], tokens: tokens),
|
||||
),
|
||||
);
|
||||
return ColoredBox(
|
||||
color: tokens.panelBackground,
|
||||
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: list) : list,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude's brand coral-orange — a fixed brand accent (not a theme token)
|
||||
/// used for the "claude" message card's stripe + label.
|
||||
const claudeAccent = Color(0xFFD97757);
|
||||
|
||||
/// One conversation item, rendered by kind.
|
||||
class _ConversationTurn extends StatelessWidget {
|
||||
const _ConversationTurn({required this.item, required this.tokens});
|
||||
|
||||
final ConversationItem item;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = item;
|
||||
return switch (i) {
|
||||
UserMessage() => _messageCard('you', tokens.globalFocus, ClideMarkdown(i.text)),
|
||||
AssistantTextMessage() => _messageCard('claude', claudeAccent, ClideMarkdown(i.text)),
|
||||
AssistantThinkingMessage() => _labelled(
|
||||
'thinking',
|
||||
tokens.globalTextMuted,
|
||||
ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
AssistantToolUse() => _toolUse(i),
|
||||
ToolResultMessage() => _toolResult(i),
|
||||
};
|
||||
}
|
||||
|
||||
/// A turn rendered as a distinct card: an [accent]-coloured left stripe
|
||||
/// and label over a filled background, so user and Claude turns read
|
||||
/// apart from each other (and from the panel canvas) by accent.
|
||||
Widget _messageCard(String label, Color accent, Widget body) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(width: 3, color: accent),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
label,
|
||||
fontSize: clideFontSmall,
|
||||
color: accent,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
body,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A labelled turn: a small role tag above the body.
|
||||
Widget _labelled(String label, Color labelColor, Widget body) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
label,
|
||||
fontSize: clideFontSmall,
|
||||
color: labelColor,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
body,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolUse(AssistantToolUse t) {
|
||||
final pretty = const JsonEncoder.withIndent(' ').convert(t.input);
|
||||
return _card(
|
||||
borderColor: tokens.panelBorder,
|
||||
header: Row(
|
||||
children: [
|
||||
ClideText('›', color: tokens.globalFocus, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(t.name, fontWeight: FontWeight.w500, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
body: ClideCodeBlock(source: pretty, language: 'json'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolResult(ToolResultMessage t) {
|
||||
final color = t.isError ? tokens.statusError : tokens.globalTextMuted;
|
||||
return _card(
|
||||
borderColor: t.isError ? tokens.statusError : tokens.panelBorder,
|
||||
header: ClideText(
|
||||
t.isError ? 'error' : 'result',
|
||||
fontSize: clideFontSmall,
|
||||
color: color,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
body: ClideText(
|
||||
t.content,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card({required Color borderColor, required Widget header, required Widget body}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.globalBackground,
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [header, const SizedBox(height: 6), body],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/builtin/claude/src/session_naming.dart';
|
||||
import 'package:clide/builtin/claude/src/team_observer.dart';
|
||||
import 'package:clide/builtin/claude/src/team_panel_host.dart';
|
||||
import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux;
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
@@ -19,6 +23,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
ClideExtensionContext? _ctx;
|
||||
final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey();
|
||||
|
||||
TeamObserver? _observer;
|
||||
final List<StreamSubscription<dynamic>> _subs = [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
@@ -28,7 +35,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 90,
|
||||
build: (_) => ClaudeSessionHost(key: _hostKey),
|
||||
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.new-secondary',
|
||||
@@ -59,10 +66,38 @@ class ClaudeExtension extends ClideExtension {
|
||||
// teardown, not on app quit / kill -9 / OOM.
|
||||
final primary = await _primarySessionName();
|
||||
if (primary != null) await tmux.reapSecondaries(primary);
|
||||
|
||||
// Observe a tmux agent team for the open workspace (T-139/T-140). The
|
||||
// observer emits TeamMemberJoined/Left, which TeamPanelHost renders as
|
||||
// teammate tiles. Restart it as the project changes.
|
||||
if (ctx.project.current != null) _restartObserver(ctx.project.current!.path);
|
||||
_subs.add(ctx.events.on<ProjectOpened>().listen((e) => _restartObserver(e.path)));
|
||||
_subs.add(ctx.events.on<ProjectClosed>().listen((_) => _stopObserver()));
|
||||
}
|
||||
|
||||
void _restartObserver(String workspacePath) {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return;
|
||||
unawaited(_observer?.dispose());
|
||||
_observer = TeamObserver(
|
||||
workspacePath: workspacePath,
|
||||
events: ctx.events,
|
||||
messages: ctx.messages,
|
||||
)..start();
|
||||
}
|
||||
|
||||
void _stopObserver() {
|
||||
unawaited(_observer?.dispose());
|
||||
_observer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
for (final s in _subs) {
|
||||
unawaited(s.cancel());
|
||||
}
|
||||
_subs.clear();
|
||||
_stopObserver();
|
||||
// Best-effort cleanup on explicit extension teardown. The cold-
|
||||
// start reap in activate is the actual safety net.
|
||||
final primary = await _primarySessionName();
|
||||
|
||||
@@ -6,9 +6,18 @@
|
||||
/// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp
|
||||
///
|
||||
/// Secondary sessions append `-N`.
|
||||
///
|
||||
/// Also derives the Claude `--session-id` for each pane (T-146): a pane's
|
||||
/// transcript is named `<session-id>.jsonl`, so binding each pane to a
|
||||
/// distinct UUID is how concurrent sessions in one workspace stay
|
||||
/// independent. The primary's id is deterministic from its (stable)
|
||||
/// session name so it resumes across restarts; secondaries get a fresh
|
||||
/// random id each spawn so a clean session is always one click away.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:math';
|
||||
|
||||
/// Stable session name for the primary Claude pane of [repoRoot].
|
||||
String primarySessionName(String repoRoot) {
|
||||
@@ -49,3 +58,46 @@ String _hash(String s) {
|
||||
}
|
||||
return h.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude session-id (UUID) derivation — T-146
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stable session id for the primary pane of [repoRoot]: a UUID
|
||||
/// deterministically derived from the primary session name, so the same
|
||||
/// workspace re-binds the same `<uuid>.jsonl` across restarts (resume).
|
||||
String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot));
|
||||
|
||||
/// A fresh random session id for a secondary pane — secondaries are
|
||||
/// always clean sessions, never resumed.
|
||||
String freshSessionId() {
|
||||
final r = Random.secure();
|
||||
return _formatUuid(List<int>.generate(16, (_) => r.nextInt(256)));
|
||||
}
|
||||
|
||||
/// Deterministic, valid-format UUID derived from [seed] (same seed →
|
||||
/// same id). Expands an FNV-1a stream into 16 bytes.
|
||||
String _deterministicUuid(String seed) {
|
||||
final bytes = <int>[];
|
||||
var h = 0xcbf29ce484222325;
|
||||
const prime = 0x100000001b3;
|
||||
for (var i = 0; i < 16; i++) {
|
||||
for (final c in utf8.encode('$seed:$i')) {
|
||||
h ^= c;
|
||||
h = (h * prime) & 0xFFFFFFFFFFFFFFFF;
|
||||
}
|
||||
bytes.add(h & 0xff);
|
||||
}
|
||||
return _formatUuid(bytes);
|
||||
}
|
||||
|
||||
/// Format 16 [bytes] as a canonical v4 UUID string (sets the version and
|
||||
/// variant nibbles so it passes `--session-id`'s UUID validation).
|
||||
String _formatUuid(List<int> bytes) {
|
||||
final b = List<int>.of(bytes);
|
||||
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
|
||||
final hex = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-'
|
||||
'${hex.substring(16, 20)}-${hex.substring(20)}';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/// tmux agent-team observer (epic T-132, T-139, D-75).
|
||||
///
|
||||
/// THE single drift-containment point for Claude Code's experimental tmux
|
||||
/// team mode. Everything that reads CC's undocumented team artifacts lives
|
||||
/// here so a CC change only breaks one file.
|
||||
///
|
||||
/// # What's reliable vs. fragile
|
||||
/// - **Reliable — lifecycle + identity (config-driven).** A team writes
|
||||
/// `~/.claude/teams/<team>/config.json` listing each member with its
|
||||
/// `tmuxPaneId` (`%N`, empty for the lead), `name`, `agentType`, `model`,
|
||||
/// `color`, `cwd`, `joinedAt`. Polling `tmux -L clide list-panes -a` and
|
||||
/// correlating live pane ids with `tmuxPaneId` gives a dependable
|
||||
/// joined/left signal and full identity — no transcript needed.
|
||||
/// - **Fragile — per-teammate transcript join.** A teammate's transcript is
|
||||
/// a subagent file `<munged-cwd>/<leadSessionId>/subagents/agent-<hex>.jsonl`
|
||||
/// whose only ids are a random hex (the filename) and a `slug`; it carries
|
||||
/// no `name`/`agentType`. The config's `agentId` is `<name>@<team>` — a
|
||||
/// different namespace — so there is no shared key. We join via a sibling
|
||||
/// `agent-<hex>.meta.json` (`{agentType}`) when present, else fall back to
|
||||
/// zipping members-by-`joinedAt` against files-by-mtime. This is the part
|
||||
/// most likely to drift; it needs validation against a live team run.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
|
||||
/// One member of a team config.
|
||||
class TeamMember {
|
||||
const TeamMember({
|
||||
required this.agentId,
|
||||
required this.name,
|
||||
required this.agentType,
|
||||
required this.tmuxPaneId,
|
||||
this.model,
|
||||
this.color,
|
||||
this.cwd,
|
||||
this.joinedAt,
|
||||
});
|
||||
|
||||
/// Config agent id, `<name>@<team>`.
|
||||
final String agentId;
|
||||
final String name;
|
||||
final String agentType;
|
||||
|
||||
/// tmux pane id (`%N`); empty for the lead.
|
||||
final String tmuxPaneId;
|
||||
final String? model;
|
||||
final String? color;
|
||||
final String? cwd;
|
||||
final int? joinedAt;
|
||||
|
||||
bool get isLead => tmuxPaneId.isEmpty || agentType == 'team-lead';
|
||||
}
|
||||
|
||||
/// Parsed `~/.claude/teams/<team>/config.json`.
|
||||
class TeamConfig {
|
||||
const TeamConfig({
|
||||
required this.team,
|
||||
required this.leadSessionId,
|
||||
required this.members,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String team;
|
||||
final String leadSessionId;
|
||||
final List<TeamMember> members;
|
||||
final int createdAt;
|
||||
|
||||
/// Non-lead members (the panes we surface).
|
||||
List<TeamMember> get teammates => members.where((m) => !m.isLead).toList();
|
||||
|
||||
/// Parse a config; returns null on malformed JSON.
|
||||
static TeamConfig? parse(String teamDirName, String jsonStr) {
|
||||
Map<String, dynamic> d;
|
||||
try {
|
||||
d = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
final members = <TeamMember>[];
|
||||
for (final m in (d['members'] as List? ?? const [])) {
|
||||
if (m is! Map) continue;
|
||||
members.add(TeamMember(
|
||||
agentId: m['agentId'] as String? ?? '',
|
||||
name: m['name'] as String? ?? '',
|
||||
agentType: m['agentType'] as String? ?? '',
|
||||
tmuxPaneId: m['tmuxPaneId'] as String? ?? '',
|
||||
model: m['model'] as String?,
|
||||
color: m['color'] as String?,
|
||||
cwd: m['cwd'] as String?,
|
||||
joinedAt: (m['joinedAt'] as num?)?.toInt(),
|
||||
));
|
||||
}
|
||||
return TeamConfig(
|
||||
team: (d['name'] as String?) ?? teamDirName,
|
||||
leadSessionId: d['leadSessionId'] as String? ?? '',
|
||||
members: members,
|
||||
createdAt: (d['createdAt'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover the active team config for [workspacePath]: the team (under
|
||||
/// [teamsBase]) any of whose members runs in [workspacePath], newest by
|
||||
/// `createdAt` when several match. Null if none.
|
||||
Future<TeamConfig?> discoverTeam(String workspacePath, {required String teamsBase}) async {
|
||||
final dir = Directory(teamsBase);
|
||||
if (!await dir.exists()) return null;
|
||||
TeamConfig? best;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! Directory) continue;
|
||||
final cfgFile = File('${entity.path}/config.json');
|
||||
if (!await cfgFile.exists()) continue;
|
||||
final cfg = TeamConfig.parse(entity.path.split('/').last, await cfgFile.readAsString());
|
||||
if (cfg == null) continue;
|
||||
if (!cfg.members.any((m) => m.cwd == workspacePath)) continue;
|
||||
if (best == null || cfg.createdAt > best.createdAt) best = cfg;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Returns the set of live tmux pane ids on the `clide` socket. Injectable
|
||||
/// so tests don't shell out.
|
||||
typedef PaneLister = Future<Set<String>> Function();
|
||||
|
||||
class _LiveMember {
|
||||
_LiveMember(this.member, this.team, this.publisher);
|
||||
final TeamMember member;
|
||||
final String team;
|
||||
final TranscriptPublisher? publisher;
|
||||
}
|
||||
|
||||
/// Watches a workspace's tmux team and emits [TeamMemberJoined] /
|
||||
/// [TeamMemberLeft] as panes appear/disappear, publishing each teammate's
|
||||
/// transcript onto the [MessageBus] under its per-agent channel.
|
||||
class TeamObserver {
|
||||
TeamObserver({
|
||||
required this.workspacePath,
|
||||
required DaemonBus events,
|
||||
required MessageBus messages,
|
||||
String? teamsBase,
|
||||
String? projectsBase,
|
||||
PaneLister? paneLister,
|
||||
Duration pollInterval = const Duration(seconds: 2),
|
||||
}) : _events = events,
|
||||
_messages = messages,
|
||||
_teamsBase = teamsBase ?? _defaultTeamsBase(),
|
||||
_projectsBase = projectsBase ?? _defaultProjectsBase(),
|
||||
_paneLister = paneLister ?? _tmuxPaneLister,
|
||||
_pollInterval = pollInterval;
|
||||
|
||||
final String workspacePath;
|
||||
final DaemonBus _events;
|
||||
final MessageBus _messages;
|
||||
final String _teamsBase;
|
||||
final String _projectsBase;
|
||||
final PaneLister _paneLister;
|
||||
final Duration _pollInterval;
|
||||
|
||||
Timer? _timer;
|
||||
bool _disposed = false;
|
||||
final Map<String, _LiveMember> _live = {};
|
||||
|
||||
static String _defaultTeamsBase() {
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
return home.isNotEmpty ? '$home/.claude/teams' : '.claude/teams';
|
||||
}
|
||||
|
||||
static String _defaultProjectsBase() {
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
return home.isNotEmpty ? '$home/.claude/projects' : '.claude/projects';
|
||||
}
|
||||
|
||||
static Future<Set<String>> _tmuxPaneLister() async {
|
||||
try {
|
||||
final r = await Process.run('tmux', ['-L', 'clide', 'list-panes', '-a', '-F', '#{pane_id}']);
|
||||
if (r.exitCode != 0) return const {};
|
||||
return (r.stdout as String).split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toSet();
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin polling.
|
||||
void start() => _scheduleNext();
|
||||
|
||||
void _scheduleNext() {
|
||||
_timer = Timer(_pollInterval, () async {
|
||||
if (_disposed) return;
|
||||
await tick();
|
||||
if (!_disposed) _scheduleNext();
|
||||
});
|
||||
}
|
||||
|
||||
/// One poll cycle (public for tests). Diffs the config roster against the
|
||||
/// live panes and emits joined/left.
|
||||
Future<void> tick() async {
|
||||
final config = await discoverTeam(workspacePath, teamsBase: _teamsBase);
|
||||
if (config == null) {
|
||||
await _killAll();
|
||||
return;
|
||||
}
|
||||
final livePanes = await _paneLister();
|
||||
final configIds = <String>{};
|
||||
|
||||
for (final m in config.teammates) {
|
||||
configIds.add(m.agentId);
|
||||
final paneLive = livePanes.contains(m.tmuxPaneId);
|
||||
final tracked = _live.containsKey(m.agentId);
|
||||
if (paneLive && !tracked) {
|
||||
await _joined(config, m);
|
||||
} else if (!paneLive && tracked) {
|
||||
await _left(m.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
// A member dropped from the config (team reshaped) also counts as left.
|
||||
for (final id in _live.keys.toList()) {
|
||||
if (!configIds.contains(id)) await _left(id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _joined(TeamConfig config, TeamMember m) async {
|
||||
final path = await _resolveTranscript(config, m);
|
||||
TranscriptPublisher? pub;
|
||||
if (path != null) {
|
||||
pub = TranscriptPublisher(
|
||||
messages: _messages,
|
||||
reader: TranscriptReader(m.cwd ?? workspacePath, file: path, projectsBase: _projectsBase),
|
||||
channel: ClaudeConversation.teammateChannel(m.agentId),
|
||||
);
|
||||
}
|
||||
_live[m.agentId] = _LiveMember(m, config.team, pub);
|
||||
_events.emit(TeamMemberJoined(
|
||||
team: config.team,
|
||||
agentId: m.agentId,
|
||||
name: m.name,
|
||||
agentType: m.agentType,
|
||||
paneId: m.tmuxPaneId,
|
||||
model: m.model,
|
||||
color: m.color,
|
||||
cwd: m.cwd,
|
||||
transcriptPath: path,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _left(String agentId) async {
|
||||
final live = _live.remove(agentId);
|
||||
if (live == null) return;
|
||||
await live.publisher?.dispose();
|
||||
_events.emit(TeamMemberLeft(team: live.team, agentId: agentId, paneId: live.member.tmuxPaneId));
|
||||
}
|
||||
|
||||
Future<void> _killAll() async {
|
||||
for (final id in _live.keys.toList()) {
|
||||
await _left(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort join of [member] to its subagent transcript file. See the
|
||||
/// library doc — this is the drift-prone part. Returns null if no
|
||||
/// transcript can be resolved.
|
||||
Future<String?> _resolveTranscript(TeamConfig config, TeamMember member) async {
|
||||
final cwd = member.cwd;
|
||||
if (cwd == null || config.leadSessionId.isEmpty) return null;
|
||||
final munged = cwd.replaceAll('/', '-');
|
||||
final subDir = Directory('$_projectsBase/$munged/${config.leadSessionId}/subagents');
|
||||
if (!await subDir.exists()) return null;
|
||||
|
||||
final files = <File>[];
|
||||
await for (final e in subDir.list()) {
|
||||
if (e is File && e.path.endsWith('.jsonl') && !e.path.contains('compact')) {
|
||||
files.add(e);
|
||||
}
|
||||
}
|
||||
if (files.isEmpty) return null;
|
||||
|
||||
// Clean join: a sibling `.meta.json` whose agentType matches.
|
||||
for (final f in files) {
|
||||
final metaPath = '${f.path.substring(0, f.path.length - '.jsonl'.length)}.meta.json';
|
||||
final meta = File(metaPath);
|
||||
if (!await meta.exists()) continue;
|
||||
try {
|
||||
final m = jsonDecode(await meta.readAsString());
|
||||
if (m is Map && m['agentType'] == member.agentType) return f.path;
|
||||
} catch (_) {
|
||||
// ignore malformed meta
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: zip teammates-by-joinedAt against files-by-mtime.
|
||||
final teammates = [...config.teammates]..sort((a, b) => (a.joinedAt ?? 0).compareTo(b.joinedAt ?? 0));
|
||||
final idx = teammates.indexWhere((m) => m.agentId == member.agentId);
|
||||
if (idx < 0) return null;
|
||||
final stats = <(File, DateTime)>[];
|
||||
for (final f in files) {
|
||||
stats.add((f, (await f.stat()).modified));
|
||||
}
|
||||
stats.sort((a, b) => a.$2.compareTo(b.$2));
|
||||
return idx < stats.length ? stats[idx].$1.path : null;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
await _killAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/// Faithful tiling for a Claude agent team (epic T-132, T-140).
|
||||
///
|
||||
/// Wraps the lead Claude surface on the left; when the [TeamObserver]
|
||||
/// emits [TeamMemberJoined], a resizable right pane appears holding a
|
||||
/// teammate tile per live member, arranged in a responsive grid that
|
||||
/// wraps 1→2→3 columns by count. Each tile renders the teammate's
|
||||
/// conversation from its per-agent MessageBus channel (published by the
|
||||
/// observer). Tiles vanish on [TeamMemberLeft]. With no team, only the
|
||||
/// lead is shown.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_view.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TeamPanelHost extends StatefulWidget {
|
||||
const TeamPanelHost({super.key, required this.lead});
|
||||
|
||||
/// The lead Claude surface (normally the tabbed primary/secondary host).
|
||||
final Widget lead;
|
||||
|
||||
@override
|
||||
State<TeamPanelHost> createState() => _TeamPanelHostState();
|
||||
}
|
||||
|
||||
class _TeamPanelHostState extends State<TeamPanelHost> {
|
||||
final List<TeamMemberJoined> _members = [];
|
||||
final Map<String, ConversationController> _controllers = {};
|
||||
StreamSubscription<TeamMemberJoined>? _joinSub;
|
||||
StreamSubscription<TeamMemberLeft>? _leftSub;
|
||||
bool _subscribed = false;
|
||||
|
||||
/// Fraction of the width given to the lead pane.
|
||||
double _leadFraction = 0.5;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_subscribed) return;
|
||||
_subscribed = true;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_joinSub = kernel.events.on<TeamMemberJoined>().listen(_onJoined);
|
||||
_leftSub = kernel.events.on<TeamMemberLeft>().listen(_onLeft);
|
||||
}
|
||||
|
||||
void _onJoined(TeamMemberJoined m) {
|
||||
if (_controllers.containsKey(m.agentId)) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controllers[m.agentId] = ConversationController.fromBus(
|
||||
messages: kernel.messages,
|
||||
channel: ClaudeConversation.teammateChannel(m.agentId),
|
||||
);
|
||||
setState(() => _members.add(m));
|
||||
}
|
||||
|
||||
void _onLeft(TeamMemberLeft m) {
|
||||
_controllers.remove(m.agentId)?.dispose();
|
||||
setState(() => _members.removeWhere((x) => x.agentId == m.agentId));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_joinSub?.cancel();
|
||||
_leftSub?.cancel();
|
||||
for (final c in _controllers.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_members.isEmpty) return widget.lead;
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final total = constraints.maxWidth;
|
||||
final leadW = (total * _leadFraction).clamp(240.0, total > 480 ? total - 240 : total);
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: leadW, child: widget.lead),
|
||||
_Divider(
|
||||
color: tokens.panelBorder,
|
||||
onDrag: (dx) => setState(() {
|
||||
_leadFraction = ((leadW + dx) / total).clamp(0.2, 0.8);
|
||||
}),
|
||||
),
|
||||
Expanded(
|
||||
child: _TeammateGrid(
|
||||
members: _members,
|
||||
controllers: _controllers,
|
||||
tokens: tokens,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Divider extends StatelessWidget {
|
||||
const _Divider({required this.color, required this.onDrag});
|
||||
final Color color;
|
||||
final void Function(double dx) onDrag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragUpdate: (e) => onDrag(e.delta.dx),
|
||||
child: SizedBox(
|
||||
width: 7,
|
||||
child: Center(child: Container(width: 1, color: color)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal-sized teammate tiles in a grid that wraps 1→2→3 columns by count.
|
||||
class _TeammateGrid extends StatelessWidget {
|
||||
const _TeammateGrid({required this.members, required this.controllers, required this.tokens});
|
||||
|
||||
final List<TeamMemberJoined> members;
|
||||
final Map<String, ConversationController> controllers;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cols = members.length >= 3 ? 3 : members.length;
|
||||
final rows = (members.length / cols).ceil();
|
||||
|
||||
// One selection area over the whole grid so a drag-select spans tiles.
|
||||
return ClideSelectionArea(
|
||||
child: Column(
|
||||
children: [
|
||||
for (var r = 0; r < rows; r++)
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cell(int i) {
|
||||
if (i >= members.length) return const SizedBox.shrink();
|
||||
final m = members[i];
|
||||
return _TeammateTile(member: m, controller: controllers[m.agentId]!, tokens: tokens);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeammateTile extends StatelessWidget {
|
||||
const _TeammateTile({required this.member, required this.controller, required this.tokens});
|
||||
|
||||
final TeamMemberJoined member;
|
||||
final ConversationController controller;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = teamColor(member.color, fallback: tokens.globalForeground);
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(border: Border.all(color: tokens.panelBorder)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelHeader,
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: 3, height: 13, color: accent),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(member.name, fontSize: clideFontSmall, color: accent, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
[member.agentType, if (member.model != null) member.model!].join(' · '),
|
||||
fontSize: clideFontSmall,
|
||||
muted: true,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: ConversationView(controller: controller, wrapInSelectionArea: false)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a Claude Code team member colour name to a display [Color]. These
|
||||
/// are CC-assigned identity colours (named strings), so they're fixed
|
||||
/// constants here rather than theme tokens.
|
||||
Color teamColor(String? name, {required Color fallback}) {
|
||||
switch (name) {
|
||||
case 'red':
|
||||
return const Color(0xFFE06C75);
|
||||
case 'orange':
|
||||
return const Color(0xFFD97757);
|
||||
case 'yellow':
|
||||
return const Color(0xFFE5C07B);
|
||||
case 'green':
|
||||
return const Color(0xFF98C379);
|
||||
case 'cyan':
|
||||
return const Color(0xFF56B6C2);
|
||||
case 'blue':
|
||||
return const Color(0xFF61AFEF);
|
||||
case 'magenta':
|
||||
case 'purple':
|
||||
return const Color(0xFFC678DD);
|
||||
case 'pink':
|
||||
return const Color(0xFFE39EC1);
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -54,3 +54,21 @@ Future<void> killAllForRepo(String primaryName) async {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Named tmux paste buffer clide loads composed messages into.
|
||||
const _composeBuffer = 'clide-compose';
|
||||
|
||||
/// Submit [text] to [session] as a single message: load it into a named
|
||||
/// paste buffer, paste it in bracketed mode (so multi-line content and
|
||||
/// special characters arrive as one block rather than a stream of
|
||||
/// submits), then press Enter.
|
||||
///
|
||||
/// This goes through the tmux *server*, so it reaches Claude whether or
|
||||
/// not a client is attached. Writing to the app's spawned client PTY
|
||||
/// (`pane.write`) does not — once that client detaches, the PTY is dead
|
||||
/// and keystrokes vanish (the bug this replaces).
|
||||
Future<void> sendMessage(String session, String text) async {
|
||||
await tmuxRunner([..._socket, 'set-buffer', '-b', _composeBuffer, '--', text]);
|
||||
await tmuxRunner([..._socket, 'paste-buffer', '-p', '-d', '-b', _composeBuffer, '-t', session]);
|
||||
await tmuxRunner([..._socket, 'send-keys', '-t', session, 'Enter']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/// Bridges a [TranscriptReader] onto the kernel [MessageBus] (epic T-132,
|
||||
/// D-75).
|
||||
///
|
||||
/// One reader tails a workspace transcript; this publisher republishes
|
||||
/// every [ConversationItem] as a bus [Message]. Any number of Claude
|
||||
/// panels can then subscribe to the same conversation via the bus instead
|
||||
/// of each owning its own reader — the decoupling the team panels
|
||||
/// (T-139/T-140) need, where a single observer feeds the lead tile plus a
|
||||
/// tile per teammate.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
|
||||
/// Bus addressing for Claude conversation content.
|
||||
abstract final class ClaudeConversation {
|
||||
/// Publisher id under which conversation items are published.
|
||||
static const publisher = 'builtin.claude';
|
||||
|
||||
/// Channel for the lead (or single) Claude pane's conversation.
|
||||
static const leadChannel = 'conversation';
|
||||
|
||||
/// Per-session channel, keyed by the Claude session id (T-146) so
|
||||
/// concurrent panes in one workspace don't cross-talk.
|
||||
static String sessionChannel(String sessionId) => 'conversation/$sessionId';
|
||||
|
||||
/// Channel for a teammate's conversation (team work, T-139/T-140).
|
||||
static String teammateChannel(String agentId) => 'conversation/$agentId';
|
||||
|
||||
/// Key under which the [ConversationItem] travels in a [Message]'s data.
|
||||
static const itemKey = 'item';
|
||||
}
|
||||
|
||||
class TranscriptPublisher {
|
||||
/// Starts republishing [reader]'s items onto [messages] under
|
||||
/// [ClaudeConversation.publisher] / [channel]. The subscription is
|
||||
/// attached synchronously, so a controller that subscribes before the
|
||||
/// reader's first poll never misses the initial tail.
|
||||
TranscriptPublisher({
|
||||
required MessageBus messages,
|
||||
required TranscriptReader reader,
|
||||
this.channel = ClaudeConversation.leadChannel,
|
||||
}) : _messages = messages,
|
||||
_reader = reader {
|
||||
_sub = _reader.stream.listen((item) {
|
||||
_messages.publish(ClaudeConversation.publisher, channel, {
|
||||
ClaudeConversation.itemKey: item,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
final MessageBus _messages;
|
||||
final TranscriptReader _reader;
|
||||
final String channel;
|
||||
late final StreamSubscription<ConversationItem> _sub;
|
||||
|
||||
/// Stops publishing and tears down the underlying reader.
|
||||
Future<void> dispose() async {
|
||||
await _sub.cancel();
|
||||
await _reader.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/// Tails Claude Code's transcript JSONL and emits parsed conversation items.
|
||||
///
|
||||
/// Data layer only — no UI. Feeds native Claude rendering (epic T-132).
|
||||
///
|
||||
/// # Path convention
|
||||
/// Claude Code stores transcripts at:
|
||||
/// `~/.claude/projects/<munged-cwd>/<session-id>.jsonl`
|
||||
/// where `munged-cwd = absolutePath.replaceAll('/', '-')` (leading `-` kept).
|
||||
/// Subagent transcripts live under:
|
||||
/// `<munged-cwd>/<session-id>/subagents/agent-<id>.jsonl`
|
||||
///
|
||||
/// # Session discovery
|
||||
/// The caller supplies a workspace path. [TranscriptReader] resolves the
|
||||
/// munged directory and picks the newest `.jsonl` by mtime. It polls mtime
|
||||
/// and switches to a newer file if one appears (e.g. the user starts a new
|
||||
/// Claude Code session).
|
||||
///
|
||||
/// # Streaming
|
||||
/// The stream uses an append-only byte-cursor so it never re-processes bytes
|
||||
/// it has already seen. Each iteration:
|
||||
/// 1. Reads from the cursor position to EOF.
|
||||
/// 2. Splits on newlines.
|
||||
/// 3. Parses each line as JSON and emits any recognised [ConversationItem].
|
||||
///
|
||||
/// # Version drift-guard
|
||||
/// If the envelope `version` field has an unfamiliar major version the reader
|
||||
/// warns via [onWarn] (or stderr if omitted) and degrades gracefully — it
|
||||
/// parses whatever it can and skips the rest rather than crashing.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discriminated union of conversation items the reader can emit.
|
||||
sealed class ConversationItem {
|
||||
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain});
|
||||
|
||||
final String uuid;
|
||||
final DateTime timestamp;
|
||||
final bool isSidechain;
|
||||
}
|
||||
|
||||
/// A user-typed message (plain text, possibly multi-part).
|
||||
final class UserMessage extends ConversationItem {
|
||||
const UserMessage({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
/// The concatenated text of all `text` parts in the content array.
|
||||
final String text;
|
||||
|
||||
@override
|
||||
String toString() => 'UserMessage(${_shortId(uuid)}, ${text.length} chars)';
|
||||
}
|
||||
|
||||
/// A tool-result delivered from the host back to Claude as a user message.
|
||||
final class ToolResultMessage extends ConversationItem {
|
||||
const ToolResultMessage({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.toolUseId,
|
||||
required this.content,
|
||||
required this.isError,
|
||||
});
|
||||
|
||||
final String toolUseId;
|
||||
final String content;
|
||||
final bool isError;
|
||||
|
||||
@override
|
||||
String toString() => 'ToolResultMessage(toolUseId=$toolUseId, isError=$isError)';
|
||||
}
|
||||
|
||||
/// Plain text from an assistant turn.
|
||||
final class AssistantTextMessage extends ConversationItem {
|
||||
const AssistantTextMessage({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars)';
|
||||
}
|
||||
|
||||
/// Extended thinking block from an assistant turn.
|
||||
final class AssistantThinkingMessage extends ConversationItem {
|
||||
const AssistantThinkingMessage({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.thinking,
|
||||
});
|
||||
|
||||
final String thinking;
|
||||
|
||||
@override
|
||||
String toString() => 'AssistantThinkingMessage(${_shortId(uuid)}, ${thinking.length} chars)';
|
||||
}
|
||||
|
||||
/// A tool-use invocation in an assistant turn.
|
||||
final class AssistantToolUse extends ConversationItem {
|
||||
const AssistantToolUse({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.toolUseId,
|
||||
required this.name,
|
||||
required this.input,
|
||||
});
|
||||
|
||||
/// Tool invocation id (matches the [ToolResultMessage.toolUseId]).
|
||||
final String toolUseId;
|
||||
|
||||
/// Tool name, e.g. `"Bash"` or `"Read"`.
|
||||
final String name;
|
||||
|
||||
/// Raw decoded input map.
|
||||
final Map<String, dynamic> input;
|
||||
|
||||
@override
|
||||
String toString() => 'AssistantToolUse(name=$name, id=$toolUseId)';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Safe short UUID prefix for use in [toString] methods.
|
||||
String _shortId(String uuid) => uuid.length >= 8 ? uuid.substring(0, 8) : uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// On first attach to a session file, read at most this many recent
|
||||
/// bytes (not the whole file) — an active transcript can be many MB and
|
||||
/// parsing it all synchronously would freeze the UI. Appends after that
|
||||
/// stream incrementally.
|
||||
const _defaultInitialTailBytes = 256 * 1024;
|
||||
|
||||
/// Chunks at least this large are parsed in a background isolate; smaller
|
||||
/// ones parse inline. Streaming appends are small, so this keeps the
|
||||
/// off-thread parse to the initial-tail case that actually janks a frame.
|
||||
const _isolateParseThreshold = 64 * 1024;
|
||||
|
||||
/// Known major transcript versions.
|
||||
const _knownMajorVersions = {1, 2};
|
||||
|
||||
/// Record types to skip (do not emit as conversation items).
|
||||
const _skipTypes = {
|
||||
'attachment',
|
||||
'system',
|
||||
'last-prompt',
|
||||
'permission-mode',
|
||||
'file-history-snapshot',
|
||||
'queue-operation',
|
||||
};
|
||||
|
||||
/// Tails Claude Code's transcript JSONL and emits [ConversationItem]s.
|
||||
///
|
||||
/// Call [stream] to obtain the live stream. Dispose with [dispose] when done.
|
||||
class TranscriptReader {
|
||||
/// Creates a reader for [workspacePath].
|
||||
///
|
||||
/// [pollInterval] controls how often the reader polls for new data and
|
||||
/// session switches (default 500 ms).
|
||||
///
|
||||
/// [onWarn] receives warning messages from the version drift-guard.
|
||||
/// If omitted, warnings are written to stderr.
|
||||
TranscriptReader(
|
||||
this.workspacePath, {
|
||||
Duration pollInterval = const Duration(milliseconds: 500),
|
||||
void Function(String)? onWarn,
|
||||
String? projectsBase,
|
||||
int? initialTailBytes,
|
||||
String? file,
|
||||
}) : _pollInterval = pollInterval,
|
||||
_onWarn = onWarn ?? _defaultWarn,
|
||||
_projectsBase = projectsBase ?? _defaultProjectsBase(),
|
||||
_initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes,
|
||||
_explicitFile = file;
|
||||
|
||||
final String workspacePath;
|
||||
final Duration _pollInterval;
|
||||
final void Function(String) _onWarn;
|
||||
|
||||
/// Max bytes of recent history to read when first attaching to a
|
||||
/// session file (overridable for tests).
|
||||
final int _initialTailBytes;
|
||||
|
||||
/// Base dir holding the per-workspace transcript dirs. Defaults to
|
||||
/// `~/.claude/projects`; overridable so tests point the real reader at a
|
||||
/// temp directory instead of the user's home.
|
||||
final String _projectsBase;
|
||||
|
||||
/// When set, tail this exact file instead of discovering the newest
|
||||
/// `.jsonl` in the munged dir. Used for teammate subagent transcripts
|
||||
/// (T-139), whose path the team observer resolves explicitly.
|
||||
final String? _explicitFile;
|
||||
|
||||
static String _defaultProjectsBase() {
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
return home.isNotEmpty ? '$home/.claude/projects' : '.claude/projects';
|
||||
}
|
||||
|
||||
StreamController<ConversationItem>? _controller;
|
||||
Timer? _timer;
|
||||
String? _currentPath;
|
||||
int _cursor = 0;
|
||||
|
||||
static void _defaultWarn(String msg) => stderr.writeln('[TranscriptReader] $msg');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolves `<projectsBase>/<munged-cwd>`.
|
||||
String _mungedDir() {
|
||||
final munged = workspacePath.replaceAll('/', '-');
|
||||
return '$_projectsBase/$munged';
|
||||
}
|
||||
|
||||
/// Finds the newest `.jsonl` by mtime inside [dir], or null if none exist.
|
||||
static Future<String?> _newestJsonl(String dir) async {
|
||||
final d = Directory(dir);
|
||||
if (!await d.exists()) return null;
|
||||
|
||||
FileStat? bestStat;
|
||||
String? bestPath;
|
||||
|
||||
await for (final entity in d.list()) {
|
||||
if (entity is! File) continue;
|
||||
if (!entity.path.endsWith('.jsonl')) continue;
|
||||
final stat = await entity.stat();
|
||||
if (bestStat == null || stat.modified.isAfter(bestStat.modified)) {
|
||||
bestStat = stat;
|
||||
bestPath = entity.path;
|
||||
}
|
||||
}
|
||||
return bestPath;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A broadcast stream of [ConversationItem]s from the active transcript.
|
||||
///
|
||||
/// The stream is created lazily on first call. Subsequent calls return the
|
||||
/// same stream. Call [dispose] to cancel polling and close the stream.
|
||||
Stream<ConversationItem> get stream {
|
||||
_controller ??= _start();
|
||||
return _controller!.stream;
|
||||
}
|
||||
|
||||
/// Cancels polling and closes the underlying stream.
|
||||
Future<void> dispose() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
await _controller?.close();
|
||||
_controller = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal — polling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
StreamController<ConversationItem> _start() {
|
||||
final controller = StreamController<ConversationItem>.broadcast();
|
||||
_scheduleNext(controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
void _scheduleNext(StreamController<ConversationItem> controller) {
|
||||
_timer = Timer(_pollInterval, () async {
|
||||
if (controller.isClosed) return;
|
||||
await _tick(controller);
|
||||
if (!controller.isClosed) _scheduleNext(controller);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _tick(StreamController<ConversationItem> controller) async {
|
||||
// A pane/teammate reader tails one fixed file; otherwise discover the
|
||||
// newest session `.jsonl` in the munged dir.
|
||||
final newest = _explicitFile ?? await _newestJsonl(_mungedDir());
|
||||
if (newest == null) return;
|
||||
// An explicit file may not exist yet (claude writes it shortly after
|
||||
// spawn) — wait for it rather than throwing in the poll loop.
|
||||
if (!await File(newest).exists()) return;
|
||||
|
||||
if (newest != _currentPath) {
|
||||
// New session file. Start from the recent tail rather than byte 0:
|
||||
// an active transcript can be many MB (thousands of records), and
|
||||
// parsing the whole thing synchronously on first attach freezes the
|
||||
// UI. Read at most [_initialTailBytes] of recent history, then stream
|
||||
// appends. A partial first line (from landing mid-record) simply
|
||||
// fails to JSON-parse and is skipped. Older scrollback is a future
|
||||
// load-more concern.
|
||||
_currentPath = newest;
|
||||
final length = await File(newest).length();
|
||||
_cursor = length > _initialTailBytes ? length - _initialTailBytes : 0;
|
||||
}
|
||||
|
||||
await _tail(controller, newest);
|
||||
}
|
||||
|
||||
Future<void> _tail(StreamController<ConversationItem> controller, String path) async {
|
||||
final file = File(path);
|
||||
final length = await file.length();
|
||||
if (length <= _cursor) return; // no new bytes
|
||||
|
||||
final String chunk;
|
||||
final raf = await file.open();
|
||||
try {
|
||||
await raf.setPosition(_cursor);
|
||||
final newBytes = await raf.read(length - _cursor);
|
||||
_cursor = length;
|
||||
chunk = utf8.decode(newBytes, allowMalformed: true);
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
if (controller.isClosed) return;
|
||||
|
||||
// Parse off the UI isolate only when the chunk is big enough to jank a
|
||||
// frame — the initial tail read (up to [_initialTailBytes]) is the case
|
||||
// that froze the app. Streaming appends are small (a message at a time);
|
||||
// parsing those inline avoids spawning a one-shot isolate every poll
|
||||
// tick, which is pure overhead and adds latency under load.
|
||||
final parsed = chunk.length >= _isolateParseThreshold ? await Isolate.run(() => parseTranscriptChunk(chunk)) : parseTranscriptChunk(chunk);
|
||||
if (controller.isClosed) return;
|
||||
for (final w in parsed.warnings) {
|
||||
_onWarn(w);
|
||||
}
|
||||
for (final item in parsed.items) {
|
||||
if (controller.isClosed) break;
|
||||
controller.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single JSONL line into its items (forwarding any version
|
||||
/// warnings to [onWarn]). Public so tests exercise the real parser.
|
||||
List<ConversationItem> parseLine(String line) {
|
||||
final parsed = parseTranscriptChunk(line);
|
||||
for (final w in parsed.warnings) {
|
||||
_onWarn(w);
|
||||
}
|
||||
return parsed.items;
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of [parseTranscriptChunk]: parsed items + version-drift warnings.
|
||||
typedef ParsedChunk = ({List<ConversationItem> items, List<String> warnings});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing — pure + isolate-safe. Top-level (no instance state) so it can run
|
||||
// via Isolate.run. Malformed JSON and skip/unknown types are dropped; an
|
||||
// unfamiliar major `version` adds a warning but parsing still proceeds.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ParsedChunk parseTranscriptChunk(String chunk) {
|
||||
final items = <ConversationItem>[];
|
||||
final warnings = <String>[];
|
||||
for (final raw in chunk.split('\n')) {
|
||||
final line = raw.trim();
|
||||
if (line.isEmpty) continue;
|
||||
_parseLineInto(line, items, warnings);
|
||||
}
|
||||
return (items: items, warnings: warnings);
|
||||
}
|
||||
|
||||
void _parseLineInto(String line, List<ConversationItem> out, List<String> warnings) {
|
||||
Map<String, dynamic> envelope;
|
||||
try {
|
||||
envelope = (jsonDecode(line) as Map).cast<String, dynamic>();
|
||||
} catch (_) {
|
||||
return; // malformed JSON — skip silently
|
||||
}
|
||||
|
||||
final rawVersion = envelope['version'] as String?;
|
||||
if (rawVersion != null) {
|
||||
final dotIdx = rawVersion.indexOf('.');
|
||||
final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion;
|
||||
final major = int.tryParse(majorStr);
|
||||
if (major != null && !_knownMajorVersions.contains(major)) {
|
||||
warnings.add('unfamiliar transcript version "$rawVersion" (major=$major); '
|
||||
'parsing will degrade gracefully');
|
||||
}
|
||||
}
|
||||
|
||||
final type = envelope['type'] as String?;
|
||||
if (type == null || _skipTypes.contains(type)) return;
|
||||
|
||||
final uuid = envelope['uuid'] as String? ?? '';
|
||||
final isSidechain = envelope['isSidechain'] as bool? ?? false;
|
||||
|
||||
DateTime timestamp;
|
||||
try {
|
||||
timestamp = DateTime.parse(envelope['timestamp'] as String? ?? '');
|
||||
} catch (_) {
|
||||
timestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'user':
|
||||
_parseUserInto(envelope, uuid, timestamp, isSidechain, out);
|
||||
case 'assistant':
|
||||
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, out);
|
||||
default:
|
||||
break; // unknown type — degrade gracefully
|
||||
}
|
||||
}
|
||||
|
||||
void _parseUserInto(
|
||||
Map<String, dynamic> envelope,
|
||||
String uuid,
|
||||
DateTime timestamp,
|
||||
bool isSidechain,
|
||||
List<ConversationItem> out,
|
||||
) {
|
||||
final message = envelope['message'] as Map?;
|
||||
if (message == null) return;
|
||||
final content = message['content'];
|
||||
|
||||
if (content is String) {
|
||||
if (content.isNotEmpty) {
|
||||
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: content));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (content is! List) return;
|
||||
|
||||
final textParts = <String>[];
|
||||
for (final item in content) {
|
||||
if (item is! Map) continue;
|
||||
switch (item['type'] as String?) {
|
||||
case 'text':
|
||||
final text = item['text'] as String? ?? '';
|
||||
if (text.isNotEmpty) textParts.add(text);
|
||||
case 'tool_result':
|
||||
final rawContent = item['content'];
|
||||
out.add(ToolResultMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
toolUseId: item['tool_use_id'] as String? ?? '',
|
||||
content: rawContent is String ? rawContent : jsonEncode(rawContent),
|
||||
isError: item['is_error'] as bool? ?? false,
|
||||
));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (textParts.isNotEmpty) {
|
||||
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: textParts.join('\n')));
|
||||
}
|
||||
}
|
||||
|
||||
void _parseAssistantInto(
|
||||
Map<String, dynamic> envelope,
|
||||
String uuid,
|
||||
DateTime timestamp,
|
||||
bool isSidechain,
|
||||
List<ConversationItem> out,
|
||||
) {
|
||||
final message = envelope['message'] as Map?;
|
||||
if (message == null) return;
|
||||
final content = message['content'];
|
||||
if (content is! List) return;
|
||||
|
||||
for (final item in content) {
|
||||
if (item is! Map) continue;
|
||||
switch (item['type'] as String?) {
|
||||
case 'text':
|
||||
final text = item['text'] as String? ?? '';
|
||||
if (text.isNotEmpty) {
|
||||
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: text));
|
||||
}
|
||||
case 'thinking':
|
||||
final thinking = item['thinking'] as String? ?? '';
|
||||
if (thinking.isNotEmpty) {
|
||||
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, thinking: thinking));
|
||||
}
|
||||
case 'tool_use':
|
||||
final rawInput = item['input'];
|
||||
out.add(AssistantToolUse(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
toolUseId: item['id'] as String? ?? '',
|
||||
name: item['name'] as String? ?? '',
|
||||
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
|
||||
));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Lightweight view of one open buffer for the tab strip — the
|
||||
/// daemon's authoritative content lives behind [EditorController];
|
||||
/// this is just what the tabs need to render.
|
||||
typedef OpenBuffer = ({String id, String path, bool dirty});
|
||||
|
||||
class EditorController extends ChangeNotifier {
|
||||
EditorController({required this.ipc, required DaemonBus events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
@@ -32,6 +37,9 @@ class EditorController extends ChangeNotifier {
|
||||
bool _dirty = false;
|
||||
String? _error;
|
||||
|
||||
/// All open buffers, in daemon order, for the tab strip.
|
||||
List<OpenBuffer> _buffers = const [];
|
||||
|
||||
bool _suppressNextRemoteEdit = false;
|
||||
int _pendingLocalEdits = 0;
|
||||
|
||||
@@ -41,10 +49,12 @@ class EditorController extends ChangeNotifier {
|
||||
Selection get selection => _selection;
|
||||
bool get dirty => _dirty;
|
||||
String? get error => _error;
|
||||
List<OpenBuffer> get buffers => _buffers;
|
||||
|
||||
/// On first mount we don't know what (if anything) is already
|
||||
/// active. Ask the daemon.
|
||||
/// On first mount we don't know what's already open. Ask the daemon
|
||||
/// for the buffer list and the active buffer.
|
||||
Future<void> hydrate() async {
|
||||
await _refreshList();
|
||||
final r = await ipc.request('editor.active');
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
@@ -63,6 +73,52 @@ class EditorController extends ChangeNotifier {
|
||||
await _loadBuffer(id);
|
||||
}
|
||||
|
||||
/// Make [id] the active buffer. The daemon echoes an
|
||||
/// `editor.active-changed` event which loads its content.
|
||||
Future<void> activate(String id) async {
|
||||
if (id == _activeId) return;
|
||||
await ipc.request('editor.activate', args: {'id': id});
|
||||
}
|
||||
|
||||
/// Close the buffer [id]. The daemon emits `editor.closed` (and an
|
||||
/// `editor.active-changed` if it was the active one).
|
||||
Future<void> closeBuffer(String id) async {
|
||||
await ipc.request('editor.close', args: {'id': id});
|
||||
}
|
||||
|
||||
/// Re-fetch the open-buffer list from the daemon (authoritative).
|
||||
Future<void> _refreshList() async {
|
||||
final r = await ipc.request('editor.list');
|
||||
if (!r.ok) return;
|
||||
final raw = r.data['buffers'];
|
||||
if (raw is! List) return;
|
||||
_buffers = [
|
||||
for (final b in raw)
|
||||
if (b is Map)
|
||||
(
|
||||
id: b['id']! as String,
|
||||
path: b['path']! as String,
|
||||
dirty: (b['dirty'] as bool?) ?? false,
|
||||
),
|
||||
];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _markDirty(String id, bool dirty) {
|
||||
var changed = false;
|
||||
_buffers = [
|
||||
for (final b in _buffers)
|
||||
if (b.id == id && b.dirty != dirty)
|
||||
(() {
|
||||
changed = true;
|
||||
return (id: b.id, path: b.path, dirty: dirty);
|
||||
})()
|
||||
else
|
||||
b,
|
||||
];
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadBuffer(String id) async {
|
||||
final r = await ipc.request('editor.read', args: {'id': id});
|
||||
if (!r.ok) {
|
||||
@@ -91,6 +147,7 @@ class EditorController extends ChangeNotifier {
|
||||
_content = newContent;
|
||||
_selection = newSelection;
|
||||
_dirty = true;
|
||||
_markDirty(id, true);
|
||||
notifyListeners();
|
||||
|
||||
// Mirror to daemon. Use editor.set-content for the first cut —
|
||||
@@ -116,15 +173,19 @@ class EditorController extends ChangeNotifier {
|
||||
if (e.subsystem != 'editor') return;
|
||||
switch (e.kind) {
|
||||
case 'editor.opened':
|
||||
// A new buffer joined the set — refresh the tab list, then
|
||||
// load whatever the daemon now considers active.
|
||||
unawaited(_refreshList());
|
||||
final id = e.data['id'] as String?;
|
||||
if (id == null) {
|
||||
_clearActive();
|
||||
} else if (id != _activeId) {
|
||||
_loadBuffer(id);
|
||||
}
|
||||
case 'editor.active-changed':
|
||||
final id = e.data['id'] as String?;
|
||||
if (id == null) {
|
||||
_activeId = null;
|
||||
_activePath = null;
|
||||
_content = '';
|
||||
_selection = const Selection.collapsed(0);
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
_clearActive();
|
||||
} else if (id != _activeId) {
|
||||
_loadBuffer(id);
|
||||
}
|
||||
@@ -138,25 +199,37 @@ class EditorController extends ChangeNotifier {
|
||||
// Remote edit (another client, or the CLI inserting bytes).
|
||||
// Reload the authoritative buffer.
|
||||
final id = e.data['id'] as String?;
|
||||
if (id != null) _markDirty(id, true);
|
||||
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
|
||||
_loadBuffer(id);
|
||||
}
|
||||
case 'editor.saved':
|
||||
if (e.data['id'] == _activeId) {
|
||||
final id = e.data['id'] as String?;
|
||||
if (id != null) _markDirty(id, false);
|
||||
if (id == _activeId) {
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
case 'editor.closed':
|
||||
// A buffer left the set — refresh the tab list. If it was the
|
||||
// active one the daemon promotes another and emits
|
||||
// active-changed; reflect the cleared state in the meantime.
|
||||
unawaited(_refreshList());
|
||||
if (e.data['id'] == _activeId) {
|
||||
_activeId = null;
|
||||
_activePath = null;
|
||||
_content = '';
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
_clearActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _clearActive() {
|
||||
_activeId = null;
|
||||
_activePath = null;
|
||||
_content = '';
|
||||
_selection = const Selection.collapsed(0);
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
|
||||
@@ -10,10 +10,13 @@ import 'package:flutter/widgets.dart';
|
||||
import 'editor_controller.dart';
|
||||
import 'syntax_text_controller.dart';
|
||||
|
||||
/// Tier-2 editor tab. One tab — the content reflects the daemon's
|
||||
/// active buffer. Multi-file tabs live in the workspace-slot plan but
|
||||
/// aren't in Tier 2's scope; opening a new file swaps this view's
|
||||
/// content.
|
||||
/// Tier-2 editor pane. Shows one tab per open buffer via the shared
|
||||
/// [MultitabPane] (the same strip the Claude pane uses); the body
|
||||
/// reflects the daemon's active buffer. The daemon ([EditorRegistry])
|
||||
/// is the source of truth for which buffers are open and which is
|
||||
/// active — the local [MultitabController] is reconciled from it, and
|
||||
/// tab gestures (select / close) are routed back as `editor.activate`
|
||||
/// / `editor.close`.
|
||||
///
|
||||
/// Uses Flutter's low-level `EditableText` so we stay off Material
|
||||
/// per D-007. Owning more of the editor stack (line numbers, gutter,
|
||||
@@ -27,17 +30,24 @@ class EditorView extends StatefulWidget {
|
||||
|
||||
class _EditorViewState extends State<EditorView> {
|
||||
EditorController? _controller;
|
||||
final MultitabController<String> _tabs = MultitabController<String>();
|
||||
final TreeSitterService _syntax = TreeSitterService.shared;
|
||||
late final SyntaxTextController _text;
|
||||
late final FocusNode _focus;
|
||||
String? _lastRemoteContent;
|
||||
|
||||
/// Guards the controller→tabstrip reconcile so the tabstrip's own
|
||||
/// change notifications (from us mutating it) don't bounce back as
|
||||
/// daemon calls.
|
||||
bool _applyingRemote = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_text = SyntaxTextController(syntax: _syntax);
|
||||
_focus = FocusNode();
|
||||
_text.addListener(_onTextChanged);
|
||||
_tabs.addListener(_onTabsChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -54,6 +64,8 @@ class _EditorViewState extends State<EditorView> {
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.dispose();
|
||||
_focus.dispose();
|
||||
_tabs.removeListener(_onTabsChanged);
|
||||
_tabs.dispose();
|
||||
_controller?.removeListener(_onControllerChanged);
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
@@ -61,6 +73,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
|
||||
void _onControllerChanged() {
|
||||
final c = _controller!;
|
||||
_syncTabs(c);
|
||||
_text.updatePath(c.activePath);
|
||||
if (c.content != _lastRemoteContent) {
|
||||
_lastRemoteContent = c.content;
|
||||
@@ -72,7 +85,46 @@ class _EditorViewState extends State<EditorView> {
|
||||
_text.value = TextEditingValue(text: c.content, selection: sel);
|
||||
_text.addListener(_onTextChanged);
|
||||
}
|
||||
setState(() {}); // subtitle refresh
|
||||
setState(() {}); // tab/title refresh
|
||||
}
|
||||
|
||||
/// Reconcile the local tab strip to match the daemon's open-buffer
|
||||
/// list + active selection. Membership and order follow the daemon;
|
||||
/// titles carry a dirty marker.
|
||||
void _syncTabs(EditorController c) {
|
||||
_applyingRemote = true;
|
||||
final bufs = c.buffers;
|
||||
final liveIds = {for (final b in bufs) b.id};
|
||||
for (final e in _tabs.entries) {
|
||||
if (!liveIds.contains(e.id)) _tabs.remove(e.id);
|
||||
}
|
||||
for (final b in bufs) {
|
||||
final title = _tabTitle(b);
|
||||
final existing = _tabs.entries.where((e) => e.id == b.id).toList();
|
||||
if (existing.isEmpty) {
|
||||
_tabs.add(MultitabEntry<String>(id: b.id, title: title, payload: b.id), activate: false);
|
||||
} else if (existing.first.title != title) {
|
||||
_tabs.replace(b.id, MultitabEntry<String>(id: b.id, title: title, payload: b.id));
|
||||
}
|
||||
}
|
||||
final act = c.activeId;
|
||||
if (act != null && _tabs.activeId != act) _tabs.activate(act);
|
||||
_applyingRemote = false;
|
||||
}
|
||||
|
||||
/// User tapped a tab. The tab strip already updated its local active
|
||||
/// selection; mirror that choice to the daemon.
|
||||
void _onTabsChanged() {
|
||||
if (_applyingRemote) return;
|
||||
final id = _tabs.activeId;
|
||||
if (id != null && id != _controller?.activeId) {
|
||||
unawaited(_controller?.activate(id) ?? Future.value());
|
||||
}
|
||||
}
|
||||
|
||||
String _tabTitle(OpenBuffer b) {
|
||||
final name = b.path.split('/').last;
|
||||
return b.dirty ? '$name •' : name;
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
@@ -112,32 +164,29 @@ class _EditorViewState extends State<EditorView> {
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final title = c.activePath ?? 'editor';
|
||||
final subtitle = c.activeId == null
|
||||
? 'no buffer · use `clide open <path>` or pick a file in the tree'
|
||||
: '${c.activeId} · ${c.dirty ? 'modified' : 'saved'}'
|
||||
'${c.error == null ? '' : ' · ${c.error}'}';
|
||||
|
||||
return ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
child: c.activeId == null
|
||||
? const Center(
|
||||
child: ClideText(
|
||||
'Open a file to begin editing.',
|
||||
muted: true,
|
||||
),
|
||||
)
|
||||
: Focus(
|
||||
onKeyEvent: _onKey,
|
||||
child: _TextBody(
|
||||
controller: _text,
|
||||
focus: _focus,
|
||||
background: tokens.panelBackground,
|
||||
foreground: tokens.globalForeground,
|
||||
accent: tokens.globalFocus,
|
||||
),
|
||||
),
|
||||
if (c.buffers.isEmpty) {
|
||||
return ClidePaneChrome(
|
||||
title: 'editor',
|
||||
subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree',
|
||||
child: const Center(
|
||||
child: ClideText('Open a file to begin editing.', muted: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
return MultitabPane<String>(
|
||||
controller: _tabs,
|
||||
allowReorder: false,
|
||||
onCloseRequested: (entry) => unawaited(c.closeBuffer(entry.id)),
|
||||
bodyBuilder: (context, _) => Focus(
|
||||
onKeyEvent: _onKey,
|
||||
child: _TextBody(
|
||||
controller: _text,
|
||||
focus: _focus,
|
||||
background: tokens.panelBackground,
|
||||
foreground: tokens.globalForeground,
|
||||
accent: tokens.globalFocus,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -110,3 +106,74 @@ class DaemonEvent extends ClideEvent {
|
||||
@override
|
||||
Map<String, Object?> payload() => {'ts': ts.toIso8601String(), ...data};
|
||||
}
|
||||
|
||||
/// A Claude Code tmux teammate appeared (its pane is live). Identity is
|
||||
/// taken from the team config, so it is reliable regardless of transcript
|
||||
/// drift (T-139, D-75). [transcriptPath] is the best-effort resolved
|
||||
/// subagent transcript, or null if it could not be joined yet.
|
||||
class TeamMemberJoined extends ClideEvent {
|
||||
const TeamMemberJoined({
|
||||
required this.team,
|
||||
required this.agentId,
|
||||
required this.name,
|
||||
required this.agentType,
|
||||
required this.paneId,
|
||||
this.model,
|
||||
this.color,
|
||||
this.cwd,
|
||||
this.transcriptPath,
|
||||
});
|
||||
|
||||
/// Team name (the `~/.claude/teams/<team>` directory).
|
||||
final String team;
|
||||
|
||||
/// Config agent id (`<name>@<team>`).
|
||||
final String agentId;
|
||||
final String name;
|
||||
final String agentType;
|
||||
|
||||
/// tmux pane id (`%N`).
|
||||
final String paneId;
|
||||
final String? model;
|
||||
final String? color;
|
||||
final String? cwd;
|
||||
final String? transcriptPath;
|
||||
|
||||
@override
|
||||
String get subsystem => 'team';
|
||||
@override
|
||||
String get kind => 'member-joined';
|
||||
@override
|
||||
Map<String, Object?> payload() => {
|
||||
'team': team,
|
||||
'agentId': agentId,
|
||||
'name': name,
|
||||
'agentType': agentType,
|
||||
'paneId': paneId,
|
||||
if (model != null) 'model': model,
|
||||
if (color != null) 'color': color,
|
||||
if (cwd != null) 'cwd': cwd,
|
||||
if (transcriptPath != null) 'transcriptPath': transcriptPath,
|
||||
};
|
||||
}
|
||||
|
||||
/// A Claude Code tmux teammate's pane went away (it exited or the team
|
||||
/// dissolved) — T-139.
|
||||
class TeamMemberLeft extends ClideEvent {
|
||||
const TeamMemberLeft({
|
||||
required this.team,
|
||||
required this.agentId,
|
||||
required this.paneId,
|
||||
});
|
||||
|
||||
final String team;
|
||||
final String agentId;
|
||||
final String paneId;
|
||||
|
||||
@override
|
||||
String get subsystem => 'team';
|
||||
@override
|
||||
String get kind => 'member-left';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'team': team, 'agentId': agentId, 'paneId': paneId};
|
||||
}
|
||||
|
||||
@@ -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,55 +11,102 @@ 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;
|
||||
|
||||
Socket? _socket;
|
||||
bool _connected = false;
|
||||
bool _disposed = false;
|
||||
bool _started = false;
|
||||
Timer? _reconnectTimer;
|
||||
Duration _backoff = const Duration(milliseconds: 200);
|
||||
int _nextId = 0;
|
||||
final Map<String, Completer<IpcResponse>> _pending = {};
|
||||
|
||||
/// Requests that arrived before the socket was connected park here
|
||||
/// until the connection comes up (or the wait times out).
|
||||
final List<Completer<void>> _connectWaiters = [];
|
||||
|
||||
/// How long a request will wait for an in-progress connection before
|
||||
/// giving up with a not-connected error. Covers the startup window
|
||||
/// where the UI queries before the socket has finished connecting.
|
||||
static const Duration _connectWait = Duration(seconds: 5);
|
||||
|
||||
bool get isConnected => _connected;
|
||||
|
||||
Future<void> start() async {
|
||||
_disposed = false;
|
||||
_started = true;
|
||||
await _connect();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_disposed = true;
|
||||
_started = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
final s = _socket;
|
||||
_socket = null;
|
||||
await s?.close();
|
||||
_failPending('client stopped');
|
||||
_wakeConnectWaiters();
|
||||
_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;
|
||||
_started = true;
|
||||
_backoff = const Duration(milliseconds: 200);
|
||||
await _connect();
|
||||
}
|
||||
|
||||
Future<IpcResponse> request(
|
||||
String cmd, {
|
||||
Map<String, Object?> args = const {},
|
||||
}) {
|
||||
}) async {
|
||||
if (!_connected || _socket == null) {
|
||||
return Future.value(IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'daemon not connected',
|
||||
),
|
||||
));
|
||||
// A connection attempt is in flight (startup or reconnect) — wait
|
||||
// for it rather than failing instantly, so queries issued during
|
||||
// the startup window don't get a spurious not-connected error.
|
||||
// If the client was never started (or is disposed), fail fast.
|
||||
if (_started && !_disposed) {
|
||||
await _awaitConnected(_connectWait);
|
||||
}
|
||||
if (!_connected || _socket == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'daemon not connected',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
final id = '${_nextId++}';
|
||||
final completer = Completer<IpcResponse>();
|
||||
@@ -69,15 +116,40 @@ class DaemonClient extends ChangeNotifier {
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (_disposed) return;
|
||||
/// Complete when the socket connects, or after [timeout] (whichever
|
||||
/// first). Returns immediately if already connected.
|
||||
Future<void> _awaitConnected(Duration timeout) async {
|
||||
if (_connected) return;
|
||||
final c = Completer<void>();
|
||||
_connectWaiters.add(c);
|
||||
try {
|
||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
||||
await c.future.timeout(timeout);
|
||||
} on TimeoutException {
|
||||
_connectWaiters.remove(c);
|
||||
}
|
||||
}
|
||||
|
||||
void _wakeConnectWaiters() {
|
||||
if (_connectWaiters.isEmpty) return;
|
||||
final waiters = List<Completer<void>>.from(_connectWaiters);
|
||||
_connectWaiters.clear();
|
||||
for (final c in waiters) {
|
||||
if (!c.isCompleted) c.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
// Already connected? Don't open a second socket. Guards against
|
||||
// racing connect attempts (e.g. start() arming the reconnect loop
|
||||
// while swapIpcServer's reconnectAt connects on first boot).
|
||||
if (_disposed || _connected) return;
|
||||
try {
|
||||
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,
|
||||
@@ -149,6 +221,10 @@ class DaemonClient extends ChangeNotifier {
|
||||
void _setConnected(bool v) {
|
||||
if (_connected == v) return;
|
||||
_connected = v;
|
||||
// Release any requests parked waiting for the connection — on a
|
||||
// successful connect they proceed to send; this runs before the
|
||||
// dispose guard so a connect always wakes them.
|
||||
if (v) _wakeConnectWaiters();
|
||||
// Skip side-effects (event emit + notifyListeners) after dispose —
|
||||
// the socket stream's onDone can fire post-dispose and would
|
||||
// otherwise hit ChangeNotifier's "used after disposed" assert.
|
||||
@@ -160,10 +236,12 @@ class DaemonClient extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_started = false;
|
||||
_reconnectTimer?.cancel();
|
||||
unawaited(_socket?.close());
|
||||
_socket = null;
|
||||
_failPending('client disposed');
|
||||
_wakeConnectWaiters();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -185,6 +185,20 @@ List<ContrastPair> extendedPairs(SurfaceTokens s) => [
|
||||
background: s.globalBackground,
|
||||
largeText: true,
|
||||
),
|
||||
// selection.foreground_on_selection is intentionally omitted here.
|
||||
//
|
||||
// The `selectionBackground` token defaults to `globalFocus.withAlpha(0x66)`
|
||||
// — a semi-transparent tint composited onto the real content background at
|
||||
// runtime. The WCAG compositor in contrastRatio() blends onto neutral grey
|
||||
// (0x808080) rather than the actual dark panel background, which
|
||||
// systematically understates the readable contrast for all current bundled
|
||||
// themes. Adding the pair here would require retuning palettes, which D-69
|
||||
// forbids for user-contract themes.
|
||||
//
|
||||
// Enforcement is deferred to a follow-up ticket: -hc/-cb variants will
|
||||
// declare an explicit `surface.selectionBackground` override that is
|
||||
// opaque enough to clear 3:1 against the grey compositor, at which point
|
||||
// the pair can be added to extendedPairs.
|
||||
];
|
||||
|
||||
/// Convenience for tests: returns the list of [canonicalPairs] that
|
||||
|
||||
@@ -36,6 +36,14 @@ class ThemeResolver {
|
||||
);
|
||||
}
|
||||
|
||||
// selectionBackground defaults to globalFocus at ~40 % opacity (0x66 alpha)
|
||||
// when the theme does not declare an explicit surface override for it.
|
||||
// This matches the terminal's established convention of
|
||||
// `globalFocus.withAlpha(0x66)` for focus-adjacent highlights.
|
||||
if (surfaceOverride?[TokenKeys.selectionBackground] == null) {
|
||||
surface[TokenKeys.selectionBackground] = surface[TokenKeys.globalFocus]!.withAlpha(0x66);
|
||||
}
|
||||
|
||||
final extTokens = <String, Color>{};
|
||||
if (extensionOverride != null) {
|
||||
for (final entry in extensionOverride.entries) {
|
||||
@@ -111,6 +119,7 @@ class ThemeResolver {
|
||||
syntaxComment: surface[TokenKeys.syntaxComment]!,
|
||||
syntaxMethod: surface[TokenKeys.syntaxMethod]!,
|
||||
syntaxPunct: surface[TokenKeys.syntaxPunct]!,
|
||||
selectionBackground: surface[TokenKeys.selectionBackground]!,
|
||||
extensionTokens: extTokens,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ class SurfaceTokens {
|
||||
required this.syntaxComment,
|
||||
required this.syntaxMethod,
|
||||
required this.syntaxPunct,
|
||||
// selection
|
||||
required this.selectionBackground,
|
||||
required this.extensionTokens,
|
||||
});
|
||||
|
||||
@@ -186,6 +188,11 @@ class SurfaceTokens {
|
||||
final Color syntaxMethod;
|
||||
final Color syntaxPunct;
|
||||
|
||||
/// Background color for text selection highlights. Rendered at ~40 % alpha
|
||||
/// (like the terminal's `globalFocus.withAlpha(0x66)`) so the selected text
|
||||
/// remains legible through the tint.
|
||||
final Color selectionBackground;
|
||||
|
||||
/// Extension-declared tokens keyed by their dotted path
|
||||
/// (e.g. `ext.sqlite.table.background`).
|
||||
final Map<String, Color> extensionTokens;
|
||||
@@ -296,6 +303,9 @@ abstract class TokenKeys {
|
||||
static const syntaxMethod = 'syntax.method';
|
||||
static const syntaxPunct = 'syntax.punct';
|
||||
|
||||
// selection
|
||||
static const selectionBackground = 'selection.background';
|
||||
|
||||
static const all = <String>[
|
||||
globalForeground,
|
||||
globalBackground,
|
||||
@@ -363,5 +373,6 @@ abstract class TokenKeys {
|
||||
syntaxComment,
|
||||
syntaxMethod,
|
||||
syntaxPunct,
|
||||
selectionBackground,
|
||||
];
|
||||
}
|
||||
|
||||
+119
-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,85 @@ 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;
|
||||
// Already serving this exact workspace? Reuse the live server.
|
||||
// The startup factory binds the launch CWD, then the project-open
|
||||
// flow fires for (usually) that same path — tearing the server
|
||||
// down and rebinding would drop every live connection (the UI's
|
||||
// DaemonClient, the Claude pane's spawn gate fires right on
|
||||
// ProjectOpened) for no gain, leaving panes stranded. A genuine
|
||||
// project switch (different path) falls through and rebinds.
|
||||
final live = ipcServer;
|
||||
if (live != null && live.isRunning && live.workspaceRoot == workRoot.path) {
|
||||
ipcLog.info('ipc', 'already serving ${workRoot.path}; reusing the live server');
|
||||
// Idempotent — a no-op when the client is already connected here.
|
||||
await ipcClient?.reconnectAt(live.socketPath);
|
||||
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 +172,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 +186,40 @@ 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;
|
||||
// start() synchronously marks the client "connecting" (so
|
||||
// requests issued during the startup window park for the
|
||||
// socket instead of failing) and arms the reconnect loop.
|
||||
// swapIpcServer then binds the server and reconnectAt makes
|
||||
// the connect immediate. _connect's already-connected guard
|
||||
// keeps these two paths from opening a second socket.
|
||||
unawaited(client.start());
|
||||
unawaited(swapIpcServer(dispatcher, workRoot));
|
||||
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 +262,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,
|
||||
),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:clide/clide.dart' show clideVersion;
|
||||
import 'package:clide/src/ipc/command_schema.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
|
||||
@@ -12,13 +13,26 @@ class DaemonDispatcher {
|
||||
|
||||
final Map<String, CommandHandler> _handlers = {};
|
||||
|
||||
void register(String cmd, CommandHandler handler) {
|
||||
/// Per-command argument schemas (T-120 / D-74). Populated alongside
|
||||
/// handlers by registrants. A command with no entry dispatches
|
||||
/// unvalidated — schema adoption is opt-in per command.
|
||||
final Map<String, CommandSchema> _schemas = {};
|
||||
|
||||
/// Register [handler] for [cmd]. Pass [schema] to have the dispatcher
|
||||
/// normalise + validate `req.args` before the handler runs (D-74).
|
||||
void register(String cmd, CommandHandler handler, {CommandSchema? schema}) {
|
||||
_handlers[cmd] = handler;
|
||||
if (schema != null) {
|
||||
_schemas[cmd] = schema;
|
||||
} else {
|
||||
_schemas.remove(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all registered handlers except ping/version.
|
||||
void clear() {
|
||||
_handlers.removeWhere((k, _) => k != 'ping' && k != 'version');
|
||||
_schemas.removeWhere((k, _) => k != 'ping' && k != 'version');
|
||||
}
|
||||
|
||||
bool get isEmpty => _handlers.length <= 2; // only ping + version
|
||||
@@ -36,7 +50,13 @@ class DaemonDispatcher {
|
||||
),
|
||||
);
|
||||
}
|
||||
return h(req);
|
||||
final schema = _schemas[req.cmd];
|
||||
if (schema == null) return h(req);
|
||||
final result = schema.validate(schema.normalize(req.args));
|
||||
if (!result.isOk) {
|
||||
return schemaError(req.id, result.error!);
|
||||
}
|
||||
return h(IpcRequest(id: req.id, cmd: req.cmd, args: result.values!));
|
||||
}
|
||||
|
||||
Future<IpcResponse> _ping(IpcRequest req) async => IpcResponse.ok(
|
||||
|
||||
@@ -7,6 +7,7 @@ library;
|
||||
|
||||
import '../git/client.dart';
|
||||
import '../git/operations.dart' show GitException;
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
@@ -22,6 +23,30 @@ const int _gitLogMaxCount = 1000;
|
||||
/// arguments.
|
||||
const int _gitPathsMaxCount = 256;
|
||||
|
||||
/// Schema for `git.checkout` (D-74). `positional[0]` → `branch`, so
|
||||
/// `clide git checkout <branch>` from the C client now reaches the
|
||||
/// handler; the leading-dash guard stops argv injection at the
|
||||
/// dispatcher (mirrors [validateGitRef], kept below as
|
||||
/// defense-in-depth since the git client is also UI-reachable).
|
||||
const CommandSchema _checkoutSchema = CommandSchema(
|
||||
positional: ['branch'],
|
||||
args: {
|
||||
'branch': ArgSpec(required: true, rejectLeadingDash: true),
|
||||
},
|
||||
);
|
||||
|
||||
/// Schema for `git.push` (D-74). `clide git push <remote> <branch>`.
|
||||
/// Both refs optional (bare `git.push` is valid); leading-dash
|
||||
/// rejected on each.
|
||||
const CommandSchema _pushSchema = CommandSchema(
|
||||
positional: ['remote', 'branch'],
|
||||
args: {
|
||||
'remote': ArgSpec(rejectLeadingDash: true),
|
||||
'branch': ArgSpec(rejectLeadingDash: true),
|
||||
'setUpstream': ArgSpec(type: ArgType.boolean),
|
||||
},
|
||||
);
|
||||
|
||||
void registerGitCommands(
|
||||
DaemonDispatcher d,
|
||||
GitClient git,
|
||||
@@ -245,7 +270,7 @@ void registerGitCommands(
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
}, schema: _pushSchema);
|
||||
|
||||
d.register('git.branches', (req) async {
|
||||
try {
|
||||
@@ -279,7 +304,7 @@ void registerGitCommands(
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
}, schema: _checkoutSchema);
|
||||
}
|
||||
|
||||
List<String> _pathList(Object? raw) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/// 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/command_schema.dart';
|
||||
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';
|
||||
|
||||
/// Schema for `panel.resize` (D-74). The dispatcher normalises the
|
||||
/// argv shape (`positional[0]` → `slot`, `--to/--by` flags) and
|
||||
/// coerces `to`/`by` to numbers before the handler runs. The
|
||||
/// "exactly one of to/by" rule is cross-argument semantics, so it
|
||||
/// stays in the handler.
|
||||
const CommandSchema _resizeSchema = CommandSchema(
|
||||
positional: ['slot'],
|
||||
args: {
|
||||
'slot': ArgSpec(required: true),
|
||||
'to': ArgSpec(type: ArgType.number),
|
||||
'by': ArgSpec(type: ArgType.number),
|
||||
},
|
||||
);
|
||||
|
||||
void registerPanelCommands(DaemonDispatcher d, PanelResizer resizer) {
|
||||
d.register('panel.resize', (req) => _resize(req, resizer), schema: _resizeSchema);
|
||||
}
|
||||
|
||||
Future<IpcResponse> _resize(IpcRequest req, PanelResizer r) async {
|
||||
// Args are schema-normalised + coerced by the dispatcher: `slot` is a
|
||||
// non-empty string, `to`/`by` are num? when present.
|
||||
final slot = req.args['slot'] as String;
|
||||
final hasTo = req.args['to'] != null;
|
||||
final hasBy = req.args['by'] != null;
|
||||
if (!hasTo && !hasBy) {
|
||||
return _userErr(req.id, 'one of `to` (absolute) or `by` (delta) is required');
|
||||
}
|
||||
if (hasTo && hasBy) {
|
||||
return _userErr(req.id, 'pass only one of `to` and `by`');
|
||||
}
|
||||
final value = ((hasTo ? req.args['to'] : req.args['by']) as num).toDouble();
|
||||
|
||||
if (slot == editorSplitSlot) {
|
||||
if (hasTo) {
|
||||
r.setEditorRatio(value);
|
||||
} else {
|
||||
r.bumpEditorRatio(value);
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'slot': slot,
|
||||
'ratio': r.currentEditorRatio,
|
||||
});
|
||||
}
|
||||
|
||||
final ok = 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),
|
||||
});
|
||||
}
|
||||
|
||||
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,220 @@
|
||||
/// Typed argument schema for IPC commands (T-120, per D-74).
|
||||
///
|
||||
/// A [CommandSchema] declares the arguments a command accepts — their
|
||||
/// types, whether they're required, and per-argument constraints
|
||||
/// (charset/pattern, leading-dash rejection, numeric range, list
|
||||
/// caps). Schemas are registered alongside their handlers on the
|
||||
/// `DaemonDispatcher`; the dispatcher normalises + validates
|
||||
/// `req.args` against the registered schema *before* invoking the
|
||||
/// handler, so a command can never see malformed input and no handler
|
||||
/// has to hand-roll its own argument checks.
|
||||
///
|
||||
/// Two call shapes reach a command (see T-119): the direct shape
|
||||
/// (`{branch: "main"}`, from the in-process `DaemonClient` / UI) and
|
||||
/// the argv-translator shape (`{positional: [...], flags: {...}}`,
|
||||
/// from the C `clide` client). [CommandSchema.normalize] folds the
|
||||
/// argv shape into named arguments using the schema's positional
|
||||
/// ordering, so handlers only ever read flat named keys.
|
||||
///
|
||||
/// Flutter-free by construction — lives under `lib/src/ipc/` and
|
||||
/// imports nothing from the kernel. The constraint vocabulary is
|
||||
/// hand-rolled per the prefer-zero-deps guardrail.
|
||||
library;
|
||||
|
||||
import 'envelope.dart';
|
||||
import 'schema_v1.dart';
|
||||
|
||||
/// Coarse type of an argument value after coercion.
|
||||
enum ArgType { string, number, boolean, stringList }
|
||||
|
||||
/// Constraints on a single argument.
|
||||
class ArgSpec {
|
||||
const ArgSpec({
|
||||
this.type = ArgType.string,
|
||||
this.required = false,
|
||||
this.pattern,
|
||||
this.rejectLeadingDash = false,
|
||||
this.min,
|
||||
this.max,
|
||||
this.maxItems,
|
||||
this.allowed,
|
||||
});
|
||||
|
||||
final ArgType type;
|
||||
final bool required;
|
||||
|
||||
/// String only — the value must fully match this pattern.
|
||||
final RegExp? pattern;
|
||||
|
||||
/// String / stringList — reject any value (or element) starting with
|
||||
/// `-`. This is the argv-injection guard T-104 introduced for git
|
||||
/// refs; the schema makes it declarative.
|
||||
final bool rejectLeadingDash;
|
||||
|
||||
/// Number only — inclusive bounds.
|
||||
final num? min;
|
||||
final num? max;
|
||||
|
||||
/// stringList only — maximum element count.
|
||||
final int? maxItems;
|
||||
|
||||
/// String only — closed set of accepted values.
|
||||
final Set<String>? allowed;
|
||||
|
||||
/// Coerce [raw] to this spec's [type]. Returns the typed value, or a
|
||||
/// [_CoerceError] when the raw value can't be represented as the
|
||||
/// declared type. Flag values arrive as strings from the argv
|
||||
/// translator, so numeric/boolean args parse from text here.
|
||||
Object? _coerce(Object? raw, void Function(String) fail) {
|
||||
switch (type) {
|
||||
case ArgType.string:
|
||||
if (raw is String) return raw;
|
||||
fail('expected a string');
|
||||
return null;
|
||||
case ArgType.number:
|
||||
if (raw is num) return raw;
|
||||
if (raw is String) {
|
||||
final n = num.tryParse(raw);
|
||||
if (n != null) return n;
|
||||
}
|
||||
fail('expected a number');
|
||||
return null;
|
||||
case ArgType.boolean:
|
||||
if (raw is bool) return raw;
|
||||
if (raw == 'true') return true;
|
||||
if (raw == 'false') return false;
|
||||
fail('expected a boolean');
|
||||
return null;
|
||||
case ArgType.stringList:
|
||||
if (raw is List) {
|
||||
return raw.map((e) => '$e').toList();
|
||||
}
|
||||
if (raw is String) return [raw];
|
||||
fail('expected a list');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply value constraints to an already-coerced [value]. Returns
|
||||
/// null when valid, else a human-readable reason.
|
||||
String? _check(Object? value) {
|
||||
switch (type) {
|
||||
case ArgType.string:
|
||||
final s = value as String;
|
||||
if (rejectLeadingDash && s.startsWith('-')) {
|
||||
return 'must not start with "-"';
|
||||
}
|
||||
if (allowed != null && !allowed!.contains(s)) {
|
||||
return 'must be one of ${allowed!.join(", ")}';
|
||||
}
|
||||
if (pattern != null && !pattern!.hasMatch(s)) {
|
||||
return 'does not match ${pattern!.pattern}';
|
||||
}
|
||||
return null;
|
||||
case ArgType.number:
|
||||
final n = value as num;
|
||||
if (min != null && n < min!) return 'must be >= $min';
|
||||
if (max != null && n > max!) return 'must be <= $max';
|
||||
return null;
|
||||
case ArgType.boolean:
|
||||
return null;
|
||||
case ArgType.stringList:
|
||||
final list = (value as List).cast<String>();
|
||||
if (maxItems != null && list.length > maxItems!) {
|
||||
return 'has ${list.length} items; cap is $maxItems';
|
||||
}
|
||||
if (rejectLeadingDash) {
|
||||
for (final e in list) {
|
||||
if (e.startsWith('-')) return 'element "$e" must not start with "-"';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The argument contract for one command.
|
||||
class CommandSchema {
|
||||
const CommandSchema({this.positional = const [], this.args = const {}});
|
||||
|
||||
/// Names for positional argv tokens, in order. `positional[i]` in the
|
||||
/// argv-translator shape maps to the argument named `positional[i]`.
|
||||
final List<String> positional;
|
||||
|
||||
/// Argument name → spec. Includes the positional names.
|
||||
final Map<String, ArgSpec> args;
|
||||
|
||||
/// Reserved keys the argv translator emits — never argument names.
|
||||
static const _argvKeys = {'positional', 'flags', 'passthrough'};
|
||||
|
||||
/// Fold the argv-translator shape (`{positional, flags}`) into flat
|
||||
/// named arguments using [positional] ordering. The direct call
|
||||
/// shape (already flat named keys) is returned unchanged.
|
||||
Map<String, Object?> normalize(Map<String, Object?> raw) {
|
||||
final looksLikeArgv = raw.keys.any(_argvKeys.contains);
|
||||
if (!looksLikeArgv) return raw;
|
||||
final out = <String, Object?>{};
|
||||
final pos = raw['positional'];
|
||||
if (pos is List) {
|
||||
for (var i = 0; i < pos.length && i < positional.length; i++) {
|
||||
out[positional[i]] = pos[i];
|
||||
}
|
||||
}
|
||||
final flags = raw['flags'];
|
||||
if (flags is Map) {
|
||||
for (final e in flags.entries) {
|
||||
out['${e.key}'] = e.value;
|
||||
}
|
||||
}
|
||||
if (raw.containsKey('passthrough')) out['passthrough'] = raw['passthrough'];
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Validate [normalized] (post-[normalize]) against every declared
|
||||
/// arg. Returns a [SchemaResult] carrying either the coerced argument
|
||||
/// map (declared args replaced with their typed values; undeclared
|
||||
/// keys preserved untouched) or the first violation message.
|
||||
SchemaResult validate(Map<String, Object?> normalized) {
|
||||
final out = Map<String, Object?>.from(normalized);
|
||||
for (final entry in args.entries) {
|
||||
final name = entry.key;
|
||||
final spec = entry.value;
|
||||
final present = normalized.containsKey(name) && normalized[name] != null;
|
||||
if (!present) {
|
||||
if (spec.required) return SchemaResult.err('$name is required');
|
||||
continue;
|
||||
}
|
||||
String? coerceFailure;
|
||||
final coerced = spec._coerce(normalized[name], (m) => coerceFailure = m);
|
||||
if (coerceFailure != null) return SchemaResult.err('$name: $coerceFailure');
|
||||
final reason = spec._check(coerced);
|
||||
if (reason != null) return SchemaResult.err('$name $reason');
|
||||
out[name] = coerced;
|
||||
}
|
||||
return SchemaResult.ok(out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of [CommandSchema.validate].
|
||||
class SchemaResult {
|
||||
const SchemaResult.ok(this.values) : error = null;
|
||||
const SchemaResult.err(this.error) : values = null;
|
||||
|
||||
/// Coerced argument map on success; null on failure.
|
||||
final Map<String, Object?>? values;
|
||||
|
||||
/// Violation message on failure; null on success.
|
||||
final String? error;
|
||||
|
||||
bool get isOk => error == null;
|
||||
}
|
||||
|
||||
/// Build the standard `userError` response for a schema violation.
|
||||
IpcResponse schemaError(String id, String message) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: RichText(text: textSpan),
|
||||
child: Text.rich(textSpan),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -65,8 +65,8 @@ class ClideMarkdown extends StatelessWidget {
|
||||
spans.add(_inlineElementSpan(n, tokens, onRecordTap));
|
||||
}
|
||||
}
|
||||
out.add(RichText(
|
||||
text: TextSpan(
|
||||
out.add(Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
@@ -235,11 +235,11 @@ class ClideMarkdown extends StatelessWidget {
|
||||
}
|
||||
|
||||
static Widget _inlineText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
|
||||
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight));
|
||||
return Text.rich(_buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight));
|
||||
}
|
||||
|
||||
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
|
||||
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap));
|
||||
return Text.rich(_buildInlineSpan(el, tokens, onRecordTap));
|
||||
}
|
||||
|
||||
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// No-Material text selection wrapper (D-7 — clide ships no Material).
|
||||
///
|
||||
/// Flutter's convenient `SelectionArea` lives in `package:flutter/material.dart`,
|
||||
/// so clide can't use it. This wraps the widget-layer [SelectableRegion]
|
||||
/// with the desktop-appropriate setup: a managed [FocusNode], no on-screen
|
||||
/// selection handles (`emptyTextSelectionControls` — desktop selects by
|
||||
/// mouse drag), and [DefaultTextEditingShortcuts] so Ctrl/Cmd+A and
|
||||
/// Ctrl/Cmd+C work even outside a `WidgetsApp`.
|
||||
///
|
||||
/// Any descendant `Text` / `Text.rich` (e.g. [ClideMarkdown],
|
||||
/// [ClideCodeBlock] after T-135) becomes selectable, and selection +
|
||||
/// copy span across them.
|
||||
class ClideSelectionArea extends StatefulWidget {
|
||||
const ClideSelectionArea({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<ClideSelectionArea> createState() => _ClideSelectionAreaState();
|
||||
}
|
||||
|
||||
class _ClideSelectionAreaState extends State<ClideSelectionArea> {
|
||||
final FocusNode _focusNode = FocusNode(debugLabel: 'ClideSelectionArea');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTextEditingShortcuts(
|
||||
child: SelectableRegion(
|
||||
focusNode: _focusNode,
|
||||
selectionControls: emptyTextSelectionControls,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ export 'src/clide_pane_chrome.dart';
|
||||
export 'src/clide_resize_border.dart';
|
||||
export 'src/clide_pty_view.dart';
|
||||
export 'src/clide_scrollbar.dart';
|
||||
export 'src/clide_selection_area.dart';
|
||||
export 'src/clide_spine.dart';
|
||||
export 'src/clide_surface.dart';
|
||||
export 'src/clide_tab_bar.dart';
|
||||
|
||||
@@ -245,6 +245,68 @@ static void clide_app_activate(GApplication* application) {
|
||||
},
|
||||
window, nullptr);
|
||||
|
||||
// T-138: method channel for native clipboard image/file reads. Flutter's
|
||||
// built-in clipboard is text-only; the composer turns a pasted file or
|
||||
// image into a Claude `@path` reference, which needs the non-text
|
||||
// clipboard targets read here.
|
||||
g_autoptr(FlStandardMethodCodec) clip_codec = fl_standard_method_codec_new();
|
||||
FlMethodChannel* clip_channel = fl_method_channel_new(
|
||||
fl_engine_get_binary_messenger(engine), "clide/clipboard",
|
||||
FL_METHOD_CODEC(clip_codec));
|
||||
g_object_set_data(G_OBJECT(window), "clide_clipboard_channel", clip_channel);
|
||||
fl_method_channel_set_method_call_handler(
|
||||
clip_channel,
|
||||
[](FlMethodChannel* channel, FlMethodCall* method_call,
|
||||
gpointer user_data) {
|
||||
const gchar* method = fl_method_call_get_name(method_call);
|
||||
g_autoptr(FlMethodResponse) response = nullptr;
|
||||
GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
|
||||
|
||||
if (g_strcmp0(method, "readImage") == 0) {
|
||||
GdkPixbuf* pixbuf = gtk_clipboard_wait_for_image(clipboard);
|
||||
if (pixbuf != nullptr) {
|
||||
gchar* buffer = nullptr;
|
||||
gsize buffer_size = 0;
|
||||
g_autoptr(GError) error = nullptr;
|
||||
if (gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "png",
|
||||
&error, nullptr)) {
|
||||
g_autoptr(FlValue) val =
|
||||
fl_value_new_uint8_list((const uint8_t*)buffer, buffer_size);
|
||||
response = FL_METHOD_RESPONSE(fl_method_success_response_new(val));
|
||||
g_free(buffer);
|
||||
} else {
|
||||
response = FL_METHOD_RESPONSE(
|
||||
fl_method_success_response_new(fl_value_new_null()));
|
||||
}
|
||||
g_object_unref(pixbuf);
|
||||
} else {
|
||||
response = FL_METHOD_RESPONSE(
|
||||
fl_method_success_response_new(fl_value_new_null()));
|
||||
}
|
||||
} else if (g_strcmp0(method, "readFiles") == 0) {
|
||||
g_autoptr(FlValue) list = fl_value_new_list();
|
||||
gchar** uris = gtk_clipboard_wait_for_uris(clipboard);
|
||||
if (uris != nullptr) {
|
||||
for (int i = 0; uris[i] != nullptr; i++) {
|
||||
g_autofree gchar* path =
|
||||
g_filename_from_uri(uris[i], nullptr, nullptr);
|
||||
if (path != nullptr) {
|
||||
fl_value_append_take(list, fl_value_new_string(path));
|
||||
}
|
||||
}
|
||||
g_strfreev(uris);
|
||||
}
|
||||
response =
|
||||
FL_METHOD_RESPONSE(fl_method_success_response_new(list));
|
||||
} else {
|
||||
response =
|
||||
FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
|
||||
}
|
||||
|
||||
fl_method_call_respond(method_call, response, nullptr);
|
||||
},
|
||||
window, nullptr);
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
|
||||
@@ -64,5 +64,35 @@ class AppDelegate: FlutterAppDelegate {
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// T-138: native clipboard image/file reads. Flutter's built-in
|
||||
// clipboard is text-only; the composer turns a pasted file or image
|
||||
// into a Claude `@path` reference, which needs the non-text pasteboard
|
||||
// contents read here.
|
||||
let clipboardChannel = FlutterMethodChannel(
|
||||
name: "clide/clipboard",
|
||||
binaryMessenger: flutterVC.engine.binaryMessenger)
|
||||
|
||||
clipboardChannel.setMethodCallHandler { (call, result) in
|
||||
let pasteboard = NSPasteboard.general
|
||||
switch call.method {
|
||||
case "readImage":
|
||||
if let image = NSImage(pasteboard: pasteboard),
|
||||
let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:]) {
|
||||
result(FlutterStandardTypedData(bytes: png))
|
||||
} else {
|
||||
result(nil)
|
||||
}
|
||||
case "readFiles":
|
||||
let urls = pasteboard.readObjects(
|
||||
forClasses: [NSURL.self],
|
||||
options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
|
||||
result(urls.map { $0.path })
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/// Tests for the native Claude input composer (T-138): Enter submits,
|
||||
/// Shift+Enter does not, blank input is ignored, and submitted text is
|
||||
/// PTY-encoded for `pane.write`.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_composer.dart';
|
||||
import 'package:clide/builtin/claude/src/clipboard_paste.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
group('encodeClaudeInput', () {
|
||||
test('single-line input gets a trailing carriage return', () {
|
||||
expect(encodeClaudeInput('hello claude'), 'hello claude\r');
|
||||
});
|
||||
|
||||
test('multi-line input is wrapped in bracketed-paste markers', () {
|
||||
expect(
|
||||
encodeClaudeInput('line one\nline two'),
|
||||
'\x1b[200~line one\nline two\x1b[201~\r',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClaudeComposer', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<List<String>> pump(WidgetTester tester, {bool enabled = true}) async {
|
||||
final submitted = <String>[];
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClaudeComposer(enabled: enabled, onSubmit: submitted.add),
|
||||
));
|
||||
return submitted;
|
||||
}
|
||||
|
||||
testWidgets('Enter submits the text and clears the field', (tester) async {
|
||||
final submitted = await pump(tester);
|
||||
await tester.enterText(find.byType(EditableText), 'hey there');
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(submitted, ['hey there']);
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('Shift+Enter does not submit', (tester) async {
|
||||
final submitted = await pump(tester);
|
||||
await tester.enterText(find.byType(EditableText), 'draft');
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
||||
await tester.pump();
|
||||
|
||||
expect(submitted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('blank input is ignored', (tester) async {
|
||||
final submitted = await pump(tester);
|
||||
await tester.enterText(find.byType(EditableText), ' ');
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(submitted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('disabled composer is read-only', (tester) async {
|
||||
await pump(tester, enabled: false);
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).readOnly, isTrue);
|
||||
});
|
||||
|
||||
Future<void> pasteAttachment(WidgetTester tester) async {
|
||||
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Ctrl+V of a file shows a chip and leaves the text field empty', (tester) async {
|
||||
// WidgetsApp provides DefaultTextEditingShortcuts in the real app;
|
||||
// the bare harness doesn't, so wrap explicitly to map Ctrl+V ->
|
||||
// PasteTextIntent, which the composer's Actions override intercepts.
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
DefaultTextEditingShortcuts(
|
||||
child: ClaudeComposer(
|
||||
onSubmit: (_) {},
|
||||
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
|
||||
),
|
||||
),
|
||||
));
|
||||
await pasteAttachment(tester);
|
||||
|
||||
expect(find.text('notes.txt'), findsOneWidget);
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('submit appends attachment @path tokens to the message', (tester) async {
|
||||
final submitted = <String>[];
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
DefaultTextEditingShortcuts(
|
||||
child: ClaudeComposer(
|
||||
onSubmit: submitted.add,
|
||||
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.enterText(find.byType(EditableText), 'look at this');
|
||||
await pasteAttachment(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(submitted, ['look at this @/tmp/notes.txt']);
|
||||
// Chip cleared after send.
|
||||
expect(find.text('notes.txt'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('remove × cancels the attachment before send', (tester) async {
|
||||
final submitted = <String>[];
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
DefaultTextEditingShortcuts(
|
||||
child: ClaudeComposer(
|
||||
onSubmit: submitted.add,
|
||||
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
|
||||
),
|
||||
),
|
||||
));
|
||||
await pasteAttachment(tester);
|
||||
expect(find.text('notes.txt'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('composer-remove-/tmp/notes.txt')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('notes.txt'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(EditableText), 'just text');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, ['just text']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/// Tests for clipboard paste resolution (T-138): files become `@path`
|
||||
/// tokens, a raw image is written to a temp file and referenced by
|
||||
/// `@path`, and an empty clipboard falls back to text (null). Also
|
||||
/// covers the NativeClipboard channel client's decode + missing-handler
|
||||
/// guard.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/clipboard_paste.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class _FakeSource implements ClipboardSource {
|
||||
_FakeSource({this.files = const [], this.image});
|
||||
final List<String> files;
|
||||
final Uint8List? image;
|
||||
@override
|
||||
Future<List<String>> readFiles() async => files;
|
||||
@override
|
||||
Future<Uint8List?> readImage() async => image;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('resolveClipboardAttachment', () {
|
||||
test('files become attachments with isImage by extension', () async {
|
||||
final source = _FakeSource(files: ['/home/u/a.txt', '/home/u/b.png']);
|
||||
final result = await resolveClipboardAttachment(source);
|
||||
expect(result.map((a) => a.path), ['/home/u/a.txt', '/home/u/b.png']);
|
||||
expect(result.map((a) => a.isImage), [false, true]);
|
||||
expect(result.map((a) => a.pathToken), ['@/home/u/a.txt', '@/home/u/b.png']);
|
||||
});
|
||||
|
||||
test('a raw image is written to a temp file and attached', () async {
|
||||
final dir = await Directory.systemTemp.createTemp('clide-paste-test-');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final bytes = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
|
||||
final fixedNow = DateTime.fromMillisecondsSinceEpoch(1700000000000);
|
||||
|
||||
final result = await resolveClipboardAttachment(
|
||||
_FakeSource(image: bytes),
|
||||
tempDir: dir,
|
||||
now: () => fixedNow,
|
||||
);
|
||||
|
||||
final expectedPath = '${dir.path}/paste-1700000000000.png';
|
||||
expect(result, hasLength(1));
|
||||
expect(result.single.isImage, isTrue);
|
||||
expect(result.single.path, expectedPath);
|
||||
expect(await File(expectedPath).readAsBytes(), bytes);
|
||||
});
|
||||
|
||||
test('files take precedence over an image', () async {
|
||||
final result = await resolveClipboardAttachment(
|
||||
_FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])),
|
||||
);
|
||||
expect(result.map((a) => a.path), ['/x/y']);
|
||||
});
|
||||
|
||||
test('empty clipboard returns no attachments (fall back to text)', () async {
|
||||
expect(await resolveClipboardAttachment(_FakeSource()), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ComposerAttachment', () {
|
||||
test('fileName is the last path segment', () {
|
||||
expect(const ComposerAttachment(path: '/a/b/c.png', isImage: true).fileName, 'c.png');
|
||||
expect(const ComposerAttachment(path: 'bare.txt', isImage: false).fileName, 'bare.txt');
|
||||
});
|
||||
});
|
||||
|
||||
group('NativeClipboard', () {
|
||||
const channel = MethodChannel('clide/clipboard');
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
tearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
|
||||
test('readFiles decodes the native string list', () async {
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'readFiles');
|
||||
return <String>['/a/b', '/c/d'];
|
||||
});
|
||||
expect(await const NativeClipboard().readFiles(), ['/a/b', '/c/d']);
|
||||
});
|
||||
|
||||
test('readImage decodes native bytes', () async {
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4]);
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'readImage');
|
||||
return bytes;
|
||||
});
|
||||
expect(await const NativeClipboard().readImage(), bytes);
|
||||
});
|
||||
|
||||
test('missing platform handler degrades to empty / null', () async {
|
||||
// No mock handler registered -> MissingPluginException, swallowed.
|
||||
expect(await const NativeClipboard().readFiles(), isEmpty);
|
||||
expect(await const NativeClipboard().readImage(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/// Widget tests for the native Claude ConversationView + controller
|
||||
/// (T-137): renders each transcript item kind as a card, and text
|
||||
/// selects + copies across cards via ClideSelectionArea (the terminal
|
||||
/// affordance we keep, T-135).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_view.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
final _t = DateTime.utc(2026, 1, 1);
|
||||
|
||||
UserMessage _user(String text) => UserMessage(uuid: 'u', timestamp: _t, isSidechain: false, text: text);
|
||||
AssistantTextMessage _asst(String text) => AssistantTextMessage(uuid: 'a', timestamp: _t, isSidechain: false, text: text);
|
||||
AssistantThinkingMessage _think(String text) => AssistantThinkingMessage(uuid: 't', timestamp: _t, isSidechain: false, thinking: text);
|
||||
AssistantToolUse _tool(String name, Map<String, dynamic> input) =>
|
||||
AssistantToolUse(uuid: 'tu', timestamp: _t, isSidechain: false, toolUseId: 'x1', name: name, input: input);
|
||||
ToolResultMessage _result(String content, {bool isError = false}) =>
|
||||
ToolResultMessage(uuid: 'r', timestamp: _t, isSidechain: false, toolUseId: 'x1', content: content, isError: isError);
|
||||
|
||||
class _MockClipboard {
|
||||
Map<String, dynamic> _data = {'text': null};
|
||||
Future<Object?> handleMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'Clipboard.setData':
|
||||
_data = Map<String, dynamic>.from(call.arguments as Map);
|
||||
case 'Clipboard.getData':
|
||||
return _data;
|
||||
case 'Clipboard.hasStrings':
|
||||
final t = _data['text'] as String?;
|
||||
return {'value': t != null && t.isNotEmpty};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? get text => _data['text'] as String?;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ConversationController', () {
|
||||
test('accumulates items from the stream and notifies', () async {
|
||||
final ctrl = StreamController<ConversationItem>();
|
||||
final c = ConversationController(stream: ctrl.stream);
|
||||
addTearDown(c.dispose);
|
||||
var notifications = 0;
|
||||
c.addListener(() => notifications++);
|
||||
|
||||
expect(c.isEmpty, isTrue);
|
||||
ctrl.add(_user('hi'));
|
||||
ctrl.add(_asst('hello'));
|
||||
// Wait past the coalescing timer (zero-duration, fires after the
|
||||
// microtask queue drains).
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(c.items, hasLength(2));
|
||||
expect(c.items.first, isA<UserMessage>());
|
||||
// Notifications are coalesced: a burst of items collapses to a
|
||||
// single notify so the view rebuilds once, not per item.
|
||||
expect(notifications, 1);
|
||||
await ctrl.close();
|
||||
});
|
||||
|
||||
test('onDispose is invoked on dispose', () async {
|
||||
final ctrl = StreamController<ConversationItem>();
|
||||
var disposed = false;
|
||||
final c = ConversationController(stream: ctrl.stream, onDispose: () async => disposed = true);
|
||||
c.dispose();
|
||||
expect(disposed, isTrue);
|
||||
await ctrl.close();
|
||||
});
|
||||
});
|
||||
|
||||
group('ConversationController.fromBus', () {
|
||||
test('consumes items published on its publisher/channel', () async {
|
||||
final bus = MessageBus();
|
||||
addTearDown(bus.dispose);
|
||||
final c = ConversationController.fromBus(messages: bus);
|
||||
addTearDown(c.dispose);
|
||||
|
||||
bus.publish(ClaudeConversation.publisher, ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _user('hi')});
|
||||
bus.publish(ClaudeConversation.publisher, ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _asst('hello')});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(c.items, hasLength(2));
|
||||
expect(c.items.first, isA<UserMessage>());
|
||||
});
|
||||
|
||||
test('ignores other publishers and channels', () async {
|
||||
final bus = MessageBus();
|
||||
addTearDown(bus.dispose);
|
||||
final c = ConversationController.fromBus(messages: bus);
|
||||
addTearDown(c.dispose);
|
||||
|
||||
bus.publish('someone.else', ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _user('nope')});
|
||||
bus.publish(ClaudeConversation.publisher, 'conversation/other', {ClaudeConversation.itemKey: _user('nope')});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(c.items, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ConversationView', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
final stream = StreamController<ConversationItem>.broadcast();
|
||||
final c = ConversationController(stream: stream.stream);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(harness(f, ConversationView(controller: c)));
|
||||
for (final it in items) {
|
||||
stream.add(it);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
return c;
|
||||
}
|
||||
|
||||
testWidgets('empty controller shows the waiting hint', (tester) async {
|
||||
await pumpWith(tester, const []);
|
||||
expect(find.text('Waiting for Claude…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders a card per item kind with role/tool labels', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
_user('a question'),
|
||||
_asst('an answer'),
|
||||
_think('hmm'),
|
||||
_tool('Bash', {'command': 'ls'}),
|
||||
_result('ok'),
|
||||
_result('boom', isError: true),
|
||||
]);
|
||||
expect(find.text('you'), findsOneWidget);
|
||||
expect(find.text('claude'), findsOneWidget);
|
||||
expect(find.text('thinking'), findsOneWidget);
|
||||
expect(find.text('Bash'), findsOneWidget);
|
||||
expect(find.text('result'), findsOneWidget);
|
||||
expect(find.text('error'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('select-all + copy spans multiple cards', (tester) async {
|
||||
final clipboard = _MockClipboard();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, clipboard.handleMethodCall);
|
||||
addTearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||
|
||||
await pumpWith(tester, [_user('question text'), _asst('answer text')]);
|
||||
|
||||
// Focus the selection region, select all, copy.
|
||||
final region = find.byType(ClideSelectionArea);
|
||||
expect(region, findsOneWidget);
|
||||
await tester.tap(region);
|
||||
await tester.pump();
|
||||
Future<void> keys(LogicalKeyboardKey k) async {
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
|
||||
await tester.sendKeyDownEvent(k);
|
||||
await tester.sendKeyUpEvent(k);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
await keys(LogicalKeyboardKey.keyA);
|
||||
await keys(LogicalKeyboardKey.keyC);
|
||||
await tester.pump();
|
||||
|
||||
final copied = clipboard.text ?? '';
|
||||
expect(copied, contains('question text'));
|
||||
expect(copied, contains('answer text'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -23,5 +23,57 @@ 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));
|
||||
});
|
||||
});
|
||||
|
||||
group('claude session ids (T-146)', () {
|
||||
final uuidRe = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
|
||||
|
||||
test('primary id is a valid v4-format UUID', () {
|
||||
expect(primarySessionId('/home/me/clide'), matches(uuidRe));
|
||||
});
|
||||
|
||||
test('primary id is deterministic per repo (resumes across restarts)', () {
|
||||
expect(primarySessionId('/home/me/clide'), primarySessionId('/home/me/clide'));
|
||||
});
|
||||
|
||||
test('different repos get different primary ids', () {
|
||||
expect(primarySessionId('/home/me/clide'), isNot(primarySessionId('/home/me/other')));
|
||||
});
|
||||
|
||||
test('fresh ids are valid UUIDs and unique per call (clean secondaries)', () {
|
||||
final a = freshSessionId();
|
||||
final b = freshSessionId();
|
||||
expect(a, matches(uuidRe));
|
||||
expect(b, matches(uuidRe));
|
||||
expect(a, isNot(b));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/// Tests for the tmux team observer (T-139). Pure Dart (no Flutter):
|
||||
/// config parsing/discovery, the config-driven joined/left lifecycle, and
|
||||
/// the best-effort subagent-transcript join — all exercised against
|
||||
/// on-disk fixtures, mirroring how the T-134 spike validated CC's
|
||||
/// undocumented team artifacts.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/team_observer.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _ws = '/work/space';
|
||||
|
||||
String _configJson({
|
||||
String team = 'myteam',
|
||||
int createdAt = 1000,
|
||||
String leadSessionId = 'sid-1',
|
||||
String cwd = _ws,
|
||||
List<Map<String, dynamic>> teammates = const [],
|
||||
}) {
|
||||
return jsonEncode({
|
||||
'name': team,
|
||||
'createdAt': createdAt,
|
||||
'leadSessionId': leadSessionId,
|
||||
'members': [
|
||||
{'agentId': 'team-lead@$team', 'name': 'team-lead', 'agentType': 'team-lead', 'tmuxPaneId': '', 'cwd': cwd, 'joinedAt': 1},
|
||||
...teammates,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _member(String name, String pane, {String? type, int joinedAt = 2, String cwd = _ws}) => {
|
||||
'agentId': '$name@myteam',
|
||||
'name': name,
|
||||
'agentType': type ?? name,
|
||||
'tmuxPaneId': pane,
|
||||
'model': 'sonnet',
|
||||
'color': 'blue',
|
||||
'cwd': cwd,
|
||||
'joinedAt': joinedAt,
|
||||
};
|
||||
|
||||
Future<Directory> _writeTeam(Directory teamsBase, String team, String json) async {
|
||||
final dir = Directory('${teamsBase.path}/$team');
|
||||
await dir.create(recursive: true);
|
||||
await File('${dir.path}/config.json').writeAsString(json);
|
||||
return dir;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('team events', () {
|
||||
test('TeamMemberJoined payload carries identity + optional fields', () {
|
||||
const e = TeamMemberJoined(
|
||||
team: 'myteam',
|
||||
agentId: 'alice@myteam',
|
||||
name: 'alice',
|
||||
agentType: 'researcher',
|
||||
paneId: '%5',
|
||||
model: 'sonnet',
|
||||
color: 'blue',
|
||||
cwd: '/work/space',
|
||||
transcriptPath: '/t/agent-a.jsonl',
|
||||
);
|
||||
expect(e.subsystem, 'team');
|
||||
expect(e.kind, 'member-joined');
|
||||
expect(e.payload(), {
|
||||
'team': 'myteam',
|
||||
'agentId': 'alice@myteam',
|
||||
'name': 'alice',
|
||||
'agentType': 'researcher',
|
||||
'paneId': '%5',
|
||||
'model': 'sonnet',
|
||||
'color': 'blue',
|
||||
'cwd': '/work/space',
|
||||
'transcriptPath': '/t/agent-a.jsonl',
|
||||
});
|
||||
});
|
||||
|
||||
test('TeamMemberJoined omits null optional fields', () {
|
||||
const e = TeamMemberJoined(team: 't', agentId: 'a@t', name: 'a', agentType: 'a', paneId: '%1');
|
||||
expect(e.payload().keys, ['team', 'agentId', 'name', 'agentType', 'paneId']);
|
||||
});
|
||||
|
||||
test('TeamMemberLeft payload', () {
|
||||
const e = TeamMemberLeft(team: 't', agentId: 'a@t', paneId: '%1');
|
||||
expect(e.subsystem, 'team');
|
||||
expect(e.kind, 'member-left');
|
||||
expect(e.payload(), {'team': 't', 'agentId': 'a@t', 'paneId': '%1'});
|
||||
});
|
||||
});
|
||||
|
||||
group('TeamConfig.parse', () {
|
||||
test('parses members and detects the lead', () {
|
||||
final cfg = TeamConfig.parse('myteam', _configJson(teammates: [_member('alice', '%5')]))!;
|
||||
expect(cfg.team, 'myteam');
|
||||
expect(cfg.leadSessionId, 'sid-1');
|
||||
expect(cfg.members, hasLength(2));
|
||||
expect(cfg.teammates.map((m) => m.name), ['alice']);
|
||||
final lead = cfg.members.firstWhere((m) => m.isLead);
|
||||
expect(lead.name, 'team-lead');
|
||||
final alice = cfg.teammates.single;
|
||||
expect(alice.tmuxPaneId, '%5');
|
||||
expect(alice.model, 'sonnet');
|
||||
expect(alice.isLead, isFalse);
|
||||
});
|
||||
|
||||
test('returns null on malformed JSON', () {
|
||||
expect(TeamConfig.parse('x', 'not json'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('discoverTeam', () {
|
||||
late Directory teamsBase;
|
||||
setUp(() async => teamsBase = await Directory.systemTemp.createTemp('teams_'));
|
||||
tearDown(() async => teamsBase.delete(recursive: true));
|
||||
|
||||
test('finds the team whose member cwd matches the workspace', () async {
|
||||
await _writeTeam(teamsBase, 'other', _configJson(team: 'other', cwd: '/elsewhere', teammates: [_member('bob', '%9', cwd: '/elsewhere')]));
|
||||
await _writeTeam(teamsBase, 'mine', _configJson(team: 'mine', teammates: [_member('alice', '%5')]));
|
||||
final cfg = await discoverTeam(_ws, teamsBase: teamsBase.path);
|
||||
expect(cfg, isNotNull);
|
||||
expect(cfg!.team, 'mine');
|
||||
});
|
||||
|
||||
test('prefers the newest createdAt when several match', () async {
|
||||
await _writeTeam(teamsBase, 'old', _configJson(team: 'old', createdAt: 100, teammates: [_member('a', '%1')]));
|
||||
await _writeTeam(teamsBase, 'new', _configJson(team: 'new', createdAt: 999, teammates: [_member('b', '%2')]));
|
||||
final cfg = await discoverTeam(_ws, teamsBase: teamsBase.path);
|
||||
expect(cfg!.team, 'new');
|
||||
});
|
||||
|
||||
test('returns null when nothing matches', () async {
|
||||
await _writeTeam(teamsBase, 'other', _configJson(team: 'other', cwd: '/elsewhere', teammates: [_member('bob', '%9', cwd: '/elsewhere')]));
|
||||
expect(await discoverTeam(_ws, teamsBase: teamsBase.path), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('TeamObserver lifecycle', () {
|
||||
late Directory teamsBase;
|
||||
late Directory projectsBase;
|
||||
late DaemonBus events;
|
||||
late MessageBus messages;
|
||||
late List<TeamMemberJoined> joined;
|
||||
late List<TeamMemberLeft> left;
|
||||
|
||||
setUp(() async {
|
||||
teamsBase = await Directory.systemTemp.createTemp('teams_');
|
||||
projectsBase = await Directory.systemTemp.createTemp('projects_');
|
||||
events = DaemonBus();
|
||||
messages = MessageBus();
|
||||
joined = [];
|
||||
left = [];
|
||||
events.on<TeamMemberJoined>().listen(joined.add);
|
||||
events.on<TeamMemberLeft>().listen(left.add);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await teamsBase.delete(recursive: true);
|
||||
await projectsBase.delete(recursive: true);
|
||||
await events.dispose();
|
||||
messages.dispose();
|
||||
});
|
||||
|
||||
Future<void> settle() => Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
test('emits joined when a teammate pane is live, left when it goes', () async {
|
||||
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5')]));
|
||||
var panes = {'%5'};
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => panes,
|
||||
);
|
||||
addTearDown(obs.dispose);
|
||||
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(joined.map((b) => b.name), ['alice']);
|
||||
expect(joined.single.paneId, '%5');
|
||||
expect(joined.single.agentId, 'alice@myteam');
|
||||
expect(left, isEmpty);
|
||||
|
||||
// Same pane still live -> no duplicate joined.
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(joined, hasLength(1));
|
||||
|
||||
// Pane gone -> left.
|
||||
panes = {};
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(left.map((d) => d.agentId), ['alice@myteam']);
|
||||
});
|
||||
|
||||
test('start() polls on a timer and dispose() stops it', () async {
|
||||
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5')]));
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => {'%5'},
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
obs.start();
|
||||
// Poll until the timer-driven tick emits joined (or time out).
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 2));
|
||||
while (joined.isEmpty && DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
expect(joined.map((b) => b.name), ['alice']);
|
||||
await obs.dispose();
|
||||
// dispose emits left for the tracked member.
|
||||
await settle();
|
||||
expect(left.map((d) => d.agentId), ['alice@myteam']);
|
||||
});
|
||||
|
||||
test('constructs with default base dirs / pane lister', () {
|
||||
// Exercises the default resolvers; not started, so nothing shells out.
|
||||
final obs = TeamObserver(workspacePath: _ws, events: events, messages: messages);
|
||||
expect(obs.workspacePath, _ws);
|
||||
});
|
||||
|
||||
test('no team config -> no events', () async {
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => {'%5'},
|
||||
);
|
||||
addTearDown(obs.dispose);
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(joined, isEmpty);
|
||||
expect(left, isEmpty);
|
||||
});
|
||||
|
||||
test('joins the teammate transcript via a matching .meta.json', () async {
|
||||
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5', type: 'researcher')]));
|
||||
// <projectsBase>/<munged cwd>/<leadSessionId>/subagents/agent-*.jsonl
|
||||
final sub = Directory('${projectsBase.path}/${_ws.replaceAll('/', '-')}/sid-1/subagents');
|
||||
await sub.create(recursive: true);
|
||||
await File('${sub.path}/agent-aaa111.jsonl').writeAsString('');
|
||||
await File('${sub.path}/agent-aaa111.meta.json').writeAsString(jsonEncode({'agentType': 'researcher'}));
|
||||
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => {'%5'},
|
||||
);
|
||||
addTearDown(obs.dispose);
|
||||
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(joined.single.transcriptPath, endsWith('agent-aaa111.jsonl'));
|
||||
});
|
||||
|
||||
test('falls back to joinedAt<->mtime order when no .meta.json', () async {
|
||||
await _writeTeam(
|
||||
teamsBase,
|
||||
'myteam',
|
||||
_configJson(teammates: [
|
||||
_member('first', '%5', joinedAt: 10),
|
||||
_member('second', '%6', joinedAt: 20),
|
||||
]),
|
||||
);
|
||||
final sub = Directory('${projectsBase.path}/${_ws.replaceAll('/', '-')}/sid-1/subagents');
|
||||
await sub.create(recursive: true);
|
||||
// Older file first (earlier mtime) -> maps to the earlier-joined member.
|
||||
final older = File('${sub.path}/agent-older.jsonl');
|
||||
await older.writeAsString('');
|
||||
await older.setLastModified(DateTime(2026, 1, 1));
|
||||
final newer = File('${sub.path}/agent-newer.jsonl');
|
||||
await newer.writeAsString('');
|
||||
await newer.setLastModified(DateTime(2026, 2, 1));
|
||||
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => {'%5', '%6'},
|
||||
);
|
||||
addTearDown(obs.dispose);
|
||||
|
||||
await obs.tick();
|
||||
await settle();
|
||||
final byName = {for (final b in joined) b.name: b.transcriptPath};
|
||||
expect(byName['first'], endsWith('agent-older.jsonl'));
|
||||
expect(byName['second'], endsWith('agent-newer.jsonl'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/// Widget tests for the teammate tile grid (T-140): tiles appear on
|
||||
/// TeamMemberJoined, disappear on TeamMemberLeft, and the lead shows
|
||||
/// alone when there's no team. Events are emitted directly into the
|
||||
/// fixture's event bus (the observer is exercised separately in
|
||||
/// team_observer_test.dart).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/conversation_view.dart';
|
||||
import 'package:clide/builtin/claude/src/team_panel_host.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
Widget _lead() => const Center(child: Text('LEAD', textDirection: TextDirection.ltr));
|
||||
|
||||
TeamMemberJoined _joined(String name, String pane, {String? color}) => TeamMemberJoined(
|
||||
team: 'myteam',
|
||||
agentId: '$name@myteam',
|
||||
name: name,
|
||||
agentType: 'researcher',
|
||||
paneId: pane,
|
||||
model: 'sonnet',
|
||||
color: color,
|
||||
);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<void> pumpHost(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 700);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
await tester.pumpWidget(harness(f, TeamPanelHost(lead: _lead())));
|
||||
}
|
||||
|
||||
Future<void> emit(WidgetTester tester, ClideEvent e) async {
|
||||
f.services.events.emit(e);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('lead shows alone with no team', (tester) async {
|
||||
await pumpHost(tester);
|
||||
expect(find.text('LEAD'), findsOneWidget);
|
||||
expect(find.byType(ConversationView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a teammate joining adds a tile; leaving removes it', (tester) async {
|
||||
await pumpHost(tester);
|
||||
|
||||
await emit(tester, _joined('alice', '%5', color: 'blue'));
|
||||
expect(find.text('alice'), findsOneWidget);
|
||||
expect(find.byType(ConversationView), findsOneWidget);
|
||||
expect(find.text('LEAD'), findsOneWidget); // lead still present beside the grid
|
||||
|
||||
await emit(tester, const TeamMemberLeft(team: 'myteam', agentId: 'alice@myteam', paneId: '%5'));
|
||||
expect(find.text('alice'), findsNothing);
|
||||
expect(find.byType(ConversationView), findsNothing);
|
||||
expect(find.text('LEAD'), findsOneWidget); // back to lead-only
|
||||
});
|
||||
|
||||
testWidgets('duplicate join is ignored', (tester) async {
|
||||
await pumpHost(tester);
|
||||
await emit(tester, _joined('alice', '%5'));
|
||||
await emit(tester, _joined('alice', '%5'));
|
||||
expect(find.text('alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders a tile per member as the team grows', (tester) async {
|
||||
await pumpHost(tester);
|
||||
await emit(tester, _joined('alice', '%5'));
|
||||
await emit(tester, _joined('bob', '%6'));
|
||||
await emit(tester, _joined('carol', '%7'));
|
||||
await emit(tester, _joined('dave', '%8'));
|
||||
for (final name in ['alice', 'bob', 'carol', 'dave']) {
|
||||
expect(find.text(name), findsOneWidget);
|
||||
}
|
||||
expect(find.byType(ConversationView), findsNWidgets(4));
|
||||
});
|
||||
|
||||
group('teamColor', () {
|
||||
test('maps known names and falls back', () {
|
||||
const fallback = Color(0xFF000000);
|
||||
expect(teamColor('blue', fallback: fallback), const Color(0xFF61AFEF));
|
||||
expect(teamColor('orange', fallback: fallback), const Color(0xFFD97757));
|
||||
expect(teamColor('purple', fallback: fallback), teamColor('magenta', fallback: fallback));
|
||||
expect(teamColor(null, fallback: fallback), fallback);
|
||||
expect(teamColor('chartreuse', fallback: fallback), fallback);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -103,4 +103,15 @@ void main() {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('sendMessage', () {
|
||||
test('loads a bracketed paste buffer then submits with Enter', () async {
|
||||
await tmux.sendMessage('clide-claude-foo', 'hello\nworld');
|
||||
expect(runner.calls, [
|
||||
['-L', 'clide', 'set-buffer', '-b', 'clide-compose', '--', 'hello\nworld'],
|
||||
['-L', 'clide', 'paste-buffer', '-p', '-d', '-b', 'clide-compose', '-t', 'clide-claude-foo'],
|
||||
['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', 'Enter'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/// Tests for TranscriptPublisher — bridges a TranscriptReader onto the
|
||||
/// kernel MessageBus (T-137/D-75). Pure Dart: MessageBus + reader have no
|
||||
/// Flutter dependency, so this runs under `package:test`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
Map<String, dynamic> _userLine(String uuid, String text) => {
|
||||
'type': 'user',
|
||||
'uuid': uuid,
|
||||
'parentUuid': '',
|
||||
'isSidechain': false,
|
||||
'version': '2.1.143',
|
||||
'timestamp': '2026-05-16T08:53:06.708Z',
|
||||
'message': {'role': 'user', 'content': text},
|
||||
};
|
||||
|
||||
Map<String, dynamic> _asstLine(String uuid, String text) => {
|
||||
'type': 'assistant',
|
||||
'uuid': uuid,
|
||||
'parentUuid': '',
|
||||
'isSidechain': false,
|
||||
'version': '2.1.143',
|
||||
'timestamp': '2026-05-16T08:53:07.708Z',
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'text', 'text': text}
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('TranscriptPublisher', () {
|
||||
late Directory base;
|
||||
const workspace = '/pub/ws';
|
||||
|
||||
setUp(() async => base = await Directory.systemTemp.createTemp('transcript_publisher_test_'));
|
||||
tearDown(() async => base.delete(recursive: true));
|
||||
|
||||
test('republishes reader items onto the bus (lead channel + item key)', () async {
|
||||
final dir = Directory('${base.path}/${workspace.replaceAll('/', '-')}');
|
||||
await dir.create(recursive: true);
|
||||
File('${dir.path}/session-abc.jsonl').writeAsStringSync(
|
||||
'${[_userLine('u1', 'hello'), _asstLine('a1', 'hi there')].map(jsonEncode).join('\n')}\n',
|
||||
);
|
||||
|
||||
final bus = MessageBus();
|
||||
addTearDown(bus.dispose);
|
||||
final received = <Message>[];
|
||||
// Subscribe before the publisher starts the reader's first poll.
|
||||
final sub = bus.subscribe(publisher: ClaudeConversation.publisher, channel: ClaudeConversation.leadChannel).listen(received.add);
|
||||
|
||||
final reader = TranscriptReader(
|
||||
workspace,
|
||||
projectsBase: base.path,
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
final pub = TranscriptPublisher(messages: bus, reader: reader);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await sub.cancel();
|
||||
await pub.dispose();
|
||||
|
||||
expect(received, hasLength(2));
|
||||
expect(received.every((m) => m.data[ClaudeConversation.itemKey] is ConversationItem), isTrue);
|
||||
final items = received.map((m) => m.data[ClaudeConversation.itemKey]).toList();
|
||||
expect(items.first, isA<UserMessage>());
|
||||
expect((items.first as UserMessage).text, 'hello');
|
||||
expect(items[1], isA<AssistantTextMessage>());
|
||||
});
|
||||
|
||||
test('teammateChannel namespaces by agentId', () {
|
||||
expect(ClaudeConversation.teammateChannel('coder@team-x'), 'conversation/coder@team-x');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
/// Tests for TranscriptReader (pure-Dart, no Flutter).
|
||||
///
|
||||
/// Uses a snapshotted JSONL fixture written to a temp directory so tests are
|
||||
/// hermetic and never depend on real Claude transcript files.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Write [lines] to [file], each JSON-encoded, newline-terminated.
|
||||
void writeLines(File file, List<Map<String, dynamic>> lines) {
|
||||
file.writeAsStringSync(
|
||||
'${lines.map(jsonEncode).join('\n')}\n',
|
||||
mode: FileMode.writeOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/// Append [lines] to [file].
|
||||
void appendLines(File file, List<Map<String, dynamic>> lines) {
|
||||
file.writeAsStringSync(
|
||||
'${lines.map(jsonEncode).join('\n')}\n',
|
||||
mode: FileMode.append,
|
||||
);
|
||||
}
|
||||
|
||||
/// Poll [ready] until it returns true or [timeout] elapses. Streaming
|
||||
/// assertions use this instead of a fixed delay so they don't flake under
|
||||
/// load (the reader polls on a timer and may parse off-isolate).
|
||||
Future<void> pumpUntil(
|
||||
bool Function() ready, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (!ready() && DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
/// JSONL envelope skeleton with default sentinel values.
|
||||
Map<String, dynamic> envelope({
|
||||
required String type,
|
||||
required String uuid,
|
||||
Map<String, dynamic>? message,
|
||||
String parentUuid = '',
|
||||
bool isSidechain = false,
|
||||
String version = '2.1.143',
|
||||
String timestamp = '2026-05-16T08:53:06.708Z',
|
||||
}) {
|
||||
return {
|
||||
'type': type,
|
||||
'uuid': uuid,
|
||||
'parentUuid': parentUuid,
|
||||
'isSidechain': isSidechain,
|
||||
'version': version,
|
||||
'timestamp': timestamp,
|
||||
if (message != null) 'message': message,
|
||||
};
|
||||
}
|
||||
|
||||
/// Build a `user` envelope whose content is a plain string.
|
||||
Map<String, dynamic> userText(String uuid, String text) {
|
||||
return envelope(
|
||||
type: 'user',
|
||||
uuid: uuid,
|
||||
message: {'role': 'user', 'content': text},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a `user` envelope whose content is an array of text parts.
|
||||
Map<String, dynamic> userTextArray(String uuid, List<String> parts) {
|
||||
return envelope(
|
||||
type: 'user',
|
||||
uuid: uuid,
|
||||
message: {
|
||||
'role': 'user',
|
||||
'content': [
|
||||
for (final p in parts) {'type': 'text', 'text': p}
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a `user` envelope containing a tool_result.
|
||||
Map<String, dynamic> userToolResult(
|
||||
String uuid, {
|
||||
required String toolUseId,
|
||||
required String content,
|
||||
bool isError = false,
|
||||
}) {
|
||||
return envelope(
|
||||
type: 'user',
|
||||
uuid: uuid,
|
||||
message: {
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{
|
||||
'type': 'tool_result',
|
||||
'tool_use_id': toolUseId,
|
||||
'content': content,
|
||||
'is_error': isError,
|
||||
}
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an `assistant` envelope with a tool_use content block.
|
||||
Map<String, dynamic> assistantToolUse(
|
||||
String uuid, {
|
||||
required String id,
|
||||
required String name,
|
||||
required Map<String, dynamic> input,
|
||||
}) {
|
||||
return envelope(
|
||||
type: 'assistant',
|
||||
uuid: uuid,
|
||||
message: {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'tool_use', 'id': id, 'name': name, 'input': input},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an `assistant` envelope with a text content block.
|
||||
Map<String, dynamic> assistantText(String uuid, String text) {
|
||||
return envelope(
|
||||
type: 'assistant',
|
||||
uuid: uuid,
|
||||
message: {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'text', 'text': text},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an `assistant` envelope with a thinking content block.
|
||||
Map<String, dynamic> assistantThinking(String uuid, String thinking) {
|
||||
return envelope(
|
||||
type: 'assistant',
|
||||
uuid: uuid,
|
||||
message: {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'thinking', 'thinking': thinking},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A skip-type record.
|
||||
Map<String, dynamic> skipRecord(String type, String uuid) {
|
||||
return {'type': type, 'uuid': uuid, 'timestamp': '2026-05-16T08:53:06.708Z'};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synchronous parse helper — uses the REAL TranscriptReader.parseLine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parses [lines] synchronously via the real [TranscriptReader.parseLine] and
|
||||
/// returns every emitted [ConversationItem].
|
||||
List<ConversationItem> parseAll(
|
||||
List<Map<String, dynamic>> lines, {
|
||||
void Function(String)? onWarn,
|
||||
}) {
|
||||
final reader = TranscriptReader('/fake', onWarn: onWarn);
|
||||
return [for (final l in lines) ...reader.parseLine(jsonEncode(l))];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
// -------------------------------------------------------------------------
|
||||
group('ConversationItem model', () {
|
||||
test('UserMessage holds text and metadata', () {
|
||||
final item = UserMessage(
|
||||
uuid: 'u1',
|
||||
timestamp: _epoch,
|
||||
isSidechain: false,
|
||||
text: 'hello',
|
||||
);
|
||||
expect(item.uuid, 'u1');
|
||||
expect(item.text, 'hello');
|
||||
expect(item.isSidechain, isFalse);
|
||||
expect(item.toString(), contains('UserMessage'));
|
||||
});
|
||||
|
||||
test('AssistantTextMessage holds text', () {
|
||||
final item = AssistantTextMessage(
|
||||
uuid: 'a1',
|
||||
timestamp: _epoch,
|
||||
isSidechain: false,
|
||||
text: 'world',
|
||||
);
|
||||
expect(item.text, 'world');
|
||||
});
|
||||
|
||||
test('AssistantThinkingMessage holds thinking', () {
|
||||
final item = AssistantThinkingMessage(
|
||||
uuid: 't1',
|
||||
timestamp: _epoch,
|
||||
isSidechain: false,
|
||||
thinking: 'pondering',
|
||||
);
|
||||
expect(item.thinking, 'pondering');
|
||||
});
|
||||
|
||||
test('AssistantToolUse holds name and input', () {
|
||||
final item = AssistantToolUse(
|
||||
uuid: 'tu1',
|
||||
timestamp: _epoch,
|
||||
isSidechain: false,
|
||||
toolUseId: 'toolu_001',
|
||||
name: 'Bash',
|
||||
input: const {'command': 'ls'},
|
||||
);
|
||||
expect(item.name, 'Bash');
|
||||
expect(item.input['command'], 'ls');
|
||||
expect(item.toString(), contains('Bash'));
|
||||
});
|
||||
|
||||
test('ToolResultMessage holds content and error flag', () {
|
||||
final item = ToolResultMessage(
|
||||
uuid: 'r1',
|
||||
timestamp: _epoch,
|
||||
isSidechain: false,
|
||||
toolUseId: 'toolu_001',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
);
|
||||
expect(item.content, 'ok');
|
||||
expect(item.isError, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('TranscriptReader — path munging', () {
|
||||
test('mungedDir replaces slashes with dashes and keeps leading dash', () {
|
||||
// The munging rule (path '/' -> '-') is verified directly here; the
|
||||
// real reader's directory resolution is exercised end-to-end by the
|
||||
// file-discovery / streaming tests below (via projectsBase).
|
||||
final munged = '/var/mnt/foo'.replaceAll('/', '-');
|
||||
expect(munged, '-var-mnt-foo');
|
||||
});
|
||||
|
||||
test('munged dir has leading dash for absolute paths', () {
|
||||
final munged = '/home/user/project'.replaceAll('/', '-');
|
||||
expect(munged, startsWith('-'));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('TranscriptReader — parse: skip types', () {
|
||||
test('skips attachment, system, last-prompt, permission-mode, file-history-snapshot, queue-operation', () {
|
||||
final skipTypes = [
|
||||
'attachment',
|
||||
'system',
|
||||
'last-prompt',
|
||||
'permission-mode',
|
||||
'file-history-snapshot',
|
||||
'queue-operation',
|
||||
];
|
||||
for (final t in skipTypes) {
|
||||
final items = parseAll([skipRecord(t, 'skip-$t')]);
|
||||
expect(items, isEmpty, reason: 'type "$t" should be skipped');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('TranscriptReader — parse: user messages', () {
|
||||
test('plain string content emits UserMessage', () {
|
||||
final items = parseAll([userText('u1', 'hello world')]);
|
||||
expect(items, hasLength(1));
|
||||
expect(items.first, isA<UserMessage>());
|
||||
expect((items.first as UserMessage).text, 'hello world');
|
||||
});
|
||||
|
||||
test('array content with text parts emits joined UserMessage', () {
|
||||
final items = parseAll([
|
||||
userTextArray('u2', ['foo', 'bar'])
|
||||
]);
|
||||
expect(items, hasLength(1));
|
||||
final msg = items.first as UserMessage;
|
||||
expect(msg.text, 'foo\nbar');
|
||||
});
|
||||
|
||||
test('array content with tool_result emits ToolResultMessage', () {
|
||||
final items = parseAll([
|
||||
userToolResult('u3', toolUseId: 'toolu_abc', content: '{"ok":true}'),
|
||||
]);
|
||||
expect(items, hasLength(1));
|
||||
final res = items.first as ToolResultMessage;
|
||||
expect(res.toolUseId, 'toolu_abc');
|
||||
expect(res.content, '{"ok":true}');
|
||||
expect(res.isError, isFalse);
|
||||
});
|
||||
|
||||
test('tool_result with is_error=true sets isError', () {
|
||||
final items = parseAll([
|
||||
userToolResult('u4', toolUseId: 'toolu_xyz', content: 'boom', isError: true),
|
||||
]);
|
||||
final res = items.first as ToolResultMessage;
|
||||
expect(res.isError, isTrue);
|
||||
});
|
||||
|
||||
test('mixed array: text + tool_result emits both', () {
|
||||
final raw = envelope(
|
||||
type: 'user',
|
||||
uuid: 'u5',
|
||||
message: {
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'see result'},
|
||||
{
|
||||
'type': 'tool_result',
|
||||
'tool_use_id': 'toolu_mixed',
|
||||
'content': 'done',
|
||||
'is_error': false,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
final items = parseAll([raw]);
|
||||
// ToolResultMessage emitted first (order of content array), then UserMessage.
|
||||
expect(items, hasLength(2));
|
||||
expect(items.whereType<ToolResultMessage>(), hasLength(1));
|
||||
expect(items.whereType<UserMessage>(), hasLength(1));
|
||||
});
|
||||
|
||||
test('empty string content emits nothing', () {
|
||||
final items = parseAll([userText('u6', '')]);
|
||||
expect(items, isEmpty);
|
||||
});
|
||||
|
||||
test('uuid and timestamp are preserved', () {
|
||||
final items = parseAll([userText('uuid-abc', 'hi')]);
|
||||
expect(items.first.uuid, 'uuid-abc');
|
||||
expect(items.first.timestamp, DateTime.parse('2026-05-16T08:53:06.708Z'));
|
||||
});
|
||||
|
||||
test('isSidechain flag is preserved', () {
|
||||
final raw = envelope(
|
||||
type: 'user',
|
||||
uuid: 'u7',
|
||||
isSidechain: true,
|
||||
message: {'role': 'user', 'content': 'side'},
|
||||
);
|
||||
final items = parseAll([raw]);
|
||||
expect(items.first.isSidechain, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('TranscriptReader — parse: assistant messages', () {
|
||||
test('text block emits AssistantTextMessage', () {
|
||||
final items = parseAll([assistantText('a1', 'here is the answer')]);
|
||||
expect(items, hasLength(1));
|
||||
final msg = items.first as AssistantTextMessage;
|
||||
expect(msg.text, 'here is the answer');
|
||||
});
|
||||
|
||||
test('thinking block emits AssistantThinkingMessage', () {
|
||||
final items = parseAll([assistantThinking('a2', 'I am thinking...')]);
|
||||
expect(items, hasLength(1));
|
||||
expect(items.first, isA<AssistantThinkingMessage>());
|
||||
});
|
||||
|
||||
test('empty thinking block is not emitted', () {
|
||||
final raw = envelope(
|
||||
type: 'assistant',
|
||||
uuid: 'a3',
|
||||
message: {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'thinking', 'thinking': ''},
|
||||
],
|
||||
},
|
||||
);
|
||||
final items = parseAll([raw]);
|
||||
expect(items, isEmpty);
|
||||
});
|
||||
|
||||
test('tool_use block emits AssistantToolUse', () {
|
||||
final items = parseAll([
|
||||
assistantToolUse(
|
||||
'a4',
|
||||
id: 'toolu_001',
|
||||
name: 'Bash',
|
||||
input: {'command': 'ls -la', 'description': 'List files'},
|
||||
),
|
||||
]);
|
||||
expect(items, hasLength(1));
|
||||
final tu = items.first as AssistantToolUse;
|
||||
expect(tu.name, 'Bash');
|
||||
expect(tu.toolUseId, 'toolu_001');
|
||||
expect(tu.input['command'], 'ls -la');
|
||||
});
|
||||
|
||||
test('mixed assistant turn emits multiple items in order', () {
|
||||
final raw = envelope(
|
||||
type: 'assistant',
|
||||
uuid: 'a5',
|
||||
message: {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'thinking', 'thinking': 'working...'},
|
||||
{'type': 'text', 'text': 'I will run a command'},
|
||||
{
|
||||
'type': 'tool_use',
|
||||
'id': 'toolu_002',
|
||||
'name': 'Read',
|
||||
'input': {'file_path': '/foo'}
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
final items = parseAll([raw]);
|
||||
expect(items, hasLength(3));
|
||||
expect(items[0], isA<AssistantThinkingMessage>());
|
||||
expect(items[1], isA<AssistantTextMessage>());
|
||||
expect(items[2], isA<AssistantToolUse>());
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('TranscriptReader — parse: snapshot fixture', () {
|
||||
/// Canonical fixture: assistant text + tool_use + tool_result + skip-type.
|
||||
/// Simulates a real Claude Code conversation turn.
|
||||
test('parses snapshot fixture into correct ConversationItems', () {
|
||||
final fixture = _snapshotFixture();
|
||||
final items = parseAll(fixture);
|
||||
|
||||
// Expected items from fixture (see _snapshotFixture):
|
||||
// 1. UserMessage (user turn)
|
||||
// 2. AssistantThinkingMessage (thinking block)
|
||||
// 3. AssistantToolUse (Bash call)
|
||||
// 4. ToolResultMessage (tool result)
|
||||
// 5. AssistantTextMessage (final reply)
|
||||
// Skip types are not counted.
|
||||
|
||||
expect(items, hasLength(5));
|
||||
expect(items[0], isA<UserMessage>());
|
||||
expect((items[0] as UserMessage).text, 'what files are here?');
|
||||
|
||||
expect(items[1], isA<AssistantThinkingMessage>());
|
||||
expect((items[1] as AssistantThinkingMessage).thinking, contains('need to list'));
|
||||
|
||||
expect(items[2], isA<AssistantToolUse>());
|
||||
final tu = items[2] as AssistantToolUse;
|
||||
expect(tu.name, 'Bash');
|
||||
expect(tu.input['command'], 'ls -la');
|
||||
|
||||
expect(items[3], isA<ToolResultMessage>());
|
||||
final tr = items[3] as ToolResultMessage;
|
||||
expect(tr.toolUseId, tu.toolUseId);
|
||||
expect(tr.content, contains('file.dart'));
|
||||
expect(tr.isError, isFalse);
|
||||
|
||||
expect(items[4], isA<AssistantTextMessage>());
|
||||
expect((items[4] as AssistantTextMessage).text, contains('file.dart'));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('TranscriptReader — version drift-guard', () {
|
||||
test('known versions (1.x, 2.x) produce no warning', () {
|
||||
final warnings = <String>[];
|
||||
parseAll(
|
||||
[userText('u1', 'hello'), assistantText('a1', 'world')],
|
||||
onWarn: warnings.add,
|
||||
);
|
||||
expect(warnings, isEmpty);
|
||||
});
|
||||
|
||||
test('unknown major version warns and still emits parseable items', () {
|
||||
final warnings = <String>[];
|
||||
final raw = envelope(
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
version: '99.0.1',
|
||||
message: {'role': 'user', 'content': 'future format'},
|
||||
);
|
||||
final items = parseAll([raw], onWarn: warnings.add);
|
||||
expect(warnings, hasLength(1));
|
||||
expect(warnings.first, contains('99'));
|
||||
expect(warnings.first, contains('degrade gracefully'));
|
||||
// The item still parses — degrade, don't crash.
|
||||
expect(items, hasLength(1));
|
||||
expect(items.first, isA<UserMessage>());
|
||||
});
|
||||
|
||||
test('multiple lines with unknown version warn once per line that has it', () {
|
||||
final warnings = <String>[];
|
||||
final lines = [
|
||||
envelope(type: 'user', uuid: 'u1', version: '5.0.0', message: {'role': 'user', 'content': 'a'}),
|
||||
envelope(type: 'user', uuid: 'u2', version: '5.0.0', message: {'role': 'user', 'content': 'b'}),
|
||||
];
|
||||
parseAll(lines, onWarn: warnings.add);
|
||||
expect(warnings, hasLength(2));
|
||||
});
|
||||
|
||||
test('missing version field does not warn', () {
|
||||
final warnings = <String>[];
|
||||
final raw = <String, dynamic>{
|
||||
'type': 'user',
|
||||
'uuid': 'u1',
|
||||
'isSidechain': false,
|
||||
'timestamp': '2026-05-16T08:53:06.708Z',
|
||||
'message': {'role': 'user', 'content': 'no version'},
|
||||
};
|
||||
parseAll([raw], onWarn: warnings.add);
|
||||
expect(warnings, isEmpty);
|
||||
});
|
||||
|
||||
test('malformed JSON line is skipped without throwing', () {
|
||||
expect(
|
||||
TranscriptReader('/fake').parseLine('not json!!!'),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('TranscriptReader — append streaming (filesystem)', () {
|
||||
late Directory tempBase;
|
||||
|
||||
/// The workspace path used in all streaming tests.
|
||||
const workspace = '/test/workspace';
|
||||
|
||||
/// Resolves the munged project dir inside [base] for [workspacePath].
|
||||
Directory mungedDir(Directory base, String workspacePath) {
|
||||
final munged = workspacePath.replaceAll('/', '-');
|
||||
return Directory('${base.path}/$munged');
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
tempBase = await Directory.systemTemp.createTemp('transcript_reader_test_');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await tempBase.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('initial lines are emitted on first poll', () async {
|
||||
final projectDir = mungedDir(tempBase, workspace);
|
||||
await projectDir.create(recursive: true);
|
||||
final sessionFile = File('${projectDir.path}/session-abc.jsonl');
|
||||
|
||||
writeLines(sessionFile, [
|
||||
userText('u1', 'first message'),
|
||||
assistantText('a1', 'first reply'),
|
||||
]);
|
||||
|
||||
final reader = TranscriptReader(
|
||||
workspace,
|
||||
projectsBase: tempBase.path,
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
final collected = <ConversationItem>[];
|
||||
final sub = reader.stream.listen(collected.add);
|
||||
|
||||
await pumpUntil(() => collected.whereType<UserMessage>().isNotEmpty && collected.whereType<AssistantTextMessage>().isNotEmpty);
|
||||
|
||||
await sub.cancel();
|
||||
await reader.dispose();
|
||||
|
||||
expect(collected.whereType<UserMessage>(), hasLength(1));
|
||||
expect(collected.whereType<AssistantTextMessage>(), hasLength(1));
|
||||
});
|
||||
|
||||
test('appended lines are emitted without replaying earlier lines', () async {
|
||||
final projectDir = mungedDir(tempBase, workspace);
|
||||
await projectDir.create(recursive: true);
|
||||
final sessionFile = File('${projectDir.path}/session-abc.jsonl');
|
||||
|
||||
writeLines(sessionFile, [userText('u1', 'initial')]);
|
||||
|
||||
final reader = TranscriptReader(
|
||||
workspace,
|
||||
projectsBase: tempBase.path,
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
final collected = <ConversationItem>[];
|
||||
final sub = reader.stream.listen(collected.add);
|
||||
|
||||
// Let the reader consume the initial lines.
|
||||
await pumpUntil(() => collected.whereType<UserMessage>().isNotEmpty);
|
||||
final countAfterInit = collected.length;
|
||||
|
||||
// Append new lines.
|
||||
appendLines(sessionFile, [assistantText('a1', 'appended reply')]);
|
||||
|
||||
// Let the reader pick up the append.
|
||||
await pumpUntil(() => collected.whereType<AssistantTextMessage>().isNotEmpty);
|
||||
|
||||
await sub.cancel();
|
||||
await reader.dispose();
|
||||
|
||||
expect(collected.length, greaterThan(countAfterInit));
|
||||
expect(collected.whereType<AssistantTextMessage>(), hasLength(1));
|
||||
// The initial UserMessage was already counted; no duplicates.
|
||||
expect(collected.whereType<UserMessage>(), hasLength(1));
|
||||
});
|
||||
|
||||
test('session switch: newer file triggers replay from new file start', () async {
|
||||
final projectDir = mungedDir(tempBase, workspace);
|
||||
await projectDir.create(recursive: true);
|
||||
final sessionFile = File('${projectDir.path}/session-abc.jsonl');
|
||||
|
||||
// Write session A and backdate it to a fixed past time. This makes
|
||||
// "newest by mtime" deterministic: relying on wall-clock mtimes lets
|
||||
// the two files tie under timing pressure, so the reader never
|
||||
// switches and the test flakes. A fixed old mtime removes the race.
|
||||
writeLines(sessionFile, [userText('u1', 'old session')]);
|
||||
await sessionFile.setLastModified(DateTime.utc(2020));
|
||||
|
||||
final reader = TranscriptReader(
|
||||
workspace,
|
||||
projectsBase: tempBase.path,
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
final collected = <ConversationItem>[];
|
||||
final sub = reader.stream.listen(collected.add);
|
||||
|
||||
await pumpUntil(() => collected.whereType<UserMessage>().any((m) => m.text == 'old session'));
|
||||
|
||||
// A newer session file — its mtime (now, then pinned to 2021) is
|
||||
// unambiguously after session A's 2020, so the switch is guaranteed.
|
||||
final newerFile = File('${projectDir.path}/session-xyz.jsonl');
|
||||
writeLines(newerFile, [userText('u2', 'new session')]);
|
||||
await newerFile.setLastModified(DateTime.utc(2021));
|
||||
|
||||
await pumpUntil(() => collected.whereType<UserMessage>().any((m) => m.text == 'new session'));
|
||||
|
||||
await sub.cancel();
|
||||
await reader.dispose();
|
||||
|
||||
// The reader should have switched to the newer file.
|
||||
final userMessages = collected.whereType<UserMessage>().map((m) => m.text).toList();
|
||||
expect(userMessages, contains('new session'));
|
||||
});
|
||||
|
||||
test('skip types in a real file are not emitted', () async {
|
||||
final projectDir = mungedDir(tempBase, workspace);
|
||||
await projectDir.create(recursive: true);
|
||||
final sessionFile = File('${projectDir.path}/session-abc.jsonl');
|
||||
|
||||
writeLines(sessionFile, [
|
||||
skipRecord('last-prompt', 'skip1'),
|
||||
skipRecord('attachment', 'skip2'),
|
||||
userText('u1', 'real message'),
|
||||
skipRecord('system', 'skip3'),
|
||||
]);
|
||||
|
||||
final reader = TranscriptReader(
|
||||
workspace,
|
||||
projectsBase: tempBase.path,
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
final collected = <ConversationItem>[];
|
||||
final sub = reader.stream.listen(collected.add);
|
||||
|
||||
await pumpUntil(() => collected.isNotEmpty);
|
||||
|
||||
await sub.cancel();
|
||||
await reader.dispose();
|
||||
|
||||
expect(collected, hasLength(1));
|
||||
expect((collected.first as UserMessage).text, 'real message');
|
||||
});
|
||||
|
||||
test('initial attach reads only the recent tail (initialTailBytes cap)', () async {
|
||||
final projectDir = mungedDir(tempBase, workspace);
|
||||
await projectDir.create(recursive: true);
|
||||
final sessionFile = File('${projectDir.path}/session-abc.jsonl');
|
||||
|
||||
// A long pre-existing transcript — the kind that froze the UI when
|
||||
// parsed in full on attach.
|
||||
writeLines(sessionFile, [
|
||||
for (var i = 0; i < 200; i++) assistantText('a$i', 'reply number $i'),
|
||||
]);
|
||||
|
||||
// Cap the initial read well below the file size so only the last
|
||||
// records are within the tail window.
|
||||
final reader = TranscriptReader(
|
||||
workspace,
|
||||
projectsBase: tempBase.path,
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
initialTailBytes: 256,
|
||||
);
|
||||
final collected = <ConversationItem>[];
|
||||
final sub = reader.stream.listen(collected.add);
|
||||
|
||||
await pumpUntil(() => collected.isNotEmpty);
|
||||
|
||||
await sub.cancel();
|
||||
await reader.dispose();
|
||||
|
||||
// Far fewer than the 200 records — only the recent tail was read.
|
||||
expect(collected, isNotEmpty);
|
||||
expect(collected.length, lessThan(200));
|
||||
// The most recent record is always intact at the end of the file.
|
||||
expect(
|
||||
collected.whereType<AssistantTextMessage>().last.text,
|
||||
'reply number 199',
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit file: tails that exact file, ignoring newest-discovery', () async {
|
||||
// A teammate reader (T-139) points at one fixed subagent file rather
|
||||
// than the newest .jsonl in the munged dir.
|
||||
final dir = await Directory.systemTemp.createTemp('explicit_file_');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final target = File('${dir.path}/agent-abc.jsonl');
|
||||
writeLines(target, [assistantText('a1', 'from the explicit file')]);
|
||||
|
||||
final reader = TranscriptReader(
|
||||
'/unused/workspace',
|
||||
projectsBase: '/nonexistent',
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
file: target.path,
|
||||
);
|
||||
final collected = <ConversationItem>[];
|
||||
final sub = reader.stream.listen(collected.add);
|
||||
|
||||
await pumpUntil(() => collected.isNotEmpty);
|
||||
|
||||
await sub.cancel();
|
||||
await reader.dispose();
|
||||
|
||||
expect(collected.whereType<AssistantTextMessage>().single.text, 'from the explicit file');
|
||||
});
|
||||
|
||||
test('explicit file: waits without error until the file appears', () async {
|
||||
// claude writes <session-id>.jsonl shortly after spawn (T-146); the
|
||||
// reader must poll without throwing until it exists, then stream it.
|
||||
final dir = await Directory.systemTemp.createTemp('late_file_');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final target = File('${dir.path}/agent-late.jsonl');
|
||||
|
||||
final reader = TranscriptReader(
|
||||
'/unused',
|
||||
projectsBase: '/nonexistent',
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
file: target.path,
|
||||
);
|
||||
final collected = <ConversationItem>[];
|
||||
final sub = reader.stream.listen(collected.add);
|
||||
|
||||
// Let it poll a few times against the missing file — must not throw.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
expect(collected, isEmpty);
|
||||
|
||||
// Now the file appears.
|
||||
writeLines(target, [assistantText('a1', 'arrived late')]);
|
||||
await pumpUntil(() => collected.isNotEmpty);
|
||||
|
||||
await sub.cancel();
|
||||
await reader.dispose();
|
||||
expect(collected.whereType<AssistantTextMessage>().single.text, 'arrived late');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final _epoch = DateTime.utc(2026, 5, 16, 8, 53, 6);
|
||||
|
||||
/// Canonical snapshot fixture representing a full conversation turn.
|
||||
List<Map<String, dynamic>> _snapshotFixture() {
|
||||
const toolUseId = 'toolu_fixture_001';
|
||||
return [
|
||||
// skip type — must not appear in output
|
||||
skipRecord('last-prompt', 'skip-0'),
|
||||
|
||||
// user turn — plain string
|
||||
userText('u-001', 'what files are here?'),
|
||||
|
||||
// skip type inside the sequence
|
||||
skipRecord('permission-mode', 'skip-1'),
|
||||
|
||||
// assistant turn — thinking + tool_use
|
||||
envelope(
|
||||
type: 'assistant',
|
||||
uuid: 'a-001',
|
||||
message: {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'thinking', 'thinking': 'I need to list the directory to answer.'},
|
||||
{
|
||||
'type': 'tool_use',
|
||||
'id': toolUseId,
|
||||
'name': 'Bash',
|
||||
'input': {'command': 'ls -la'}
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
|
||||
// tool result (user turn)
|
||||
userToolResult(
|
||||
'u-002',
|
||||
toolUseId: toolUseId,
|
||||
content: 'total 4\n-rw-r--r-- file.dart',
|
||||
),
|
||||
|
||||
// assistant text reply
|
||||
assistantText('a-002', 'The directory contains file.dart.'),
|
||||
|
||||
// another skip type at the end
|
||||
skipRecord('file-history-snapshot', 'skip-2'),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
/// Tests the multi-buffer tracking in EditorController (the UI mirror
|
||||
/// of the daemon's editor model): hydrate populates the open-buffer
|
||||
/// list, events keep it in sync, and activate/close route to IPC.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/editor_controller.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/fake_ipc.dart';
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
|
||||
Map<String, Object?> _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty};
|
||||
|
||||
Map<String, Object?> _read(String id, String path, String content, {bool dirty = false}) => {
|
||||
'id': id,
|
||||
'path': path,
|
||||
'content': content,
|
||||
'selection': {'start': 0, 'end': 0},
|
||||
'dirty': dirty,
|
||||
};
|
||||
|
||||
void emitEditor(DaemonBus bus, String kind, Map<String, Object?> data) {
|
||||
bus.emit(DaemonEvent(subsystem: 'editor', kind: kind, data: data, ts: DateTime.now().toUtc()));
|
||||
}
|
||||
|
||||
void main() {
|
||||
late DaemonBus bus;
|
||||
late FakeDaemonClient ipc;
|
||||
late EditorController c;
|
||||
|
||||
setUp(() {
|
||||
bus = DaemonBus();
|
||||
ipc = FakeDaemonClient(log: Logger(), events: bus);
|
||||
c = EditorController(ipc: ipc, events: bus);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
c.dispose();
|
||||
await bus.dispose();
|
||||
});
|
||||
|
||||
group('hydrate', () {
|
||||
test('populates the open-buffer list and loads the active buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'lib/a.dart'), _buf('b_2', 'lib/b.dart', dirty: true)]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (a) async => _ok(_read('b_1', 'lib/a.dart', 'hello')));
|
||||
|
||||
await c.hydrate();
|
||||
|
||||
expect(c.buffers.map((b) => b.id).toList(), ['b_1', 'b_2']);
|
||||
expect(c.buffers[1].dirty, isTrue);
|
||||
expect(c.activeId, 'b_1');
|
||||
expect(c.content, 'hello');
|
||||
});
|
||||
|
||||
test('with no active buffer leaves the list but no active content', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'lib/a.dart')]
|
||||
}));
|
||||
ipc.stub('editor.active', (_) async => _ok(const {})); // no `active` key
|
||||
|
||||
await c.hydrate();
|
||||
|
||||
expect(c.buffers, hasLength(1));
|
||||
expect(c.activeId, isNull);
|
||||
expect(c.content, '');
|
||||
});
|
||||
});
|
||||
|
||||
group('routing tab gestures to IPC', () {
|
||||
test('activate(id) issues editor.activate', () async {
|
||||
String? activated;
|
||||
ipc.stub('editor.activate', (a) async {
|
||||
activated = a['id'] as String?;
|
||||
return _ok({'active': a['id']});
|
||||
});
|
||||
await c.activate('b_7');
|
||||
expect(activated, 'b_7');
|
||||
});
|
||||
|
||||
test('activate is a no-op when the id is already active', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
|
||||
var calls = 0;
|
||||
ipc.stub('editor.activate', (a) async {
|
||||
calls++;
|
||||
return _ok({'active': a['id']});
|
||||
});
|
||||
await c.activate('b_1'); // already active
|
||||
expect(calls, 0);
|
||||
});
|
||||
|
||||
test('closeBuffer(id) issues editor.close', () async {
|
||||
String? closed;
|
||||
ipc.stub('editor.close', (a) async {
|
||||
closed = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await c.closeBuffer('b_3');
|
||||
expect(closed, 'b_3');
|
||||
});
|
||||
});
|
||||
|
||||
group('event sync', () {
|
||||
test('editor.opened refreshes the list and loads the new active buffer', () async {
|
||||
var listVersion = 1;
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': listVersion == 1 ? [_buf('b_1', 'a.dart')] : [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (a) async {
|
||||
final id = a['id'] as String;
|
||||
return _ok(_read(id, id == 'b_1' ? 'a.dart' : 'b.dart', 'content-$id'));
|
||||
});
|
||||
await c.hydrate();
|
||||
expect(c.buffers, hasLength(1));
|
||||
|
||||
listVersion = 2;
|
||||
emitEditor(bus, 'editor.opened', {'id': 'b_2'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.buffers.map((b) => b.id).toList(), ['b_1', 'b_2']);
|
||||
expect(c.activeId, 'b_2');
|
||||
expect(c.content, 'content-b_2');
|
||||
});
|
||||
|
||||
test('editor.closed refreshes the list and clears active when it was active', () async {
|
||||
var listVersion = 1;
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': listVersion == 1 ? [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')] : [_buf('b_2', 'b.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (a) async => _ok(_read(a['id'] as String, 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
expect(c.buffers, hasLength(2));
|
||||
|
||||
listVersion = 2;
|
||||
emitEditor(bus, 'editor.closed', {'id': 'b_1'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.buffers.map((b) => b.id).toList(), ['b_2']);
|
||||
expect(c.activeId, isNull); // cleared until an active-changed arrives
|
||||
});
|
||||
|
||||
test('editor.saved clears the dirty marker on the buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart', dirty: true)]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x', dirty: true)));
|
||||
await c.hydrate();
|
||||
expect(c.buffers.single.dirty, isTrue);
|
||||
|
||||
emitEditor(bus, 'editor.saved', {'id': 'b_1'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.buffers.single.dirty, isFalse);
|
||||
expect(c.dirty, isFalse);
|
||||
});
|
||||
|
||||
test('editor.edited marks the right buffer dirty, leaving siblings alone', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (a) async => _ok(_read(a['id'] as String, 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
expect(c.buffers.every((b) => !b.dirty), isTrue);
|
||||
|
||||
// A remote edit of b_2 (not our own). b_1 stays clean — exercises
|
||||
// the untouched-sibling branch of the dirty marker.
|
||||
emitEditor(bus, 'editor.edited', {'id': 'b_2'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.buffers.firstWhere((b) => b.id == 'b_2').dirty, isTrue);
|
||||
expect(c.buffers.firstWhere((b) => b.id == 'b_1').dirty, isFalse);
|
||||
});
|
||||
|
||||
test('editor.active-changed to a different buffer loads its content', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart'), _buf('b_2', 'b.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (a) async {
|
||||
final id = a['id'] as String;
|
||||
return _ok(_read(id, id == 'b_1' ? 'a.dart' : 'b.dart', 'body-$id'));
|
||||
});
|
||||
await c.hydrate();
|
||||
expect(c.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.active-changed', {'id': 'b_2'});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.activeId, 'b_2');
|
||||
expect(c.content, 'body-b_2');
|
||||
});
|
||||
});
|
||||
|
||||
group('local edits', () {
|
||||
test('pushLocalEdit marks the active buffer dirty and mirrors to IPC', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
|
||||
Map<String, Object?>? setArgs;
|
||||
ipc.stub('editor.set-content', (a) async {
|
||||
setArgs = a;
|
||||
return _ok(const {});
|
||||
});
|
||||
c.pushLocalEdit(newContent: 'xy', newSelection: const Selection.collapsed(2));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.dirty, isTrue);
|
||||
expect(c.buffers.single.dirty, isTrue);
|
||||
expect(setArgs?['id'], 'b_1');
|
||||
expect(setArgs?['text'], 'xy');
|
||||
});
|
||||
|
||||
test('save() issues editor.save for the active buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
|
||||
String? saved;
|
||||
ipc.stub('editor.save', (a) async {
|
||||
saved = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await c.save();
|
||||
expect(saved, 'b_1');
|
||||
});
|
||||
|
||||
test('save() is a no-op with no active buffer', () async {
|
||||
var calls = 0;
|
||||
ipc.stub('editor.save', (_) async {
|
||||
calls++;
|
||||
return _ok(const {});
|
||||
});
|
||||
await c.save(); // never hydrated → no active
|
||||
expect(calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('edge cases', () {
|
||||
test('editor.active-changed to null clears the active buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
expect(c.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.active-changed', const {}); // no id
|
||||
await pumpEventQueue();
|
||||
expect(c.activeId, isNull);
|
||||
expect(c.content, '');
|
||||
});
|
||||
|
||||
test('hydrate surfaces an error when editor.active fails', () async {
|
||||
ipc.stub('editor.list', (_) async => _ok({'buffers': const []}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
|
||||
),
|
||||
);
|
||||
await c.hydrate();
|
||||
expect(c.error, 'boom');
|
||||
});
|
||||
|
||||
test('a missing-stub editor.list (not ok) leaves the buffer list empty', () async {
|
||||
// No editor.list stub registered → FakeDaemonClient returns a
|
||||
// notFound error; _refreshList bails without crashing.
|
||||
ipc.stub('editor.active', (_) async => _ok(const {}));
|
||||
await c.hydrate();
|
||||
expect(c.buffers, isEmpty);
|
||||
});
|
||||
|
||||
test('editor.opened with no id clears the active buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'x')));
|
||||
await c.hydrate();
|
||||
expect(c.activeId, 'b_1');
|
||||
|
||||
emitEditor(bus, 'editor.opened', const {}); // no id
|
||||
await pumpEventQueue();
|
||||
expect(c.activeId, isNull);
|
||||
});
|
||||
|
||||
test('a failing editor.read surfaces the error', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.read',
|
||||
(_) async => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'read failed'),
|
||||
),
|
||||
);
|
||||
await c.hydrate();
|
||||
expect(c.error, 'read failed');
|
||||
});
|
||||
|
||||
test('our own edit echo (editor.edited) is suppressed once, not reloaded', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')]
|
||||
}));
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'original')));
|
||||
ipc.stub('editor.set-content', (_) async => _ok(const {}));
|
||||
await c.hydrate();
|
||||
|
||||
// Local edit arms _suppressNextRemoteEdit.
|
||||
c.pushLocalEdit(newContent: 'local', newSelection: const Selection.collapsed(5));
|
||||
var reads = 0;
|
||||
ipc.stub('editor.read', (_) async {
|
||||
reads++;
|
||||
return _ok(_read('b_1', 'a.dart', 'reloaded'));
|
||||
});
|
||||
// The echo of our own set-content comes back as editor.edited.
|
||||
emitEditor(bus, 'editor.edited', {'id': 'b_1'});
|
||||
await pumpEventQueue();
|
||||
|
||||
// Suppressed: no reload, local content preserved.
|
||||
expect(reads, 0);
|
||||
expect(c.content, 'local');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/// Widget tests for the multi-tab editor view: a tab per open buffer
|
||||
/// (filename + dirty marker), the empty-state hint, and tab taps
|
||||
/// routing to `editor.activate`. Buffer-list logic itself is covered
|
||||
/// in editor_controller_test.dart.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/editor_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
Finder _closeIcons() => find.byWidgetPredicate((w) => w is ClideIcon && w.painter is CloseIcon);
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
|
||||
Map<String, Object?> _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty};
|
||||
|
||||
Map<String, Object?> _read(String id, String path) => {
|
||||
'id': id,
|
||||
'path': path,
|
||||
'content': 'content of $path',
|
||||
'selection': {'start': 0, 'end': 0},
|
||||
'dirty': false,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('EditorView tabs', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
void stubBuffers(List<Map<String, Object?>> buffers, {String? active}) {
|
||||
f.ipc.stub('editor.list', (_) async => _ok({'buffers': buffers}));
|
||||
f.ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => active == null
|
||||
? _ok(const {})
|
||||
: _ok({
|
||||
'active': {'id': active}
|
||||
}));
|
||||
f.ipc.stub('editor.read', (a) async {
|
||||
final id = a['id'] as String;
|
||||
final b = buffers.firstWhere((b) => b['id'] == id);
|
||||
return _ok(_read(id, b['path'] as String));
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('renders one tab per open buffer, by filename', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('a.dart'), findsOneWidget);
|
||||
expect(find.text('b.dart'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a dirty buffer carries a marker in its tab title', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart', dirty: true)], active: 'b_1');
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('a.dart •'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no open buffers shows the open-a-file hint', (tester) async {
|
||||
stubBuffers(const [], active: null);
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Open a file to begin editing.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping an inactive tab routes to editor.activate', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
|
||||
String? activated;
|
||||
f.ipc.stub('editor.activate', (a) async {
|
||||
activated = a['id'] as String?;
|
||||
return _ok({'active': a['id']});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('b.dart'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(activated, 'b_2');
|
||||
});
|
||||
|
||||
testWidgets('closing a tab routes to editor.close', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
String? closed;
|
||||
f.ipc.stub('editor.close', (a) async {
|
||||
closed = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_closeIcons(), findsWidgets);
|
||||
await tester.tap(_closeIcons().first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, 'b_1');
|
||||
});
|
||||
|
||||
testWidgets('typing in the editor mirrors to editor.set-content', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
Map<String, Object?>? setArgs;
|
||||
f.ipc.stub('editor.set-content', (a) async {
|
||||
setArgs = a;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(EditableText), 'edited body');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(setArgs?['id'], 'b_1');
|
||||
expect(setArgs?['text'], 'edited body');
|
||||
});
|
||||
|
||||
testWidgets('Ctrl+S in the editor triggers editor.save', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
String? saved;
|
||||
f.ipc.stub('editor.save', (a) async {
|
||||
saved = a['id'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(saved, 'b_1');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/// Tests SyntaxTextController — the editor's TextEditingController that
|
||||
/// turns tree-sitter spans into styled TextSpans. A fake
|
||||
/// TreeSitterService supplies canned spans so the byte→char mapping and
|
||||
/// span-rendering logic can be exercised without the native grammar.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/syntax_text_controller.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
/// TreeSitterService stub returning canned spans (constructor does no
|
||||
/// FFI work — `_init` is lazy and never reached here).
|
||||
class _FakeSyntax extends TreeSitterService {
|
||||
_FakeSyntax(this.spans);
|
||||
final List<SyntaxSpan> spans;
|
||||
@override
|
||||
Future<SyntaxResult> highlight(String path, String source) async => SyntaxResult(spans);
|
||||
}
|
||||
|
||||
/// Counts highlight invocations so a test can assert a same-path
|
||||
/// updatePath doesn't kick off a redundant highlight.
|
||||
class _CountingSyntax extends TreeSitterService {
|
||||
int calls = 0;
|
||||
@override
|
||||
Future<SyntaxResult> highlight(String path, String source) async {
|
||||
calls++;
|
||||
return const SyntaxResult([]);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SyntaxTextController', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
/// Pump a themed context, run [body] with a controller already
|
||||
/// given tokens from the active theme.
|
||||
Future<void> withController(
|
||||
WidgetTester tester,
|
||||
SyntaxTextController c,
|
||||
Future<void> Function(BuildContext ctx) body,
|
||||
) async {
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(harness(f, Builder(builder: (context) {
|
||||
ctx = context;
|
||||
return const SizedBox();
|
||||
})));
|
||||
c.tokens = ClideTheme.of(ctx).surface;
|
||||
await body(ctx);
|
||||
}
|
||||
|
||||
testWidgets('renders highlighted spans as styled TextSpan children', (tester) async {
|
||||
final c = SyntaxTextController(
|
||||
syntax: _FakeSyntax(const [
|
||||
SyntaxSpan(start: 0, end: 5, role: 'keyword'), // "class"
|
||||
SyntaxSpan(start: 6, end: 9, role: 'type'), // "Foo"
|
||||
]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'class Foo {}';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNotNull);
|
||||
// keyword + gap + type + trailing → several children.
|
||||
expect(span.children!.length, greaterThan(2));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('maps byte offsets across multi-byte (surrogate) characters', (tester) async {
|
||||
// '😀' is a surrogate pair (4 UTF-8 bytes). A span after it must
|
||||
// still land on the right character offset.
|
||||
final c = SyntaxTextController(
|
||||
syntax: _FakeSyntax(const [
|
||||
SyntaxSpan(start: 5, end: 8, role: 'type'), // "Foo" after "😀 "
|
||||
]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = '😀 Foo';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNotNull);
|
||||
expect(span.toPlainText(), '😀 Foo');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('with no spans falls back to a plain TextSpan', (tester) async {
|
||||
final c = SyntaxTextController(syntax: _FakeSyntax(const []));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'plain text';
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
expect(span.children, isNull);
|
||||
expect(span.text, 'plain text');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('updatePath to the same path is a no-op', (tester) async {
|
||||
final syntax = _CountingSyntax();
|
||||
final c = SyntaxTextController(syntax: syntax);
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'x';
|
||||
c.updatePath('a.dart');
|
||||
await tester.pumpAndSettle();
|
||||
final first = syntax.calls;
|
||||
expect(first, greaterThan(0));
|
||||
c.updatePath('a.dart'); // same path → early return
|
||||
await tester.pumpAndSettle();
|
||||
expect(syntax.calls, first); // no new highlight request
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a path with no known grammar skips highlighting', (tester) async {
|
||||
final c = SyntaxTextController(syntax: _FakeSyntax(const [SyntaxSpan(start: 0, end: 1, role: 'x')]));
|
||||
await withController(tester, c, (ctx) async {
|
||||
c.text = 'data';
|
||||
c.updatePath('notes.unknownext');
|
||||
await tester.pumpAndSettle();
|
||||
final span = c.buildTextSpan(context: ctx, withComposing: false);
|
||||
// No grammar → no spans applied → plain text.
|
||||
expect(span.children, isNull);
|
||||
expect(span.text, 'data');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -252,6 +252,17 @@ void main() {
|
||||
expect(r.data['branch'], 'next');
|
||||
});
|
||||
|
||||
test('git.checkout via argv positional reaches the handler (D-74)', () async {
|
||||
// `clide git checkout next` → {positional: ['next']}; the schema's
|
||||
// positional ordering maps it to `branch` at the dispatcher.
|
||||
await Process.run('git', ['branch', 'next'], workingDirectory: sandbox.path);
|
||||
final r = await call('git.checkout', {
|
||||
'positional': ['next'],
|
||||
});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['branch'], 'next');
|
||||
});
|
||||
|
||||
test('git.checkout to an unknown branch surfaces a tool error', () async {
|
||||
final r = await call('git.checkout', {'branch': 'no-such-branch'});
|
||||
expect(r.ok, isFalse);
|
||||
|
||||
@@ -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('number'));
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/// Covers the `ClideExtensionContext` sugar extensions (publish /
|
||||
/// subscribe / t / tr) and the default no-op `ClideExtension`
|
||||
/// lifecycle hooks, driving the *real* context the ExtensionManager
|
||||
/// hands to `activate()`.
|
||||
library;
|
||||
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
/// Extension that exercises the context sugar inside activate().
|
||||
class _SugarExt extends ClideExtension {
|
||||
_SugarExt(this.onActivate);
|
||||
final Future<void> Function(ClideExtensionContext ctx) onActivate;
|
||||
@override
|
||||
String get id => 'sugar.ext';
|
||||
@override
|
||||
String get title => 'Sugar';
|
||||
@override
|
||||
String get version => '0.0.0-test';
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) => onActivate(ctx);
|
||||
}
|
||||
|
||||
/// Extension that overrides nothing — its activate/deactivate are the
|
||||
/// base-class defaults (the lines under test).
|
||||
class _BareExt extends ClideExtension {
|
||||
@override
|
||||
String get id => 'bare.ext';
|
||||
@override
|
||||
String get title => 'Bare';
|
||||
@override
|
||||
String get version => '0.0.0-test';
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ClideExtensionContext sugar', () {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await f.dispose();
|
||||
});
|
||||
|
||||
test('publish + subscribe round-trip through the message bus', () async {
|
||||
Message? received;
|
||||
f.services.extensions.register(_SugarExt((ctx) async {
|
||||
final first = ctx.subscribe(channel: 'greet').first;
|
||||
ctx.publish('greet', {'hello': 'world'});
|
||||
received = await first;
|
||||
}));
|
||||
await f.services.extensions.activate('sugar.ext');
|
||||
expect(received, isNotNull);
|
||||
expect(received!.publisher, 'sugar.ext');
|
||||
expect(received!.channel, 'greet');
|
||||
expect(received!.data['hello'], 'world');
|
||||
});
|
||||
|
||||
test('t + tr resolve against the extension namespace', () async {
|
||||
String? t;
|
||||
String? tr;
|
||||
f.services.extensions.register(_SugarExt((ctx) async {
|
||||
t = ctx.t('missing.key');
|
||||
tr = ctx.tr('missing.key', replacers: const []);
|
||||
}));
|
||||
await f.services.extensions.activate('sugar.ext');
|
||||
// No catalog loaded for this namespace → i18n falls back, but the
|
||||
// sugar getters still execute and return a non-null string.
|
||||
expect(t, isNotNull);
|
||||
expect(tr, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClideExtension default lifecycle', () {
|
||||
test('base activate + deactivate are safe no-ops', () async {
|
||||
final ext = _BareExt();
|
||||
// Default activate (no context needed by the base impl) +
|
||||
// default deactivate must both complete without throwing.
|
||||
await expectLater(ext.deactivate(), completes);
|
||||
});
|
||||
|
||||
test('manager deactivate invokes the base deactivate', () async {
|
||||
final f = await KernelFixture.create();
|
||||
addTearDown(f.dispose);
|
||||
f.services.extensions.register(_BareExt());
|
||||
await f.services.extensions.activate('bare.ext');
|
||||
await f.services.extensions.deactivate('bare.ext');
|
||||
// Reaching here means the base ClideExtension.deactivate() ran
|
||||
// through the manager without error.
|
||||
expect(f.services.extensions.failedExtensions['bare.ext'], isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -52,5 +52,19 @@ void main() {
|
||||
final out = await const ExtensionScanner().discover(root: root);
|
||||
expect(out.map((m) => m.id).toList(), ['ext.ok']);
|
||||
});
|
||||
|
||||
test('defaultRoot points at ~/.clide/extensions', () {
|
||||
final scanner = ExtensionScanner();
|
||||
final home = Platform.environment['HOME'] ?? '/tmp';
|
||||
expect(scanner.defaultRoot().path, '$home/.clide/extensions');
|
||||
});
|
||||
|
||||
test('discover() with no root falls back to defaultRoot', () async {
|
||||
// Exercises the `root ?? defaultRoot()` fallback. The install
|
||||
// root rarely exists in CI, so this returns a (possibly empty)
|
||||
// list rather than throwing.
|
||||
final out = await const ExtensionScanner().discover();
|
||||
expect(out, isA<List<ExtensionManifest>>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,193 @@
|
||||
/// Tests for the typed command-schema framework (T-120 / D-74):
|
||||
/// argument coercion, per-constraint validation, the argv-shape
|
||||
/// normaliser, and the dispatcher-level validation hook.
|
||||
library;
|
||||
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/ipc/command_schema.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('ArgSpec coercion + constraints', () {
|
||||
SchemaResult run(ArgSpec spec, Object? value, {bool required = false}) {
|
||||
final schema = CommandSchema(args: {'x': spec});
|
||||
return schema.validate({'x': value});
|
||||
}
|
||||
|
||||
test('string passes through; non-string rejected', () {
|
||||
expect(run(const ArgSpec(), 'hi').isOk, isTrue);
|
||||
final bad = run(const ArgSpec(), 42);
|
||||
expect(bad.isOk, isFalse);
|
||||
expect(bad.error, contains('expected a string'));
|
||||
});
|
||||
|
||||
test('number coerces from a numeric string', () {
|
||||
final r = run(const ArgSpec(type: ArgType.number), '3.5');
|
||||
expect(r.isOk, isTrue);
|
||||
expect(r.values!['x'], 3.5);
|
||||
});
|
||||
|
||||
test('number rejects non-numeric text', () {
|
||||
final r = run(const ArgSpec(type: ArgType.number), 'abc');
|
||||
expect(r.isOk, isFalse);
|
||||
expect(r.error, contains('expected a number'));
|
||||
});
|
||||
|
||||
test('number honours min/max bounds', () {
|
||||
expect(run(const ArgSpec(type: ArgType.number, min: 0, max: 1), 0.5).isOk, isTrue);
|
||||
expect(run(const ArgSpec(type: ArgType.number, min: 0), -1).error, contains('>= 0'));
|
||||
expect(run(const ArgSpec(type: ArgType.number, max: 1), 2).error, contains('<= 1'));
|
||||
});
|
||||
|
||||
test('boolean coerces from "true"/"false" strings', () {
|
||||
expect(run(const ArgSpec(type: ArgType.boolean), 'true').values!['x'], true);
|
||||
expect(run(const ArgSpec(type: ArgType.boolean), 'false').values!['x'], false);
|
||||
expect(run(const ArgSpec(type: ArgType.boolean), 'maybe').isOk, isFalse);
|
||||
});
|
||||
|
||||
test('rejectLeadingDash blocks an argv-injection value', () {
|
||||
final r = run(const ArgSpec(rejectLeadingDash: true), '--upload-pack=evil');
|
||||
expect(r.isOk, isFalse);
|
||||
expect(r.error, contains('must not start with "-"'));
|
||||
});
|
||||
|
||||
test('allowed enforces a closed set', () {
|
||||
final spec = ArgSpec(allowed: {'a', 'b'});
|
||||
expect(run(spec, 'a').isOk, isTrue);
|
||||
expect(run(spec, 'z').error, contains('one of'));
|
||||
});
|
||||
|
||||
test('pattern must fully match', () {
|
||||
final spec = ArgSpec(pattern: RegExp(r'^[0-9]+$'));
|
||||
expect(run(spec, '123').isOk, isTrue);
|
||||
expect(run(spec, '12a').error, contains('does not match'));
|
||||
});
|
||||
|
||||
test('stringList coerces a scalar + caps element count', () {
|
||||
final r = run(const ArgSpec(type: ArgType.stringList), 'solo');
|
||||
expect(r.values!['x'], ['solo']);
|
||||
final capped = run(const ArgSpec(type: ArgType.stringList, maxItems: 2), ['a', 'b', 'c']);
|
||||
expect(capped.error, contains('cap is 2'));
|
||||
});
|
||||
|
||||
test('stringList rejectLeadingDash inspects every element', () {
|
||||
final r = run(const ArgSpec(type: ArgType.stringList, rejectLeadingDash: true), ['ok', '-bad']);
|
||||
expect(r.isOk, isFalse);
|
||||
expect(r.error, contains('-bad'));
|
||||
});
|
||||
|
||||
test('stringList rejects a value that is neither list nor string', () {
|
||||
final r = run(const ArgSpec(type: ArgType.stringList), 42);
|
||||
expect(r.isOk, isFalse);
|
||||
expect(r.error, contains('expected a list'));
|
||||
});
|
||||
});
|
||||
|
||||
group('required + unknown handling', () {
|
||||
test('missing required arg fails', () {
|
||||
final s = CommandSchema(args: {'name': const ArgSpec(required: true)});
|
||||
final r = s.validate(const {});
|
||||
expect(r.isOk, isFalse);
|
||||
expect(r.error, contains('name is required'));
|
||||
});
|
||||
|
||||
test('missing optional arg is fine', () {
|
||||
final s = CommandSchema(args: {'name': const ArgSpec()});
|
||||
expect(s.validate(const {}).isOk, isTrue);
|
||||
});
|
||||
|
||||
test('undeclared keys are preserved untouched', () {
|
||||
final s = CommandSchema(args: {'a': const ArgSpec()});
|
||||
final r = s.validate(const {'a': 'x', 'extra': 99});
|
||||
expect(r.isOk, isTrue);
|
||||
expect(r.values!['extra'], 99);
|
||||
});
|
||||
});
|
||||
|
||||
group('normalize (argv shape → named args)', () {
|
||||
const schema = CommandSchema(
|
||||
positional: ['slot'],
|
||||
args: {'slot': ArgSpec(), 'to': ArgSpec(type: ArgType.number)},
|
||||
);
|
||||
|
||||
test('positional[i] maps to the i-th declared name', () {
|
||||
final out = schema.normalize(const {
|
||||
'positional': ['sidebar'],
|
||||
'flags': {'to': '300'},
|
||||
});
|
||||
expect(out['slot'], 'sidebar');
|
||||
expect(out['to'], '300');
|
||||
});
|
||||
|
||||
test('passthrough is carried over', () {
|
||||
final out = schema.normalize(const {
|
||||
'positional': ['x'],
|
||||
'passthrough': ['--', 'raw'],
|
||||
});
|
||||
expect(out['passthrough'], ['--', 'raw']);
|
||||
});
|
||||
|
||||
test('direct (already-named) shape is returned unchanged', () {
|
||||
final input = {'slot': 'context', 'to': 240};
|
||||
expect(identical(schema.normalize(input), input), isTrue);
|
||||
});
|
||||
|
||||
test('extra positionals beyond the declared names are dropped', () {
|
||||
final out = schema.normalize(const {
|
||||
'positional': ['a', 'b', 'c'],
|
||||
});
|
||||
expect(out['slot'], 'a');
|
||||
expect(out.containsKey('b'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('DaemonDispatcher schema gate', () {
|
||||
test('validates + coerces before the handler runs', () async {
|
||||
final d = DaemonDispatcher();
|
||||
Object? seen;
|
||||
d.register('demo.cmd', (req) async {
|
||||
seen = req.args['n'];
|
||||
return IpcResponse.ok(id: req.id, data: const {});
|
||||
}, schema: const CommandSchema(args: {'n': ArgSpec(type: ArgType.number)}));
|
||||
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'demo.cmd', args: const {'n': '7'}));
|
||||
expect(r.ok, isTrue);
|
||||
expect(seen, 7); // coerced from the string "7"
|
||||
});
|
||||
|
||||
test('rejects a violation with userError before the handler runs', () async {
|
||||
final d = DaemonDispatcher();
|
||||
var handlerRan = false;
|
||||
d.register('demo.cmd', (req) async {
|
||||
handlerRan = true;
|
||||
return IpcResponse.ok(id: req.id, data: const {});
|
||||
}, schema: const CommandSchema(args: {'ref': ArgSpec(rejectLeadingDash: true, required: true)}));
|
||||
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'demo.cmd', args: const {'ref': '-rf'}));
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, IpcErrorKind.userError);
|
||||
expect(handlerRan, isFalse);
|
||||
});
|
||||
|
||||
test('a command with no schema dispatches unvalidated', () async {
|
||||
final d = DaemonDispatcher();
|
||||
d.register('demo.bare', (req) async {
|
||||
return IpcResponse.ok(id: req.id, data: {'echo': req.args['anything']});
|
||||
});
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'demo.bare', args: const {'anything': '-not-checked'}));
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['echo'], '-not-checked');
|
||||
});
|
||||
|
||||
test('re-registering a command without a schema clears its old schema', () async {
|
||||
final d = DaemonDispatcher();
|
||||
handler(IpcRequest req) async => IpcResponse.ok(id: req.id, data: const {});
|
||||
d.register('demo.cmd', handler, schema: const CommandSchema(args: {'r': ArgSpec(required: true)}));
|
||||
d.register('demo.cmd', handler); // no schema this time
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'demo.cmd', args: const {}));
|
||||
expect(r.ok, isTrue); // required check no longer applies
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -131,8 +131,8 @@ void main() {
|
||||
});
|
||||
|
||||
group('DaemonClient — error + lifecycle paths', () {
|
||||
test('request while disconnected returns a not-connected error', () async {
|
||||
// Don't connect — point at a non-existent socket path.
|
||||
test('request while disconnected (never started) returns a not-connected error fast', () async {
|
||||
// Never started → no connect attempt in flight → fail fast (no wait).
|
||||
final bus = DaemonBus();
|
||||
addTearDown(bus.dispose);
|
||||
final client = _build('/tmp/does-not-exist.sock', bus);
|
||||
@@ -142,6 +142,37 @@ void main() {
|
||||
expect(resp.error?.message, contains('not connected'));
|
||||
});
|
||||
|
||||
test('a request issued before connect waits, then sends once connected', () async {
|
||||
// Reproduces the startup race: the UI queries before the socket
|
||||
// finishes connecting. Started (so a connect is in flight) but no
|
||||
// server yet — the request must park, not fail, and send once the
|
||||
// server comes up.
|
||||
final path = await _tmpSocket();
|
||||
final bus = DaemonBus();
|
||||
addTearDown(bus.dispose);
|
||||
final client = _build(path, bus);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
await client.start(); // no server yet → connect fails, reconnect armed
|
||||
expect(client.isConnected, isFalse);
|
||||
|
||||
final respFuture = client.request('ping'); // parks (does not fail)
|
||||
|
||||
// Bring the server up; the reconnect loop connects, waking the request.
|
||||
final daemon = _TestDaemon(path);
|
||||
await daemon.start();
|
||||
addTearDown(daemon.close);
|
||||
|
||||
final line = await daemon.lines.first;
|
||||
expect(line, contains('ping'));
|
||||
final req = IpcMessage.decode(line) as IpcRequest;
|
||||
daemon.send(IpcResponse.ok(id: req.id, data: const {'pong': true}).encode());
|
||||
|
||||
final resp = await respFuture;
|
||||
expect(resp.ok, isTrue);
|
||||
expect(resp.data['pong'], isTrue);
|
||||
});
|
||||
|
||||
test('malformed line is logged and skipped, real lines still work', () async {
|
||||
final path = await _tmpSocket();
|
||||
final daemon = _TestDaemon(path);
|
||||
@@ -276,5 +307,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, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user