19 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.8 57a25e6c2d chore(release): cut 2.6.0
Bump pubspec 2.5.0 -> 2.6.0 and move the Unreleased entries under a dated
2.6.0 heading. Minor (not patch) — the cut includes new features (vim
ex-line + gt/gT, crash-survivable logging, watchdog) alongside the fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:07:17 +02:00
jpmschweitzerandClaude Opus 4.8 804bba1680 docs(design): settings-UI wireframes + settings-ui implementation epic (T-302, T-444)
Close T-302 with the Frame0 wireframes for the schema-driven settings UI, and
open the implementation epic T-444 under the Tier-6 epic T-8.

Wireframes (docs/design/wireframes/settings/, JSON source + PNG):
- settings-screen — modal shell + Editor category (all field-type patterns,
  scope tags, carded sections)
- settings-search — cross-category search-active state
- settings-claude — mirrors the sidebar Claude Config panel (settings controls
  + carded config lists)
- settings-appearance — theme-picker swatch grid (live bundled-theme previews)

Design answers: full-screen MODAL overlay; rail + cross-category search IA;
per-field scope-tag model (folder/globe/circle-dashed); carded sections; the
schema-driven renderer makes per-category tabs data, not new design.

Also documents the sectioned-card preference in the ui-design skill
(references/surface.md → "Settings & grouped lists — sectioned cards").

Epic T-444 children: infra (modal shell T-445, rail T-447, field renderer
T-448, scope-tag control T-449, search T-450) + per-category (Editor=T-290
reparented, Keymap T-451, Appearance/theme-picker T-452, Activity T-453,
Terminal T-455, Extensions T-456, Claude T-457).

Also files: T-441/T-442 (UI bugs), T-446 (slash-typeahead intermittent),
T-454 (Claude remote-control not plumbed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:41:55 +02:00
jpmschweitzerandClaude Opus 4.8 b5146cc9ad chore(plan): relabel local T-440 -> T-443 (duplicate across clones)
The push race created two T-440s: the remote tree-sitter.dll task and our
local Playwright-e2e follow-up. Kept the already-shared remote one as T-440;
renumbered our local one to T-443 via pql ticket relabel (record_id stable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:18:39 +02:00
jpmschweitzer 7fa9c168b9 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.pql/changelog/ticket_idmap/2026-06.sql
#	.pql/changelog/tickets/2026-06.sql
2026-06-16 12:17:15 +02:00
jpmschweitzerandClaude Opus 4.8 9b4ee63db4 chore(plan): activate T-302 (picked up for the settings wireframe)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:12:07 +02:00
jpmschweitzerandClaude Opus 4.8 2e4f87455a feat(vim): gt / gT cycle workspace tabs (T-405 part 2)
Closes out the T-403 cross-pane vim layer. gt/gT bind to the existing
workspace.tab.next/previous commands (also on ctrl+pagedown/up for every
preset), resolved by the focused editor's matcher or a pane's PaneKeyNav —
bare-g sequences stay editor/pane-local (T-404's global matcher only engages
on modified-chord prefixes), so no global-matcher surgery.

- vim.yaml: g t -> command:workspace.tab.next, g shift+t -> .previous
  (vim.normal); shares the `g` prefix with `g g` (docStart / nav.top),
  distinguished by the final chord.
- PaneKeyNav now EXECUTES non-editor.vim.* command intents (e.g.
  workspace.tab.*) instead of swallowing all command intents, so gt/gT work
  from a focused pane; editor.vim.* buffer edits stay blocked in panes.

Tests: vim-preset resolution (gt/gT bind, gg intact), PaneKeyNav executes
the command from a pane, and editor.vim.* is never run from a pane. make
test green; analyze + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:08:07 +02:00
jpmschweitzerandClaude Opus 4.8 3a59d5be8a chore(plan): close T-407 (ex-line overlay; review skipped per user)
User opted to skip the live GUI review; improvement tickets will be filed
if anything needs polish. Completes all built children of T-403; only
T-405 part 2 (gt/gT) remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:57:28 +02:00
jpmschweitzerandClaude Opus 4.8 e54d5263e0 feat(vim): ex command-line overlay (:w :q :wq :x :e :N, ZZ) (T-407)
Under the Vim preset, `:` opens a transient one-line ex overlay running a
fixed v1 table; ZZ runs :wq directly. Completes the last built child of the
T-403 cross-pane vim layer (T-405 part 2 gt/gT still open).

- ExLineController + parseExCommand grammar + editor-targeted executors
  (lib/kernel/src/ex_line.dart); the overlay (lib/widgets/src/ex_line_overlay
  .dart) reuses the quick-open chrome, mounts in the root_shell Stack, and
  publishes the exline.open scope flag. Unknown commands flash + stay open;
  with no active buffer every command no-ops (2026-06-13 decision).
- :q closes the active tab via editor.close on its id — the registry promotes
  the next buffer and the split self-collapses on the last (2026-06-12
  decision); :w/:wq/:x/ZZ save (+close) the active buffer.
- :e <path> seeds quick-open (new QuickOpenController.open(seed:)); :N adds the
  editor.goto-line IPC/CLI verb (reuses _offsetForLine). Goto needs caret sync:
  EditorController now handles editor.selection-changed and the editor view
  moves the caret on a selection-only change.
- `:` and ZZ are typed intents; the editor matcher and PaneKeyNav now bubble
  unhandled typed intents to the app-root Actions, so they fire from any focus.
  vim.yaml binds `:`, ZZ (shift+z shift+z), and Esc-dismiss.

Tests: parser/controller/executors, editor.goto-line daemon tests,
selection-changed (controller + view), full overlay widget test. make test
green; analyze + format clean.

Also files T-441 (drop bold from the ticket-id card label) and T-442
(sub-agent renders as 3 cards instead of one bundle) under the T-276 UI epic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:44:48 +02:00
dehlakandClaude dfcfeed8b1 file T-440: build + vendor tree-sitter.dll for Windows
Split the Windows-DLL slice out of T-25 (cross-OS libtree-sitter
build). The FFI loader already resolves tree-sitter.dll on Windows,
but no Windows binary is vendored — only the Linux .so — so
tree-sitter highlighting silently falls back to plain text on
Windows. T-440 is the narrow task to build and vendor that DLL now;
CI reproducibility and the macOS .dylib stay on the T-25 umbrella.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-15 19:03:33 +02:00
jpmschweitzerandClaude Opus 4.8 c5e54ee0ac chore(plan): close T-439 (login-shell PATH verified via Linux menu launch)
Acceptance gap confirmed closed: a make-install build launched from the
Linux application menu (not a terminal) resolves the login-shell PATH —
pql/git/claude/PTY children find user-installed tools. macOS Homebrew
path remains code-correct; a separate bug will be filed if it surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:03:19 +02:00
jpmschweitzerandClaude Opus 4.8 02950cd853 chore(plan): close T-437 (/clear fix verified live in GUI)
Live-confirmed on CLI 2.1.177: /clear clears the primary pane to an
empty conversation without exiting code 1. The await-death teardown fix
(081678e) holds in the running app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:59:36 +02:00
jpmschweitzerandClaude Opus 4.8 ffcf17a136 chore(plan): close T-438 (web fence done); file T-440 (Playwright e2e follow-up)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:57:49 +02:00
jpmschweitzerandClaude Opus 4.8 03d053274e fix(env): one login-shell-derived PATH for every spawned tool (T-439)
Desktop/dock-launched clide inherits a sparse PATH (no ~/.local/bin, brew,
nvm, …), so pql/git/claude and PTY tools went missing. T-347 fixed only the
toolchain/pql path on Linux; env.dart's expander was still macOS-only and
claude/PTY/git used the raw PATH — the breakage recurred per spawn site
because there were three divergent expanders.

Consolidate into one resolver (lib/src/env/shell_env.dart):
- primeLoginShellPath(): probe the user's real login shell once at startup
  (`$SHELL -l -c`, sentinel-framed, bounded timeout, graceful fallback to the
  process PATH). Captures the user's actual PATH, not a hardcoded guess.
- expandToolPath(): the canonical merge (moved from toolchain_paths, which
  re-exports it for its tests) — unions the well-known user/local bin dirs.
- resolvedToolPath(): currentSearchPath() + expandToolPath, the single call
  every spawn site uses.

Routed through it: PTY children (registry.dart now overrides PATH), git
(env.dart → operations.dart), the toolchain probe (toolchain_paths), and
hosted claude (agent_bootstrap). Primed in main.dart's !kIsWeb boot. Deleted
the macOS-only env.dart copy and the cli_install copy.

Tests: new shell_env_test (probe + every fallback + merge); env_test and
cli_install_test updated to the consolidated surface. analyze clean, web wasm
build still green, make test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:46:11 +02:00
jpmschweitzerandClaude Opus 4.8 ca08c2a17d feat(web): fence dart:ffi behind web stubs so the WASM build compiles (T-438, D-100)
`flutter build web --wasm` had been broken since the tree-sitter/PTY dart:ffi
pivot. Per D-100 (resolving Q-50: keep the web "happy accident" alive), every
native binding now sits behind a `dart.library.ffi` conditional import with a
graceful web stub. Desktop builds are unchanged — no fidelity loss; the web
target degrades (no terminal, native git, or syntax highlighting).

Discriminator is `dart.library.ffi`, not `dart.library.io` — dart2wasm provides
dart:io, so FFI is the only blocker.

Fences:
- PTY: pty_session → pty_backend_io / pty_backend_web (stub throws).
- tree-sitter: pure types → syntax_result.dart; tree_sitter_service is now a
  facade over _ffi/_stub; tree_sitter_boot_io/stub fences TreeSitterLib.init().
- watchdog: watchdog_windows_stub (all -1 sampler).
- claude ABI probe: native_abi_io/stub (was `dart:ffi show Abi`).
- testmode fd-check: fd_check_io/stub.

Also dart2js-safe: the 64-bit FNV literals in session_naming.dart + paths.dart
(the dual JS fallback rejected them) — split into 32-bit halves, dropped a
no-op 64-bit mask. Desktop/wasm hash values unchanged.

CI: added a `web-wasm` job (flutter build web --wasm) so the fence can't rot.
Two FFI-constructing tree-sitter tests import _ffi.dart directly (the analyzer
resolves the conditional facade to the stub branch).

Verified: `flutter build web --wasm` → built; `flutter analyze` clean;
`make test` green. Full Playwright e2e harness wiring is the tracked follow-on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:31:24 +02:00
jpmschweitzerandClaude Opus 4.8 967db2f8d9 chore(governance): sync DQR index for D-100 / D-32 / Q-50
Regenerated governance/README.md index (pql decisions sync): D-32
retitled, D-100 added, Q-50 moved to Resolved. Belongs with 4bba2ef.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:36:53 +02:00
jpmschweitzerandClaude Opus 4.8 4bba2ef2f0 D-100 (fence dart:ffi) + amend D-32 (GitHub Actions); close T-384
- D-32 amended: Gitea-primary/not-activated → GitHub Actions, active
  (Linux test + Windows ConPTY/soak + release; web-WASM e2e withheld).
  The staged Gitea pipeline was never activated and is gone.
- Q-50 resolved → D-100: FENCE the web/WASM target. Every dart:ffi
  importer goes behind a conditional-import facade with a web stub so
  `flutter build web --wasm` compiles; desktop fidelity untouched. Keeps
  the web "happy accident" alive as a hopeful future target per user.
- T-438 filed for the fence implementation (12 ffi importers + CI wasm
  compile gate + re-enable e2e/ui targets).
- T-384 closed (done): Gitea premise OBE, scripts repointed (2026-06-12),
  D-32 reconciled; the dead e2e targets delegated to T-438.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:29:11 +02:00
jpmschweitzerandClaude Opus 4.8 081678e2f7 fix(claude): /clear no longer kills the session on CLI 2.1.177 (T-437)
/clear tore the session down and respawned on the same deterministic
--session-id BEFORE the old claude process had actually exited. The
orchestrator's close() ran conversation.dispose() unawaited and kill()
only sent SIGTERM without awaiting exitCode, so the respawn raced a
still-alive holder of the id — claude 2.1.177 rejects it as "Session ID
… is already in use" and exits 1.

Root cause confirmed from clide's own crash log + isolated probes against
2.1.177: the id frees the instant the holder dies (SIGTERM cleans the new
~/.claude/sessions/<pid>.json registry), so awaiting real death is the
fix — and it preserves T-268's deterministic-id continuity (chosen over
minting a fresh id, which would change the continuity model).

- stream_json_session: kill() awaits exitCode (SIGTERM → 2s → SIGKILL →
  await); dispose() idempotent (shared cached future); new
  SessionEnd.reason getter (last non-empty stderr line, capped).
- session_orchestrator: close() awaits session.dispose() so teardown
  returns only once the process is truly dead, before clear + respawn.
- claude_pane: surface end.reason in the status line — no more opaque
  "code 1".
- session_naming: correct the stale clearSessionTranscript doc (real
  sidecar is the shared memory/ dir) + the await-death precondition.
- tests: close() blocks until process exit; SessionEnd.reason.

CLI 2.1.177 re-probe (folded-in scope): sessions/ registry characterized
(PID-keyed, cleaned on exit); init cache auto-refreshes; advertised
slash_commands show no routing-table drift. No further code change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:32:02 +02:00
jpmschweitzerandClaude Opus 4.8 708d4c6f95 T-437: fold the CLI 2.1.177 re-probe into scope + acceptance
The init-cache/routing-table refresh and sessions/ registry
characterization are now explicit deliverables of T-437, not a
separate follow-up, with updated acceptance criteria.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:31:55 +02:00
jpmschweitzerandClaude Opus 4.8 8f962226a0 file T-437: /clear kills the Claude session (exit 1) on CLI 2.1.177
Regression of T-268's /clear mechanism. /clear deletes the transcript and
respawns with the same deterministic --session-id, but claude 2.1.177 now
tracks session ids in ~/.claude/sessions/<pid>.json (+history.jsonl) beyond
the per-project transcript clide purges, so the id reads as in-use and
claude exits 1 at startup validation. Codebase only probed <=2.1.175.

Hypothesis is strong but unconfirmed: the pane shows an opaque "code 1" and
swallows claude's stderr — capturing it is fix step 1. Filed high with
ranked fixes (mint fresh id on clear; or clean the registry; surface stderr).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:23:09 +02:00
74 changed files with 4531 additions and 494 deletions
@@ -112,9 +112,37 @@ tooltip → tooltipBackground / tooltipForeground / tooltipBorder
dropdown → dropdownBackground / dropdownForeground / dropdownBorder dropdown → dropdownBackground / dropdownForeground / dropdownBorder
``` ```
## Settings & grouped lists — sectioned cards
Settings surfaces and any long grouped list (e.g. the Claude config lists)
read as **sectioned cards**, not bare rows floating on the panel. Each logical
group gets its own card; the small-caps section label (+ optional count) sits
just **above** the card.
```
panel bg → panelBackground (#20202C)
card surface → surface (#242838) fill + dividerColor/border (1px), ~6px corners
section head → sidebarSectionHeader (small-caps), with the count muted to its right
control inset → inputs INSIDE a card recede to panelBackground, so they still
read as fields against the elevated card
```
- **One card per group** — a settings table, each config list. The card's
elevated fill + border do the visual separation; don't rely on spacing alone.
- **Field row inside a card:** label (`globalForeground`) + help
(`globalTextMuted`) + the control right-aligned, with the per-field scope tag
in the far-right column.
- **Scroll, don't cram:** when stacked cards exceed the modal/pane viewport, the
pane scrolls vertically (sticky header, scrolling body) — prefer that over
shrinking content to fit one screen.
- Pattern reference: the settings wireframes under
`docs/design/wireframes/settings/` (T-302).
## Anti-patterns ## Anti-patterns
- `globalBackground` for panel fill → use `panelBackground` - `globalBackground` for panel fill → use `panelBackground`
- Bare settings rows on the panel where a group of them should be one card →
see "Settings & grouped lists".
- `listItemHoverBackground` in sidebar → use `sidebarItemHover` - `listItemHoverBackground` in sidebar → use `sidebarItemHover`
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome) - Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
- Tab active border = `globalFocus` → use `panelActiveBorder` - Tab active border = `globalFocus` → use `panelActiveBorder`
+15 -7
View File
@@ -114,13 +114,21 @@ jobs:
path: ${{ github.workspace }}/clide-logs path: ${{ github.workspace }}/clide-logs
if-no-files-found: ignore if-no-files-found: ignore
# The web-WASM Playwright job is withheld: `flutter build web --wasm` cannot web-wasm:
# compile the tree since the tree-sitter/PTY dart:ffi pivot (dart:ffi is name: web build (wasm compile gate)
# unavailable on the wasm target). Whether the web target gets conditional- runs-on: ubuntu-latest
# import fences or is dropped is an open question — see Q-50 in steps:
# governance/questions/architecture.md. Re-add the job (setup-node, npm - uses: actions/checkout@v4
# install + playwright install in tools/ui, `make test-e2e`) when Q-50 - uses: subosito/flutter-action@v2
# resolves toward keeping it. with: { channel: stable, cache: true }
- run: flutter pub get
# Compile gate for the dart:ffi web fence (D-100 / T-438, resolving Q-50):
# every native binding lives behind a `dart.library.ffi` conditional import
# with a web stub. If a new one lands without its stub, this fails — the
# fence can't silently rot. Build-only for now; the full web-WASM Playwright
# e2e (`make test-e2e`: setup-node + playwright install in tools/ui +
# serve) is the follow-on once the harness is wired back up.
- run: flutter build web --wasm
docs: docs:
name: dart doc (lib API) name: dart doc (lib API)
+717
View File
@@ -5088,3 +5088,720 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'cce7ddffbd88447be319407f9cc7ce91', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'cce7ddffbd88447be319407f9cc7ce91', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'f9a33140c03031c10ca45fecb2edb277', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'f9a33140c03031c10ca45fecb2edb277', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'status', 'backlog', 'done', NULL, '2026-06-15 10:34:31', '2026-06-15 10:34:31', '2026-06-15 10:34:31', NULL, 'b9cda82863c37a365e7aa5fc8230256a', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'status', 'backlog', 'done', NULL, '2026-06-15 10:34:31', '2026-06-15 10:34:31', '2026-06-15 10:34:31', NULL, 'b9cda82863c37a365e7aa5fc8230256a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'description', NULL, '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).', NULL, '2026-06-15 11:12:40', '2026-06-15 11:12:40', '2026-06-15 11:12:40', NULL, '8d437ab6db22ffa01ac237821bf446c5', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'description', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).', NULL, '2026-06-15 11:30:26', '2026-06-15 11:30:26', '2026-06-15 11:30:26', NULL, '2ab7f67318159b7b9a8b26eeefa2ed8d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 11:38:50', '2026-06-15 11:38:50', '2026-06-15 11:38:50', NULL, 'c0d2e8bddc695f3cc0afdfd728aed3a6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 11:40:04', '2026-06-15 11:40:04', '2026-06-15 11:40:04', NULL, 'd858bb1bd89be62a0d00d564229a2517', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'description', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).
---
**RESOLUTION (2026-06-15) root cause confirmed, fixed.**
**Actual cause (confirmed against real stderr, NOT the original collision-vs-registry guess).** Captured from clide''s own crash log (`~/.local/state/clide/logs/clide.log`):
`session primary exited (code 1); stderr tail: Error: Session ID <id> is already in use.`
But the "in use" is a **lifecycle race**, not a persistent registry/transcript collision. Isolated probes against claude 2.1.177 proved:
- `--session-id` reuse SUCCEEDS once the prior holder is dead — even immediately after SIGTERM (the `~/.claude/sessions/<pid>.json` entry is cleaned on a SIGTERM exit), and even with another *live* duplicate in `-p` mode.
- A transcript-absent id never collides.
The only way real clide hit "already in use": the **old process was still alive** at respawn. `ClaudeSessionOrchestrator.close()` called `conversation.dispose()` (unawaited) and `ClaudeStreamJsonProcess.kill()` sent SIGTERM **without awaiting `exitCode`** so `/clear` deleted the transcript and respawned on the same deterministic `--session-id` while the old `claude` was still alive (and re-flushing its transcript). 2.1.177 then rejects the id → exit 1.
**Fix shipped (chose await-death over the ticket''s tentative "mint a fresh id").** Awaiting the old process''s real death is the targeted root-cause fix and *preserves T-268''s deterministic-id continuity* (fresh-id would have required persisting a per-repo current-id and changing the continuity model). Changes:
- `stream_json_session.dart`: `kill()` now awaits `exitCode` (SIGTERM 2s SIGKILL await); `dispose()` is idempotent (cached future) so the conversation''s unawaited dispose and the orchestrator''s awaited one share one teardown; new `SessionEnd.reason` getter (last non-empty stderr line, capped).
- `session_orchestrator.dart`: `close()` awaits `session.dispose()` returns only once the process is truly dead, so the transcript clear + respawn happen after death.
- `claude_pane.dart`: `_onSessionEnd` surfaces `end.reason` in the status line no more opaque "code 1" (fix #3).
- `session_naming.dart`: corrected the stale `clearSessionTranscript` doc (the real sidecar is the shared `memory/` dir, not `<id>/`; left untouched) + the await-death precondition.
- Tests: `close()` blocks until process exit (gated fake); `SessionEnd.reason` extraction/cap/empty.
**Folded-in CLI 2.1.177 re-probe done, no code change needed.**
- `~/.claude/sessions/<pid>.json` registry characterized: PID-keyed, carries `{sessionId, cwd, version, kind:"interactive", entrypoint:"sdk-cli", status}`; written at start, **removed on a SIGTERM/clean exit**. So once clide awaits death, the id is free.
- Init cache (`init-<version>.json`) auto-refreshes at runtime per version nothing to hand-edit.
- Probed 2.1.177 advertised `slash_commands` (31): no drift that breaks the routing table none of clide''s `kTuiOnlyCommands` became advertised, and new entries (skills/builtins) correctly fall through to `forward`.
**Verification status.** Unit-tested (the teardown ordering + reason surfacing) and validated against the live CLI via probes. `make test` green. NOT yet exercised in the running GUI (would need `make run` + an interactive `/clear`) recommend a quick live confirm before closing.', NULL, '2026-06-15 13:30:11', '2026-06-15 13:30:11', '2026-06-15 13:30:11', NULL, 'ca1b8cc05faf1bf385494030cbc9eb2f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'in_progress', 'review', NULL, '2026-06-15 13:30:21', '2026-06-15 13:30:21', '2026-06-15 13:30:21', NULL, '2f16ac8313a7e627166ae842ba7c096c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'description', NULL, 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', NULL, '2026-06-15 14:22:06', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, 'ab5a072e75e46831da1980d36292b84a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'description', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.
---
**Closed 2026-06-15 reconciled.**
The Gitea premise is overtaken by events: CI migrated to **GitHub Actions** (`8e0f33b` Linux, `45aa2d9` Windows/release) the staged Gitea workflow this bug was about was never activated and is gone (only `legacy/.gitea/` remains). Resolution of the three acceptance criteria:
1. *Workflow steps map to working targets* done by removal: no Gitea workflow exists; the GitHub `test.yml` goes through `make` targets and withholds the web-WASM e2e job with a Q-50 pointer.
2. *`tools/ui` scripts repointed off the removed `app/`* done 2026-06-12 (`41af83f`).
3. *D-32 matches reality* done now: D-32 amended to "GitHub Actions, active".
The one live remnant the dead `make test-e2e` / `ui-dev` / `ui-smoke` targets was a pending **decision**, now made: Q-50 resolved **fence** (D-100). The actual fencing implementation (and re-enabling the e2e targets + CI job) is tracked in **T-438**. Nothing left to do under this ticket.', NULL, '2026-06-15 14:25:07', '2026-06-15 14:25:07', '2026-06-15 14:25:07', NULL, '2c0ef2348b5b496eb67035f42cad5d5f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'status', 'review', 'done', NULL, '2026-06-15 14:25:10', '2026-06-15 14:25:10', '2026-06-15 14:25:10', NULL, 'fba93f08e50b81ab6c3c4b5d1dd4f1d9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 14:32:22', '2026-06-15 14:32:22', '2026-06-15 14:32:22', NULL, '80f8ce4f94230fe7c842bd25e1b7f131', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 14:33:11', '2026-06-15 14:33:11', '2026-06-15 14:33:11', NULL, '783dee14da908512f766c8d9b0351e1d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'description', NULL, '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, ) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` `expandedPath` **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` `_expandedPath()` augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` `expandedPath(base, {macOS, home})` a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', NULL, '2026-06-15 15:23:47', '2026-06-15 15:23:47', '2026-06-15 15:23:47', NULL, 'c5a27e255cd62d26167231269985ba39', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'description', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', NULL, '2026-06-15 15:30:48', '2026-06-15 15:30:48', '2026-06-15 15:30:48', NULL, '0cf096a810a7a075307c8df8321b5c33', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'review', NULL, '2026-06-15 15:30:48', '2026-06-15 15:30:48', '2026-06-15 15:30:48', NULL, 'a7d81444607b6ec3aa8440fcbc9abee3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 15:31:15', '2026-06-15 15:31:15', '2026-06-15 15:31:15', NULL, '5fed82be61479c8191ed8d0663507cad', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 15:32:16', '2026-06-15 15:32:16', '2026-06-15 15:32:16', NULL, 'c1e48da490b71dea20c50a486600b643', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'description', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, ) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` `expandedPath` **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` `_expandedPath()` augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` `expandedPath(base, {macOS, home})` a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, ) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` `expandedPath` **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` `_expandedPath()` augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` `expandedPath(base, {macOS, home})` a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.
---
**Implemented 2026-06-15.** One shared resolver `lib/src/env/shell_env.dart`:
- `primeLoginShellPath()` probes `$SHELL -l -c` once at startup (sentinel-framed, 4s timeout, graceful fallback to the process PATH on Windows/empty-SHELL/non-zero/timeout/spawn-failure). Primed in `main.dart`''s `!kIsWeb` boot.
- `expandToolPath()` the canonical user/local-dir merge (moved from toolchain_paths, which re-exports it for its tests).
- `resolvedToolPath()` the single call every spawn site uses.
Routed through it: PTY children (`registry.dart` overrides PATH), git (`env.dart` `operations.dart`), the toolchain probe (`toolchain_paths.dart`), hosted claude (`agent_bootstrap.dart`). Deleted the macOS-only `env.dart` expander and the `cli_install.dart` copy three expanders one.
Verified: `flutter analyze` clean; new `shell_env_test.dart` covers the probe + every fallback + the merge; `env_test`/`cli_install_test` updated; `make test` green; `flutter build web --wasm` still green (resolver is web-safe, probe is `!kIsWeb`).
**Why `review`, not `done`:** logic + tests cover Linux and macOS, but the real *desktop/dock-launch* behavior (the actual GUI process inheriting a sparse PATH) can only be confirmed by launching a packaged build from the launcher recommend a quick live confirm (open clide from the dock, check pql/git/a `~/.local/bin` tool resolve). macOS Homebrew path is code-correct but unverified on a Mac.', NULL, '2026-06-15 15:47:48', '2026-06-15 15:47:48', '2026-06-15 15:47:48', NULL, 'feaf3593dabcb0b7d7681043dddddbb3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'in_progress', 'review', NULL, '2026-06-15 15:47:49', '2026-06-15 15:47:49', '2026-06-15 15:47:49', NULL, '9b7ed7d84e12574200900604143e866c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'description', NULL, 'Follow-up from T-438 (the `dart:ffi` web fence, D-100): with `flutter build web --wasm` compiling again and a compile gate in CI, restore the *full* web-WASM Playwright e2e — the remaining slice of T-438''s acceptance.
**What''s already done (T-438).** The web build compiles; `.github/workflows/test.yml` has a `web-wasm` job that runs `flutter build web --wasm` (the anti-rot compile gate); `make ui-dev` (build + serve) works.
**What this ticket adds.** The actual browser e2e, which needs runner provisioning the compile gate doesn''t:
- `make test-e2e` / `make ui-smoke` run green locally and in CI (build serve `localhost:4280` Playwright smoke teardown).
- A GitHub Actions job: `setup-node`, `npm install` + `npx playwright install --with-deps` in `tools/ui/`, then `make test-e2e`. Add it to `.github/workflows/test.yml` (replacing the compile-only `web-wasm` job, or as a second job that `needs` it).
- Confirm the Playwright driver (D-26) still matches the current web entrypoint after the fence (degraded web build: no terminal/native-git/highlighting the smoke should assert what *does* render, e.g. the shell boots and a pane mounts).
**Acceptance.** `make test-e2e` and `make ui-smoke` pass locally; a CI job runs the Playwright smoke against the wasm build on every push/PR; D-26/D-32 reflect the restored e2e job.
**Refs:** T-438 (compile fence + gate), D-100 / Q-50 (fence decision), D-26 (Playwright driver), D-32 (CI; the withheld e2e job).', NULL, '2026-06-15 15:53:35', '2026-06-15 15:53:35', '2026-06-15 15:53:35', NULL, '14d8eb1f3bc639cb4ce3cddb5179a853', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'description', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.
Closed 2026-06-15: the dart:ffi fence + flutter build web --wasm compile gate are landed, verified (ca08c2a), and on origin/main. The remaining acceptance slice — the full web-WASM Playwright e2e harness wired into CI (make test-e2e / ui-smoke + the node/playwright runner job) — is split out to T-440, since it needs runner provisioning separate from the fence itself.', NULL, '2026-06-15 15:53:42', '2026-06-15 15:53:42', '2026-06-15 15:53:42', NULL, '0b238079758d9882647e0a2fcec7cbe7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'review', 'done', NULL, '2026-06-15 15:53:47', '2026-06-15 15:53:47', '2026-06-15 15:53:47', NULL, '799f878cdf6c8f6b9256fb852b4c4537', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'review', 'done', NULL, '2026-06-15 15:59:15', '2026-06-15 15:59:15', '2026-06-15 15:59:15', NULL, '96e17294ee3609fa7f2dd9a69fa38fa6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'review', 'done', NULL, '2026-06-15 16:03:07', '2026-06-15 16:03:07', '2026-06-15 16:03:07', NULL, '75bf3f398e16ec94668b9d7937c33a4e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'status', 'in_progress', 'backlog', NULL, '2026-06-15 16:14:56', '2026-06-15 16:14:56', '2026-06-15 16:14:56', NULL, '085d54bd54f253b684cea7fd61638cee', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'description', NULL, 'The ticket-id label at the top of each kanban card (e.g. "T-403") is rendered in semibold and is hard to read at the small card font size — user feedback 2026-06-16 (screenshot: ~/.cache/clide/pasted/paste-1781601250588.png).
**Fix:** drop the bold weight from the id label. lib/builtin/tickets/src/tickets_view.dart:405 `ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600)` remove `fontWeight: FontWeight.w600` so it renders at the default UI weight. Confirm the parent-id breadcrumb line (the muted ` T-NNN` above a child, ~line 385-395) still reads fine; keep the title (line 409) as-is.
Scope: cosmetic weight tweak only. Verify against the four presets/themes; no golden churn expected beyond the tickets-view widget golden if one exists.', NULL, '2026-06-16 09:16:18', '2026-06-16 09:16:18', '2026-06-16 09:16:18', NULL, '3b414a2fe4ba72623f59d3e461082bf7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'description', NULL, 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781601988494.png, observed in the settled-reach session). An Agent/Task tool interaction renders as THREE sibling top-level cards instead of one bundled, collapsed card:
1. `> Agent Implement T-1039+T-1043 dispatch threading` 1 step, </> badge (the Agent tool-use collapser)
2. `> agent run Edit //server/src/atlas/plugin.rs` 65 steps
3. `> agent prompt You are DUDLEY, server developer`
EXPECTED (per T-305 collapser + T-338 sidechain fold): the sub-agent''s run + prompt + its 65 steps should be bundled INSIDE the single Agent collapser card, not surfaced as siblings. The ''1 step'' on the Agent card next to a separate ''65 steps'' card is the tell the step count that belongs to the agent landed on a different card than its work.
QUESTIONS TO RESOLVE:
- Is the three-card split a regression in the sidechain fold, or are ''agent run''/''agent prompt'' a distinct (hosted-agent) render path that never went through the collapser? Determine which.
- The stray/inconsistent left indent on these cards (user also flagged) confirm and remove so the bundle reads as one unit.
CODE POINTERS: lib/builtin/claude/src/activity_cluster.dart (isAgentTool: Task|Agent, line 83); lib/builtin/claude/src/transcript_reader.dart (isSidechain / parentToolUseId fold, ~line 571-575; subagents/agent-<id>.jsonl, line 10); lib/builtin/claude/src/conversation_view.dart (card rendering). Check whether ''agent run''/''agent prompt'' items carry parentToolUseId and whether the fold groups them under the Agent tool_use id.
Scope: diagnose first (bug vs two-render-paths), then bundle the sub-agent interaction into the single collapser + fix the indent. Widget test for the folded sub-agent card.', NULL, '2026-06-16 09:27:53', '2026-06-16 09:27:53', '2026-06-16 09:27:53', NULL, '8a6a6a533fd388d09a31a1f23af55fa5', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.
IMPLEMENTED 2026-06-16. Built the ex command-line overlay end to end. Architecture chose the QuickOpen precedent over the ticket''s tentative vim-builtin layout (cleaner, no extension/ctx plumbing):
- ExLineController + parseExCommand grammar + editor-targeted executors in lib/kernel/src/ex_line.dart (controller mirrors QuickOpenController; executors go through editor IPC verbs).
- ExLineOverlay in lib/widgets/src/ex_line_overlay.dart, mounted in the root_shell Stack beside QuickOpenOverlay; publishes the exline.open scope flag; rejected commands flash a red border + "Not an editor command" hint and stay open (no timer test-friendly).
- v1 table: :w (editor.save, active-default), :q (editor.close active id; registry promotes next / collapses split on last, per the 2026-06-12 decision), :wq/:x and ZZ (save+close), :e <path> (quickOpen.open(seed:) new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
NOT yet exercised in the running GUI recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', NULL, '2026-06-16 09:42:34', '2026-06-16 09:42:34', '2026-06-16 09:42:34', NULL, 'c53afd4a1354012e36081b84dab66bfd', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'ready', 'review', NULL, '2026-06-16 09:42:39', '2026-06-16 09:42:39', '2026-06-16 09:42:39', NULL, 'e2cc00b9f1b80080e17e0213079400f1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'review', 'done', NULL, '2026-06-16 09:56:18', '2026-06-16 09:56:18', '2026-06-16 09:56:18', NULL, 'e0ea178ba163bf3395c21793c8cf9a14', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'status', 'ready', 'done', NULL, '2026-06-16 10:07:56', '2026-06-16 10:07:56', '2026-06-16 10:07:56', NULL, '4b0584f96faa90f0a715dde390c64008', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'status', 'in_progress', 'done', NULL, '2026-06-16 10:08:00', '2026-06-16 10:08:00', '2026-06-16 10:08:00', NULL, 'a7ee8d073cb004d8f3c53a7128cc3aef', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7H8JWCP59WQVD0FW4', 'status', 'done', 'ready', NULL, '2026-06-16 10:08:46', '2026-06-16 10:08:46', '2026-06-16 10:08:46', NULL, '6589fa3057a9c2d009805d1044988f43', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7H8JWCP59WQVD0FW4', 'status', 'ready', 'in_progress', NULL, '2026-06-16 10:09:07', '2026-06-16 10:09:07', '2026-06-16 10:09:07', NULL, '01cb8766df16793a9290d5ae0e0b4c25', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD07ECS3GK2V7Z2WYYPJHJYC', 'description', NULL, 'Implement the schema-driven settings UI (`builtin.settings-ui`) designed in T-302. Wireframes (JSON source + PNG): `docs/design/wireframes/settings/` — settings-screen (Editor), settings-search, settings-claude, settings-appearance.
**Form factor / IA (decided in T-302):** a centered MODAL overlay over the dimmed app (modal* / modalOverlay* tokens; close + Esc), opened by a `settings.open` command. Left category rail + a cross-category search box; right scrolling panel of CARDED sections each logical group is a card (surface fill + 1px border, ~6px corners) with a small-caps section label + count above it. See the new `ui-design` `surface.md` "Settings & grouped lists — sectioned cards".
**Engine, not per-screen UI:** field rows render from the schema each subsystem registers against the kernel SettingsStore toggle / enum-select / text-number / "opens external file" affordance each with label, help, default, reset, and a per-field SCOPE TAG (folder = project `.clide` / globe = always `~/.clide` / circle-dashed = default-unset; colour + tooltip; tap scope menu). Edits write back to the resolved scope''s settings file. Categories are DATA; the only custom control is Appearance''s theme picker.
**Decisions / consumers:** kernel SettingsStore schema; WidgetsApp-only tokens (D-7); keymap presets (T-115 / T-64 / T-65 / T-66); editor settings (T-290 / T-29); activity fold (T-183); theming (existing builtin.theme-picker, D-44 / D-69); Claude config mirrors `lib/builtin/claude/src/meta_sidebar/config_tab.dart` (T-414 / T-183). Phosphor glyphs resolve via `byName(...)` (T-314) no consts to add.
**Children build infra first, then categories extrapolate:**
Infra: modal shell + `settings.open`; category rail + nav; schema field renderer (the engine); scope-tag control + scope resolution; cross-category search.
Categories (register a schema, reuse the engine): Editor (T-290), Keymap, Appearance (theme-picker control), Activity, Terminal, Extensions, Claude (mirror the sidebar Config panel).', NULL, '2026-06-16 11:13:12', '2026-06-16 11:13:12', '2026-06-16 11:13:12', NULL, 'a197764303d8a520a601166822524506', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09DH88J1659FDDYJ83JH1M', 'description', NULL, 'User report 2026-06-16: typing `/` in the Claude composer **intermittently** does not open the slash-command typeahead dialog. Non-deterministic — sometimes it opens, sometimes the `/` just types with no popover.
Two hypotheses to distinguish (the fix depends which):
1. **Handler not triggering on `/`** the composer''s keypress/text-change detection that should fire the typeahead misses the `/` (focus/IME race, the `/` not at a word boundary, the listener not firing on that change). If so, the popover never opens regardless of list contents.
2. **Empty/incomplete list** the handler fires but the command list is empty or still loading (the slash-command set from the CLI probe / routing table not yet populated typeahead opens with nothing and dismisses, or suppresses on empty). If so, it correlates with session warm-up / probe timing.
**Diagnosis first:** add a log at the trigger point (did `/` fire the open?) and at list-resolution (how many entries?) to see which path fails when it reproduces. Likely correlates with session/probe readiness (intermittent timing).
**Files:** lib/builtin/claude/src/claude_composer.dart (the composer + typeahead trigger on `/`), lib/builtin/claude/src/slash_commands.dart (the command set / routing table, kClideOwnedCommands / kTuiOnlyCommands), the ClideTypeahead primitive (slash typeahead was migrated onto it). Compare with the `@`-mention typeahead (at_commands.dart), which shares the primitive does `@` reproduce the same intermittency? That isolates primitive vs slash-specific.
**Acceptance:** `/` reliably opens the typeahead on every invocation (cold session + warm); if the list isn''t ready yet, open with a loading/empty state rather than silently not opening; a regression test around the trigger + empty-list path.', NULL, '2026-06-16 11:17:44', '2026-06-16 11:17:44', '2026-06-16 11:17:44', NULL, 'ccec4493b58264e0921ce0670e6fbe13', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09C4A0HPZSP8HP44F7A894', 'description', NULL, 'Centered modal over the dimmed app using modalSurface/modalOverlay tokens (D-7, no Material); new settings.open CommandContribution (palette + menu/hat entry); close button + Esc dismiss; hosts the category rail + scrolling carded panel; mounted in root_shell like the other overlays. Wireframe: docs/design/wireframes/settings/settings-screen.png (modal shell).', NULL, '2026-06-16 11:17:54', '2026-06-16 11:17:54', '2026-06-16 11:17:54', NULL, '71d80701bc0f7a46d2a58a543143ff5a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09SHDMGT66C4D1DRZQZ7RR', 'description', NULL, 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', NULL, '2026-06-16 11:18:16', '2026-06-16 11:18:16', '2026-06-16 11:18:16', NULL, '0ed6ee0b39a849c77fa8773a83576f40', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'description', NULL, 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', NULL, '2026-06-16 11:18:27', '2026-06-16 11:18:27', '2026-06-16 11:18:27', NULL, '25283a37d7f7450ed7f53c5c9578382c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'description', NULL, 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', NULL, '2026-06-16 11:18:41', '2026-06-16 11:18:41', '2026-06-16 11:18:41', NULL, 'a70401823b69148f142ac32844ece558', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'description', NULL, 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', NULL, '2026-06-16 11:18:51', '2026-06-16 11:18:51', '2026-06-16 11:18:51', NULL, '63bdec18d4c8933b8c9c491d1bdec82b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM44CKN5QQGFKP1KWZC', 'parent_id', 'T-8', 'T-444', NULL, '2026-06-16 11:19:03', '2026-06-16 11:19:03', '2026-06-16 11:19:03', NULL, '80eef9832e6ef2048aed0b02bbfc2765', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'description', NULL, 'Register the keymap settings schema: preset enum select (default / vscode / jetbrains / vim — T-115, T-64, T-65, T-66) wired to KeymapService.setPreset, plus related options (e.g. sequence-timeout). Reuses the schema field renderer (T-448).', NULL, '2026-06-16 11:19:35', '2026-06-16 11:19:35', '2026-06-16 11:19:35', NULL, '1378cada399c128b9a3f5c45721c6088', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A4MY8DF6NB0KNV96RQADR', 'description', NULL, 'The one custom control beyond the four field patterns: a theme-picker swatch grid with live mini-previews of the bundled themes (D-44 / D-69), selected state, and import-custom-YAML; plus high-contrast toggle and text-size. Coordinate with / extend the existing builtin.theme-picker (T-8) and per-repo theme persistence (T-293). Design pinned in T-302. Wireframe: docs/design/wireframes/settings/settings-appearance.png.', NULL, '2026-06-16 11:19:47', '2026-06-16 11:19:47', '2026-06-16 11:19:47', NULL, 'e4d1aa3170384cb44c6099528db76ac0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A6K9B60ADTFBZA6NBVJFM', 'description', NULL, 'Register the Claude-activity settings schema: activity fold level (T-183) and related conversation display options, via the standard field renderer (T-448).', NULL, '2026-06-16 11:20:21', '2026-06-16 11:20:21', '2026-06-16 11:20:21', NULL, '658bd31d93101ce02bdbf34871d2d032', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0ABC7QNEC3XCTV73YPSGR4', 'description', NULL, 'User report 2026-06-16: Claude Code''s **remote-control** feature (start / monitor / steer a running Claude Code session remotely — e.g. from claude.ai or the mobile app) is **not plumbed into clide**.
clide hosts the `claude` CLI as a stream-json child (`--session-id` / `--resume`, D-77). For remote control to work through clide, a clide-hosted session needs to be discoverable + steerable by the remote-control surface the same way a terminal-launched `claude` is.
**Step 1 characterize (don''t assume the mechanism):** determine exactly what Claude Code exposes for remote control on the running CLI version. Likely touch points:
- the `~/.claude/sessions/<pid>.json` live-session registry we characterized in T-437 (PID-keyed: sessionId, cwd, version, kind, entrypoint, status) is this what the remote-control surface enumerates? clide-spawned sessions DO write an entry (we saw `entrypoint: sdk-cli`). Confirm whether clide''s entry is visible/controllable remotely, or whether `entrypoint: sdk-cli` / headless spawn excludes it.
- any flag / capability in the `initialize` stream-json probe (T-411/T-151 cache) advertising remote control.
- whether enrollment requires the interactive TUI (and so is suppressed under clide''s stream-json spawn).
**Step 2 plumb:** wire clide so a clide-hosted session participates in remote control (register correctly / pass the needed flag), and surface its state in the UI (e.g. an indicator that the session is remote-controllable / being driven remotely). If it conflicts with clide''s session ownership (pane pins a session-id, T-156), document the boundary.
**Open question:** is the goal (a) clide sessions become remotely controllable from claude.ai/mobile, or (b) clide can act as a remote controller of other sessions? Default read is (a). Confirm with the user before building.
Related: D-77 (stream-json session model), T-437 (sessions registry characterization), T-156 (clide owns session lifecycle), T-411 (capability probe).', NULL, '2026-06-16 13:20:17', '2026-06-16 13:20:17', '2026-06-16 13:20:17', NULL, 'b5428fe4b6cfec075693b19f46d0203d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'description', NULL, 'Register the terminal settings schema (font family/size, scrollback, cursor style, bell, copy-on-select, etc.) as standard toggle / number / select fields via the field renderer (T-448).', NULL, '2026-06-16 13:20:34', '2026-06-16 13:20:34', '2026-06-16 13:20:34', NULL, '6ed2b34154ae47dc645f570889ea8c48', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15TY946689VZK3MNABSMDW', 'description', NULL, 'List installed extensions and surface each extension''s registered settings (ext.<id>.* — the extension-owned config pattern from ui-design theme.md); per-extension enable/disable + configure. Reuses the field renderer; extensions register their schema the same way core subsystems do.', NULL, '2026-06-16 13:20:47', '2026-06-16 13:20:47', '2026-06-16 13:20:47', NULL, '0f2073250cde4ea3213f33008aec0cba', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15W7SBVAD3QR5NVE67PKWR', 'description', NULL, 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', NULL, '2026-06-16 13:21:00', '2026-06-16 13:21:00', '2026-06-16 13:21:00', NULL, '1efc3eadc7b93d57c205deddab67bbb3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7H8JWCP59WQVD0FW4', 'status', 'in_progress', 'done', NULL, '2026-06-16 13:21:11', '2026-06-16 13:21:11', '2026-06-16 13:21:11', NULL, '6410fd78c4919240bb5eaba9daa4f807', 2) ON CONFLICT(hash) DO NOTHING;
+22
View File
@@ -261,3 +261,25 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'T-434', '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, '403c4c8aa5659bb379cb8add9dff800b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'T-434', '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, '403c4c8aa5659bb379cb8add9dff800b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'T-435', '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, '7c2ed604aecea99b742b341166cf2257', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'T-435', '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, '7c2ed604aecea99b742b341166cf2257', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'T-437', '2026-06-15 11:12:36', '2026-06-15 11:12:36', NULL, '74dd08c8c42f556959746ee1a47561e6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-440', '2026-06-15 15:53:05', '2026-06-15 15:53:05', NULL, 'f219bb1a70eb1f49e30975ce8c526051', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'T-441', '2026-06-16 09:16:07', '2026-06-16 09:16:07', NULL, 'fbabc4fc344b34476b38a186c2f44a16', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'T-442', '2026-06-16 09:27:39', '2026-06-16 09:27:39', NULL, 'c85d8fd95cd73aec1e83b477612e1131', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCRERT7X5WMZSGKQCA6T0VB4', 'T-440', '2026-06-15 17:01:25', '2026-06-15 17:01:25', NULL, 'b8e152bf87ec8d81d1bddeb023f37883', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-443', '2026-06-15 15:53:05', '2026-06-16 10:17:57', NULL, 'a4d4390596d24d7dc28d80861e36d66b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD07ECS3GK2V7Z2WYYPJHJYC', 'T-444', '2026-06-16 11:07:54', '2026-06-16 11:07:54', NULL, 'ec7a0d8d01c984d931855a6e5c4e86ec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09C4A0HPZSP8HP44F7A894', 'T-445', '2026-06-16 11:16:20', '2026-06-16 11:16:20', NULL, '418d561d50d37c3dd9b6e3abe5fc1b1f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09DH88J1659FDDYJ83JH1M', 'T-446', '2026-06-16 11:16:31', '2026-06-16 11:16:31', NULL, '7c0bc42be1a170b5bb2dd8bd7b027d69', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09SHDMGT66C4D1DRZQZ7RR', 'T-447', '2026-06-16 11:18:10', '2026-06-16 11:18:10', NULL, '203dfb0348a94392f2d087b79aee655f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'T-448', '2026-06-16 11:18:20', '2026-06-16 11:18:20', NULL, '765a91fb5b51889861a41f7eea308ea6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'T-449', '2026-06-16 11:18:32', '2026-06-16 11:18:32', NULL, '1dbd17ff345551d3f091a226bb0f7164', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'T-450', '2026-06-16 11:18:45', '2026-06-16 11:18:45', NULL, '56f97ccefa83681c387ee9cc4ca6a477', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'T-451', '2026-06-16 11:19:11', '2026-06-16 11:19:11', NULL, 'c329f6f1a5d205b7bdf5ab682812a6d1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A4MY8DF6NB0KNV96RQADR', 'T-452', '2026-06-16 11:19:41', '2026-06-16 11:19:41', NULL, '71a7729a8cf7ebc4e915a9e2736717bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A6K9B60ADTFBZA6NBVJFM', 'T-453', '2026-06-16 11:19:57', '2026-06-16 11:19:57', NULL, 'd408d563785166ee1b40e7494c7f3bbc', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0ABC7QNEC3XCTV73YPSGR4', 'T-454', '2026-06-16 11:20:36', '2026-06-16 11:20:36', NULL, '27e2ae5b0897eb0f589f4617061101a9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'T-455', '2026-06-16 13:20:25', '2026-06-16 13:20:25', NULL, '370e913c62eab739cf39bda08089e033', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15TY946689VZK3MNABSMDW', 'T-456', '2026-06-16 13:20:41', '2026-06-16 13:20:41', NULL, '6041202d66dd93ee3d22206ece3a7db7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15W7SBVAD3QR5NVE67PKWR', 'T-457', '2026-06-16 13:20:52', '2026-06-16 13:20:52', NULL, 'c9bef415fb7d9d03444fde53ecac3bb1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+6
View File
@@ -1,2 +1,8 @@
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'multi-window', '2026-06-14 15:29:27', '2026-06-14 15:29:27', NULL, 'ce03f8eb534bc45e2c1c12e9b30a2c30', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash); INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'multi-window', '2026-06-14 15:29:27', '2026-06-14 15:29:27', NULL, 'ce03f8eb534bc45e2c1c12e9b30a2c30', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'ipc', '2026-06-14 15:29:28', '2026-06-14 15:29:28', NULL, 'e6789b66db5c1de85daf6fc8d3474449', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash); INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'ipc', '2026-06-14 15:29:28', '2026-06-14 15:29:28', NULL, 'e6789b66db5c1de85daf6fc8d3474449', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'regression', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, 'd1aee45df369a9afed1ecc7bc6043255', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'claude-cli', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, '2d486f878edbd7a898ece18900e0d9d6', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'web', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, '6867c9766259a7f09e81ff515973914e', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'path', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, 'e73b3b85732a2c21be23353a0f7f1493', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'web', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, '8c93337891e74d67d2465ba2c3b901d5', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'web', '2026-06-15 15:53:35', '2026-06-15 15:53:35', NULL, '4cbf5007b12087b88b8acb36f3616bf8', 2) ON CONFLICT(ticket_record_id, label) 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_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
File diff suppressed because it is too large Load Diff
+41
View File
@@ -16,8 +16,23 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased] ## [Unreleased]
## [2.6.0] — 2026-06-16
### Added ### Added
- **Vim `gt` / `gT` tab motions.** Under the Vim preset, `gt`/`gT` cycle the
workspace tab strip (the same `workspace.tab.*` commands as `ctrl+pagedown`/
`ctrl+pageup`), resolved by the focused editor or pane and sharing the `g`
prefix with `gg`. Completes the T-403 cross-pane vim layer. (T-405)
- **Vim ex command-line (`:`).** Under the Vim preset, `:` opens a transient
one-line overlay running a fixed table — `:w` save, `:q` close the active tab
(the split self-collapses on the last one), `:wq`/`:x` and `ZZ` save+close,
`:e <path>` jump to quick-open seeded with the path, `:<n>` goto-line. Unknown
commands flash and stay open; with no active buffer it no-ops. Opens from the
editor or a focused pane; Esc dismisses. Adds the `editor.goto-line` CLI/IPC
verb. (T-407)
- **Crash-survivable logging.** clide writes a durable JSON-lines log to a - **Crash-survivable logging.** clide writes a durable JSON-lines log to a
persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`, persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`,
Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so
@@ -40,6 +55,32 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
and `clide log level [<level>]` does the same from the CLI — D-6 parity. The and `clide log level [<level>]` does the same from the CLI — D-6 parity. The
choice survives restart. (T-433) choice survives restart. (T-433)
### Fixed
- **`/clear` no longer kills the Claude pane.** Clearing the primary pane tore
the session down and respawned on the same deterministic `--session-id`
*before the old `claude` process had actually exited*, so claude 2.1.177
rejected the id as "already in use" and the respawn exited 1. The session
teardown now awaits the process's real death (SIGTERM, escalating to SIGKILL)
before clearing the transcript and respawning. A dead pane also now shows the
CLI's own reason instead of a bare "exited (code 1)". (T-437)
- **Web/WASM build compiles again.** `flutter build web --wasm` (and the
`test-e2e` / `ui-dev` / `ui-smoke` harness) had been broken since the
tree-sitter/PTY `dart:ffi` pivot. Every native binding (PTY, tree-sitter, the
Lua host, the Windows watchdog, the ABI/fd-inheritance probes) now sits behind
a `dart.library.ffi` conditional import with a graceful web stub, and the FNV
hash constants are dart2js-safe. A `flutter build web --wasm` compile gate was
added to CI so the fence can't silently rot. Desktop builds are unchanged — the
web target degrades (no terminal, no native git, no syntax highlighting), it
does not compromise desktop fidelity. (T-438, D-100, Q-50)
- **Desktop-launched clide finds your installed tools.** A dock/launcher start
inherits a sparse `PATH` (no `~/.local/bin`, brew, nvm, …), so pql/git/claude
and terminal tools could go missing. clide now resolves the real login-shell
`PATH` once at startup (`$SHELL -l -c`, bounded + graceful fallback) and routes
every spawn site — PTY children, git, the toolchain probe, hosted claude —
through one shared resolver, replacing three divergent (and partly macOS-only)
PATH expanders. (T-439, follows T-347)
## [2.5.0] — 2026-06-14 ## [2.5.0] — 2026-06-14
### Added ### Added
+30
View File
@@ -50,6 +50,11 @@ bindings:
- intent: dismiss - intent: dismiss
keys: escape keys: escape
when: quickOpen.open when: quickOpen.open
# Ex command-line overlay (T-407): Esc dismisses it back to normal mode (the
# overlay's own EditableText handles Enter via onSubmitted).
- intent: dismiss
keys: escape
when: exline.open
- intent: findInFiles.open - intent: findInFiles.open
keys: [ctrl+shift+f, meta+shift+f] keys: [ctrl+shift+f, meta+shift+f]
- intent: focus.nextPanel - intent: focus.nextPanel
@@ -128,6 +133,31 @@ bindings:
keys: [ctrl+w q, ctrl+w c] keys: [ctrl+w q, ctrl+w c]
when: "vim.normal || vim.visual" when: "vim.normal || vim.visual"
# ---- Tab motions (T-405) ---------------------------------------------
# gt / gT cycle the workspace tab strip via the preset-neutral
# workspace.tab.* commands (also on ctrl+pagedown/up everywhere). Bare-`g`
# sequences are editor/pane-local — the focused editor's matcher or a pane's
# PaneKeyNav resolves them and runs the command; they share the `g` prefix
# with `g g` (docStart / nav.top), distinguished by the final chord.
- intent: command:workspace.tab.next
keys: g t # gt
when: vim.normal
- intent: command:workspace.tab.previous
keys: g shift+t # gT
when: vim.normal
# ---- Ex command-line (T-407) -----------------------------------------
# `:` opens the transient ex overlay (`:w` `:q` `:wq` `:x` `:e <path>` `:N`);
# `ZZ` runs `:wq` directly. Both are typed app intents the editor's command
# matcher and a pane's nav matcher bubble to the app-root Actions, so they
# fire from any focus. Editor-targeted: no-op when no buffer is active.
- intent: exline.open
keys: shift+semicolon # :
when: vim.normal
- intent: exline.writeQuit
keys: shift+z shift+z # ZZ
when: vim.normal
# ---- Mode transitions ------------------------------------------------ # ---- Mode transitions ------------------------------------------------
- intent: command:vim.mode.visual - intent: command:vim.mode.visual
keys: v keys: v
@@ -0,0 +1,120 @@
{
"name": "Settings — Appearance",
"shapes": {
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
"cat-appearance-sel": { "type": "Rectangle", "left": 130, "top": 228, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
"cat-appearance-stripe": { "type": "Rectangle", "left": 130, "top": 228, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#E6E8F2", "fontSize": 14 },
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#8890AC", "fontSize": 14 },
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Appearance", "fontColor": "#E6E8F2", "fontSize": 18 },
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Theme, contrast, and text size.", "fontColor": "#78809C", "fontSize": 13 },
"grp-theme": { "type": "Text", "left": 382, "top": 186, "text": "THEME", "fontColor": "#78809C", "fontSize": 12 },
"theme-scope-body": { "type": "Rectangle", "left": 1004, "top": 188, "width": 15, "height": 10, "fillColor": "#20202C", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"theme-scope-tab": { "type": "Rectangle", "left": 1004, "top": 185, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"s1-card": { "type": "Rectangle", "left": 382, "top": 210, "width": 200, "height": 92, "fillColor": "#20202C", "strokeColor": "#78A0F8", "corners": [6, 6, 6, 6] },
"s1-side": { "type": "Rectangle", "left": 390, "top": 218, "width": 22, "height": 76, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [3, 3, 3, 3] },
"s1-bar": { "type": "Rectangle", "left": 418, "top": 218, "width": 156, "height": 14, "fillColor": "#242838", "strokeColor": "#242838", "corners": [3, 3, 3, 3] },
"s1-accent": { "type": "Rectangle", "left": 418, "top": 240, "width": 40, "height": 4, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"s1-l1": { "type": "Rectangle", "left": 418, "top": 254, "width": 120, "height": 4, "fillColor": "#E6E8F2", "strokeColor": "#E6E8F2" },
"s1-l2": { "type": "Rectangle", "left": 418, "top": 266, "width": 90, "height": 4, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
"s1-l3": { "type": "Rectangle", "left": 418, "top": 278, "width": 110, "height": 4, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
"s1-check-bg": { "type": "Ellipse", "left": 558, "top": 218, "width": 16, "height": 16, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"s1-check": { "type": "Text", "left": 561, "top": 219, "text": "✓", "fontColor": "#20202C", "fontSize": 11 },
"s1-name": { "type": "Text", "left": 390, "top": 306, "text": "clide", "fontColor": "#78A0F8", "fontSize": 13 },
"s2-card": { "type": "Rectangle", "left": 598, "top": 210, "width": 200, "height": 92, "fillColor": "#1E1E1E", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s2-side": { "type": "Rectangle", "left": 606, "top": 218, "width": 22, "height": 76, "fillColor": "#181818", "strokeColor": "#181818", "corners": [3, 3, 3, 3] },
"s2-bar": { "type": "Rectangle", "left": 634, "top": 218, "width": 156, "height": 14, "fillColor": "#252526", "strokeColor": "#252526", "corners": [3, 3, 3, 3] },
"s2-accent": { "type": "Rectangle", "left": 634, "top": 240, "width": 40, "height": 4, "fillColor": "#569CD6", "strokeColor": "#569CD6" },
"s2-l1": { "type": "Rectangle", "left": 634, "top": 254, "width": 120, "height": 4, "fillColor": "#D4D4D4", "strokeColor": "#D4D4D4" },
"s2-l2": { "type": "Rectangle", "left": 634, "top": 266, "width": 90, "height": 4, "fillColor": "#6A6A6A", "strokeColor": "#6A6A6A" },
"s2-l3": { "type": "Rectangle", "left": 634, "top": 278, "width": 110, "height": 4, "fillColor": "#6A6A6A", "strokeColor": "#6A6A6A" },
"s2-name": { "type": "Text", "left": 606, "top": 306, "text": "midnight", "fontColor": "#E6E8F2", "fontSize": 13 },
"s3-card": { "type": "Rectangle", "left": 814, "top": 210, "width": 200, "height": 92, "fillColor": "#F4F1EA", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s3-side": { "type": "Rectangle", "left": 822, "top": 218, "width": 22, "height": 76, "fillColor": "#ECE7DB", "strokeColor": "#ECE7DB", "corners": [3, 3, 3, 3] },
"s3-bar": { "type": "Rectangle", "left": 850, "top": 218, "width": 156, "height": 14, "fillColor": "#FBF8F1", "strokeColor": "#FBF8F1", "corners": [3, 3, 3, 3] },
"s3-accent": { "type": "Rectangle", "left": 850, "top": 240, "width": 40, "height": 4, "fillColor": "#C14B2A", "strokeColor": "#C14B2A" },
"s3-l1": { "type": "Rectangle", "left": 850, "top": 254, "width": 120, "height": 4, "fillColor": "#1A1A1A", "strokeColor": "#1A1A1A" },
"s3-l2": { "type": "Rectangle", "left": 850, "top": 266, "width": 90, "height": 4, "fillColor": "#7A7468", "strokeColor": "#7A7468" },
"s3-l3": { "type": "Rectangle", "left": 850, "top": 278, "width": 110, "height": 4, "fillColor": "#7A7468", "strokeColor": "#7A7468" },
"s3-name": { "type": "Text", "left": 822, "top": 306, "text": "paper", "fontColor": "#E6E8F2", "fontSize": 13 },
"s4-card": { "type": "Rectangle", "left": 382, "top": 330, "width": 200, "height": 92, "fillColor": "#0C0C0C", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s4-side": { "type": "Rectangle", "left": 390, "top": 338, "width": 22, "height": 76, "fillColor": "#000000", "strokeColor": "#000000", "corners": [3, 3, 3, 3] },
"s4-bar": { "type": "Rectangle", "left": 418, "top": 338, "width": 156, "height": 14, "fillColor": "#141414", "strokeColor": "#141414", "corners": [3, 3, 3, 3] },
"s4-accent": { "type": "Rectangle", "left": 418, "top": 360, "width": 40, "height": 4, "fillColor": "#FFC868", "strokeColor": "#FFC868" },
"s4-l1": { "type": "Rectangle", "left": 418, "top": 374, "width": 120, "height": 4, "fillColor": "#C8C8C8", "strokeColor": "#C8C8C8" },
"s4-l2": { "type": "Rectangle", "left": 418, "top": 386, "width": 90, "height": 4, "fillColor": "#5A5A5A", "strokeColor": "#5A5A5A" },
"s4-l3": { "type": "Rectangle", "left": 418, "top": 398, "width": 110, "height": 4, "fillColor": "#5A5A5A", "strokeColor": "#5A5A5A" },
"s4-name": { "type": "Text", "left": 390, "top": 426, "text": "terminal", "fontColor": "#E6E8F2", "fontSize": 13 },
"s5-card": { "type": "Rectangle", "left": 598, "top": 330, "width": 200, "height": 92, "fillColor": "#1E1E2E", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s5-side": { "type": "Rectangle", "left": 606, "top": 338, "width": 22, "height": 76, "fillColor": "#181825", "strokeColor": "#181825", "corners": [3, 3, 3, 3] },
"s5-bar": { "type": "Rectangle", "left": 634, "top": 338, "width": 156, "height": 14, "fillColor": "#313244", "strokeColor": "#313244", "corners": [3, 3, 3, 3] },
"s5-accent": { "type": "Rectangle", "left": 634, "top": 360, "width": 40, "height": 4, "fillColor": "#CBA6F7", "strokeColor": "#CBA6F7" },
"s5-l1": { "type": "Rectangle", "left": 634, "top": 374, "width": 120, "height": 4, "fillColor": "#CDD6F4", "strokeColor": "#CDD6F4" },
"s5-l2": { "type": "Rectangle", "left": 634, "top": 386, "width": 90, "height": 4, "fillColor": "#7F849C", "strokeColor": "#7F849C" },
"s5-l3": { "type": "Rectangle", "left": 634, "top": 398, "width": 110, "height": 4, "fillColor": "#7F849C", "strokeColor": "#7F849C" },
"s5-name": { "type": "Text", "left": 606, "top": 426, "text": "catppuccin-mocha", "fontColor": "#E6E8F2", "fontSize": 13 },
"s6-card": { "type": "Rectangle", "left": 814, "top": 330, "width": 200, "height": 92, "fillColor": "#21262F", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s6-side": { "type": "Rectangle", "left": 822, "top": 338, "width": 22, "height": 76, "fillColor": "#292E38", "strokeColor": "#292E38", "corners": [3, 3, 3, 3] },
"s6-bar": { "type": "Rectangle", "left": 850, "top": 338, "width": 156, "height": 14, "fillColor": "#393E48", "strokeColor": "#393E48", "corners": [3, 3, 3, 3] },
"s6-accent": { "type": "Rectangle", "left": 850, "top": 360, "width": 40, "height": 4, "fillColor": "#FA5F8B", "strokeColor": "#FA5F8B" },
"s6-l1": { "type": "Rectangle", "left": 850, "top": 374, "width": 120, "height": 4, "fillColor": "#E2E8F5", "strokeColor": "#E2E8F5" },
"s6-l2": { "type": "Rectangle", "left": 850, "top": 386, "width": 90, "height": 4, "fillColor": "#7A8296", "strokeColor": "#7A8296" },
"s6-l3": { "type": "Rectangle", "left": 850, "top": 398, "width": 110, "height": 4, "fillColor": "#7A8296", "strokeColor": "#7A8296" },
"s6-name": { "type": "Text", "left": 822, "top": 426, "text": "summer-night", "fontColor": "#E6E8F2", "fontSize": 13 },
"grp-options": { "type": "Text", "left": 382, "top": 462, "text": "OPTIONS", "fontColor": "#78809C", "fontSize": 12 },
"card-options": { "type": "Rectangle", "left": 382, "top": 482, "width": 648, "height": 150, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"hc-label": { "type": "Text", "left": 398, "top": 500, "text": "High contrast", "fontColor": "#E6E8F2", "fontSize": 15 },
"hc-track": { "type": "Rectangle", "left": 950, "top": 498, "width": 40, "height": 22, "fillColor": "#343850", "strokeColor": "#343850", "corners": [11, 11, 11, 11] },
"hc-knob": { "type": "Ellipse", "left": 952, "top": 500, "width": 18, "height": 18, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
"hc-scope-circle": { "type": "Ellipse", "left": 1004, "top": 502, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"hc-scope-eq": { "type": "Rectangle", "left": 1004, "top": 508, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"hc-scope-mer": { "type": "Rectangle", "left": 1010, "top": 502, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"ts-label": { "type": "Text", "left": 398, "top": 544, "text": "Text size", "fontColor": "#E6E8F2", "fontSize": 15 },
"ts-box": { "type": "Rectangle", "left": 926, "top": 542, "width": 64, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"ts-text": { "type": "Text", "left": 940, "top": 547, "text": "100%", "fontColor": "#E6E8F2", "fontSize": 14 },
"ts-scope-circle": { "type": "Ellipse", "left": 1004, "top": 546, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"ts-scope-eq": { "type": "Rectangle", "left": 1004, "top": 552, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"ts-scope-mer": { "type": "Rectangle", "left": 1010, "top": 546, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"ct-label": { "type": "Text", "left": 398, "top": 588, "text": "Custom theme", "fontColor": "#E6E8F2", "fontSize": 15 },
"ct-button": { "type": "Rectangle", "left": 846, "top": 588, "width": 144, "height": 28, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [4, 4, 4, 4] },
"ct-button-text": { "type": "Text", "left": 858, "top": 595, "text": "Import theme YAML ↗", "fontColor": "#B1BBE3", "fontSize": 13 },
"ct-scope-circle": { "type": "Ellipse", "left": 1004, "top": 592, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"ct-scope-eq": { "type": "Rectangle", "left": 1004, "top": 598, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"ct-scope-mer": { "type": "Rectangle", "left": 1010, "top": 592, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@@ -0,0 +1,127 @@
{
"name": "Settings — Claude",
"shapes": {
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 1250, "fillColor": "#17171E", "strokeColor": "#17171E" },
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 1222, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 1160, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 1160, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 1114, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 1114, "fillColor": "#343850", "strokeColor": "#343850" },
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
"cat-claude-sel": { "type": "Rectangle", "left": 130, "top": 264, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
"cat-claude-stripe": { "type": "Rectangle", "left": 130, "top": 264, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#E6E8F2", "fontSize": 14 },
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
"scrollbar-track": { "type": "Rectangle", "left": 1038, "top": 110, "width": 4, "height": 1104, "fillColor": "#242838", "strokeColor": "#242838", "corners": [2, 2, 2, 2] },
"scrollbar-thumb": { "type": "Rectangle", "left": 1038, "top": 110, "width": 4, "height": 250, "fillColor": "#3C445C", "strokeColor": "#3C445C", "corners": [2, 2, 2, 2] },
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Claude", "fontColor": "#E6E8F2", "fontSize": 18 },
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Model, effort, permissions, and the workspace's loaded skills, agents & MCP.", "fontColor": "#78809C", "fontSize": 13 },
"grp-settings": { "type": "Text", "left": 382, "top": 186, "text": "SETTINGS", "fontColor": "#78809C", "fontSize": 12 },
"card-settings": { "type": "Rectangle", "left": 382, "top": 206, "width": 648, "height": 166, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"model-label": { "type": "Text", "left": 398, "top": 222, "text": "Model", "fontColor": "#E6E8F2", "fontSize": 15 },
"model-select": { "type": "Rectangle", "left": 870, "top": 218, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"model-text": { "type": "Text", "left": 882, "top": 223, "text": "opus-4.8", "fontColor": "#78A0F8", "fontSize": 14 },
"model-chevron": { "type": "Text", "left": 974, "top": 223, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"model-scope-circle": { "type": "Ellipse", "left": 1004, "top": 224, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"model-scope-eq": { "type": "Rectangle", "left": 1004, "top": 230, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"model-scope-mer": { "type": "Rectangle", "left": 1010, "top": 224, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"effort-label": { "type": "Text", "left": 398, "top": 254, "text": "Effort", "fontColor": "#E6E8F2", "fontSize": 15 },
"effort-select": { "type": "Rectangle", "left": 870, "top": 250, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"effort-text": { "type": "Text", "left": 882, "top": 255, "text": "high", "fontColor": "#E6E8F2", "fontSize": 14 },
"effort-chevron": { "type": "Text", "left": 974, "top": 255, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"effort-scope-circle": { "type": "Ellipse", "left": 1004, "top": 256, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"effort-scope-eq": { "type": "Rectangle", "left": 1004, "top": 262, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"effort-scope-mer": { "type": "Rectangle", "left": 1010, "top": 256, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"perm-label": { "type": "Text", "left": 398, "top": 286, "text": "Permission mode", "fontColor": "#E6E8F2", "fontSize": 15 },
"perm-select": { "type": "Rectangle", "left": 870, "top": 282, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"perm-text": { "type": "Text", "left": 882, "top": 287, "text": "default", "fontColor": "#E6E8F2", "fontSize": 14 },
"perm-chevron": { "type": "Text", "left": 974, "top": 287, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"perm-scope-body": { "type": "Rectangle", "left": 1004, "top": 290, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"perm-scope-tab": { "type": "Rectangle", "left": 1004, "top": 287, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"style-label": { "type": "Text", "left": 398, "top": 318, "text": "Output style", "fontColor": "#E6E8F2", "fontSize": 15 },
"style-select": { "type": "Rectangle", "left": 870, "top": 314, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"style-text": { "type": "Text", "left": 882, "top": 319, "text": "default", "fontColor": "#E6E8F2", "fontSize": 14 },
"style-chevron": { "type": "Text", "left": 974, "top": 319, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"style-scope-circle": { "type": "Ellipse", "left": 1004, "top": 320, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"style-scope-eq": { "type": "Rectangle", "left": 1004, "top": 326, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"style-scope-mer": { "type": "Rectangle", "left": 1010, "top": 320, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"source-label": { "type": "Text", "left": 398, "top": 350, "text": "Source", "fontColor": "#8890AC", "fontSize": 15 },
"source-value": { "type": "Text", "left": 870, "top": 350, "text": "~/.claude · .clide", "fontColor": "#545C84", "fontSize": 13 },
"grp-skills": { "type": "Text", "left": 382, "top": 392, "text": "SKILLS", "fontColor": "#78809C", "fontSize": 12 },
"grp-skills-n": { "type": "Text", "left": 1014, "top": 392, "text": "6", "fontColor": "#545C84", "fontSize": 12 },
"card-skills": { "type": "Rectangle", "left": 382, "top": 410, "width": 648, "height": 150, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"sk1": { "type": "Text", "left": 398, "top": 426, "text": "ui-design", "fontColor": "#78A0F8", "fontSize": 13 },
"sk2": { "type": "Text", "left": 398, "top": 448, "text": "frame0-wireframe", "fontColor": "#78A0F8", "fontSize": 13 },
"sk3": { "type": "Text", "left": 398, "top": 470, "text": "pql", "fontColor": "#78A0F8", "fontSize": 13 },
"sk4": { "type": "Text", "left": 398, "top": 492, "text": "clide", "fontColor": "#78A0F8", "fontSize": 13 },
"sk5": { "type": "Text", "left": 398, "top": 514, "text": "git-commit", "fontColor": "#78A0F8", "fontSize": 13 },
"sk6": { "type": "Text", "left": 398, "top": 536, "text": "testmode", "fontColor": "#78A0F8", "fontSize": 13 },
"grp-agents": { "type": "Text", "left": 382, "top": 580, "text": "AGENTS", "fontColor": "#78809C", "fontSize": 12 },
"grp-agents-n": { "type": "Text", "left": 1020, "top": 580, "text": "3", "fontColor": "#545C84", "fontSize": 12 },
"card-agents": { "type": "Rectangle", "left": 382, "top": 598, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"ag1": { "type": "Text", "left": 398, "top": 614, "text": "Explore", "fontColor": "#78A0F8", "fontSize": 13 },
"ag2": { "type": "Text", "left": 398, "top": 636, "text": "Plan", "fontColor": "#78A0F8", "fontSize": 13 },
"ag3": { "type": "Text", "left": 398, "top": 658, "text": "general-purpose", "fontColor": "#78A0F8", "fontSize": 13 },
"grp-commands": { "type": "Text", "left": 382, "top": 702, "text": "COMMANDS", "fontColor": "#78809C", "fontSize": 12 },
"grp-commands-n": { "type": "Text", "left": 1014, "top": 702, "text": "5", "fontColor": "#545C84", "fontSize": 12 },
"card-commands": { "type": "Rectangle", "left": 382, "top": 720, "width": 648, "height": 128, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"cm1": { "type": "Text", "left": 398, "top": 736, "text": "whats-next", "fontColor": "#78A0F8", "fontSize": 13 },
"cm2": { "type": "Text", "left": 398, "top": 758, "text": "code-review", "fontColor": "#78A0F8", "fontSize": 13 },
"cm3": { "type": "Text", "left": 398, "top": 780, "text": "simplify", "fontColor": "#78A0F8", "fontSize": 13 },
"cm4": { "type": "Text", "left": 398, "top": 802, "text": "verify", "fontColor": "#78A0F8", "fontSize": 13 },
"cm5": { "type": "Text", "left": 398, "top": 824, "text": "run", "fontColor": "#78A0F8", "fontSize": 13 },
"grp-hooks": { "type": "Text", "left": 382, "top": 868, "text": "HOOKS", "fontColor": "#78809C", "fontSize": 12 },
"grp-hooks-n": { "type": "Text", "left": 1020, "top": 868, "text": "3", "fontColor": "#545C84", "fontSize": 12 },
"card-hooks": { "type": "Rectangle", "left": 382, "top": 886, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"hk1-ev": { "type": "Text", "left": 398, "top": 902, "text": "SessionStart", "fontColor": "#B1BBE3", "fontSize": 13 },
"hk1-cmd": { "type": "Text", "left": 540, "top": 902, "text": "peon-ping ready", "fontColor": "#8890AC", "fontSize": 12 },
"hk2-ev": { "type": "Text", "left": 398, "top": 924, "text": "UserPromptSubmit", "fontColor": "#B1BBE3", "fontSize": 13 },
"hk2-cmd": { "type": "Text", "left": 540, "top": 924, "text": "peon-ping working", "fontColor": "#8890AC", "fontSize": 12 },
"hk3-ev": { "type": "Text", "left": 398, "top": 946, "text": "Stop", "fontColor": "#B1BBE3", "fontSize": 13 },
"hk3-cmd": { "type": "Text", "left": 540, "top": 946, "text": "peon-ping done", "fontColor": "#8890AC", "fontSize": 12 },
"grp-perms": { "type": "Text", "left": 382, "top": 990, "text": "PERMISSIONS", "fontColor": "#78809C", "fontSize": 12 },
"grp-perms-n": { "type": "Text", "left": 1020, "top": 990, "text": "6", "fontColor": "#545C84", "fontSize": 12 },
"card-perms": { "type": "Rectangle", "left": 382, "top": 1008, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"pm-allow-k": { "type": "Text", "left": 398, "top": 1024, "text": "allow", "fontColor": "#7DD3A8", "fontSize": 12 },
"pm-allow-v": { "type": "Text", "left": 452, "top": 1024, "text": "Bash(git *) · Read · Edit · Write", "fontColor": "#B1BBE3", "fontSize": 13 },
"pm-ask-k": { "type": "Text", "left": 398, "top": 1046, "text": "ask", "fontColor": "#E6C370", "fontSize": 12 },
"pm-ask-v": { "type": "Text", "left": 452, "top": 1046, "text": "Bash(rm *)", "fontColor": "#B1BBE3", "fontSize": 13 },
"pm-deny-k": { "type": "Text", "left": 398, "top": 1068, "text": "deny", "fontColor": "#E87D7D", "fontSize": 12 },
"pm-deny-v": { "type": "Text", "left": 452, "top": 1068, "text": "Bash(curl *)", "fontColor": "#B1BBE3", "fontSize": 13 },
"grp-mcp": { "type": "Text", "left": 382, "top": 1112, "text": "MCP SERVERS", "fontColor": "#78809C", "fontSize": 12 },
"grp-mcp-n": { "type": "Text", "left": 1020, "top": 1112, "text": "2", "fontColor": "#545C84", "fontSize": 12 },
"card-mcp": { "type": "Rectangle", "left": 382, "top": 1130, "width": 648, "height": 62, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"mcp1": { "type": "Text", "left": 398, "top": 1146, "text": "claude.ai", "fontColor": "#E6E8F2", "fontSize": 13 },
"mcp2": { "type": "Text", "left": 398, "top": 1168, "text": "context7", "fontColor": "#E6E8F2", "fontSize": 13 }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

@@ -0,0 +1,89 @@
{
"name": "Settings — Editor",
"shapes": {
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
"cat-editor-sel": { "type": "Rectangle", "left": 130, "top": 156, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
"cat-editor-stripe": { "type": "Rectangle", "left": 130, "top": 156, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#E6E8F2", "fontSize": 14 },
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#8890AC", "fontSize": 14 },
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Editor", "fontColor": "#E6E8F2", "fontSize": 18 },
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Buffer formatting, indentation, and per-folder overrides.", "fontColor": "#78809C", "fontSize": 13 },
"grp-fmt": { "type": "Text", "left": 382, "top": 186, "text": "FORMATTING", "fontColor": "#78809C", "fontSize": 12 },
"card-fmt": { "type": "Rectangle", "left": 382, "top": 206, "width": 648, "height": 268, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"r1-label": { "type": "Text", "left": 398, "top": 222, "text": "Format on save", "fontColor": "#E6E8F2", "fontSize": 15 },
"r1-help": { "type": "Text", "left": 398, "top": 246, "text": "Run the formatter on every buffer save.", "fontColor": "#78809C", "fontSize": 13 },
"r1-toggle": { "type": "Rectangle", "left": 950, "top": 224, "width": 40, "height": 22, "fillColor": "#78A0F8", "strokeColor": "#78A0F8", "corners": [11, 11, 11, 11] },
"r1-knob": { "type": "Ellipse", "left": 970, "top": 226, "width": 18, "height": 18, "fillColor": "#E6E8F2", "strokeColor": "#E6E8F2" },
"r1-scope-body": { "type": "Rectangle", "left": 1004, "top": 228, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"r1-scope-tab": { "type": "Rectangle", "left": 1004, "top": 225, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"r2-label": { "type": "Text", "left": 398, "top": 288, "text": "Indent style", "fontColor": "#E6E8F2", "fontSize": 15 },
"r2-help": { "type": "Text", "left": 398, "top": 312, "text": "Spaces or tabs for new indentation.", "fontColor": "#78809C", "fontSize": 13 },
"r2-select": { "type": "Rectangle", "left": 870, "top": 288, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r2-select-text": { "type": "Text", "left": 882, "top": 293, "text": "Spaces", "fontColor": "#E6E8F2", "fontSize": 14 },
"r2-select-chevron": { "type": "Text", "left": 974, "top": 293, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"r2-scope-circle": { "type": "Ellipse", "left": 1004, "top": 292, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"r2-scope-eq": { "type": "Rectangle", "left": 1004, "top": 298, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"r2-scope-mer": { "type": "Rectangle", "left": 1010, "top": 292, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"r3-label": { "type": "Text", "left": 398, "top": 354, "text": "Tab width", "fontColor": "#E6E8F2", "fontSize": 15 },
"r3-help": { "type": "Text", "left": 398, "top": 378, "text": "Columns per indent level.", "fontColor": "#78809C", "fontSize": 13 },
"r3-number": { "type": "Rectangle", "left": 934, "top": 354, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r3-number-text": { "type": "Text", "left": 956, "top": 359, "text": "2", "fontColor": "#E6E8F2", "fontSize": 14 },
"r3-scope-body": { "type": "Rectangle", "left": 1004, "top": 358, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"r3-scope-tab": { "type": "Rectangle", "left": 1004, "top": 355, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"r4-label": { "type": "Text", "left": 398, "top": 420, "text": "Ruler column", "fontColor": "#E6E8F2", "fontSize": 15 },
"r4-help": { "type": "Text", "left": 398, "top": 444, "text": "Wrap-guide position. Unset — inherits the default (120).", "fontColor": "#78809C", "fontSize": 13 },
"r4-number": { "type": "Rectangle", "left": 934, "top": 420, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r4-number-text": { "type": "Text", "left": 950, "top": 425, "text": "120", "fontColor": "#545C84", "fontSize": 14 },
"r4-scope-circle": { "type": "Ellipse", "left": 1004, "top": 424, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#78809C" },
"grp-files": { "type": "Text", "left": 382, "top": 494, "text": "FILE OVERRIDES", "fontColor": "#78809C", "fontSize": 12 },
"card-files": { "type": "Rectangle", "left": 382, "top": 514, "width": 648, "height": 66, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"r5-label": { "type": "Text", "left": 398, "top": 530, "text": "Project .editorconfig", "fontColor": "#E6E8F2", "fontSize": 15 },
"r5-help": { "type": "Text", "left": 398, "top": 554, "text": "Per-folder overrides. Opens the file in the editor.", "fontColor": "#78809C", "fontSize": 13 },
"r5-button": { "type": "Rectangle", "left": 846, "top": 530, "width": 144, "height": 28, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [4, 4, 4, 4] },
"r5-button-text": { "type": "Text", "left": 858, "top": 537, "text": "Open .editorconfig ↗", "fontColor": "#B1BBE3", "fontSize": 13 },
"r5-scope-body": { "type": "Rectangle", "left": 1004, "top": 534, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"r5-scope-tab": { "type": "Rectangle", "left": 1004, "top": 531, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"legend-rule": { "type": "Rectangle", "left": 382, "top": 600, "width": 648, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"legend-title": { "type": "Text", "left": 382, "top": 616, "text": "Scope", "fontColor": "#78809C", "fontSize": 13 },
"lg-proj-body": { "type": "Rectangle", "left": 438, "top": 618, "width": 15, "height": 10, "fillColor": "#20202C", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"lg-proj-tab": { "type": "Rectangle", "left": 438, "top": 615, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"lg-proj-text": { "type": "Text", "left": 462, "top": 614, "text": "folder · Project", "fontColor": "#8890AC", "fontSize": 12 },
"lg-always-circle": { "type": "Ellipse", "left": 600, "top": 615, "width": 14, "height": 14, "fillColor": "#20202C", "strokeColor": "#E6C370" },
"lg-always-eq": { "type": "Rectangle", "left": 600, "top": 621, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"lg-always-mer": { "type": "Rectangle", "left": 606, "top": 615, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"lg-always-text": { "type": "Text", "left": 622, "top": 614, "text": "globe · Always", "fontColor": "#8890AC", "fontSize": 12 },
"lg-default-circle": { "type": "Ellipse", "left": 752, "top": 615, "width": 14, "height": 14, "fillColor": "#20202C", "strokeColor": "#78809C" },
"lg-default-text": { "type": "Text", "left": 774, "top": 614, "text": "circle-dashed · Default", "fontColor": "#8890AC", "fontSize": 12 }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

@@ -0,0 +1,70 @@
{
"name": "Settings — Search",
"shapes": {
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#78A0F8", "corners": [4, 4, 4, 4] },
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#B1BBE3" },
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#B1BBE3", "strokeColor": "#B1BBE3" },
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "tab", "fontColor": "#E6E8F2", "fontSize": 13 },
"search-caret": { "type": "Rectangle", "left": 198, "top": 123, "width": 1, "height": 16, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"search-clear": { "type": "Text", "left": 328, "top": 124, "text": "✕", "fontColor": "#78809C", "fontSize": 12 },
"rail-hint": { "type": "Text", "left": 152, "top": 164, "text": "Searching all categories", "fontColor": "#545C84", "fontSize": 12 },
"cat-editor": { "type": "Text", "left": 152, "top": 196, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
"cat-editor-n": { "type": "Text", "left": 326, "top": 196, "text": "2", "fontColor": "#545C84", "fontSize": 13 },
"cat-keymap": { "type": "Text", "left": 152, "top": 228, "text": "Keymap", "fontColor": "#545C84", "fontSize": 14 },
"cat-appearance": { "type": "Text", "left": 152, "top": 260, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
"cat-appearance-n": { "type": "Text", "left": 326, "top": 260, "text": "1", "fontColor": "#545C84", "fontSize": 13 },
"cat-claude": { "type": "Text", "left": 152, "top": 292, "text": "Claude", "fontColor": "#545C84", "fontSize": 14 },
"cat-activity": { "type": "Text", "left": 152, "top": 324, "text": "Activity", "fontColor": "#545C84", "fontSize": 14 },
"cat-terminal": { "type": "Text", "left": 152, "top": 356, "text": "Terminal", "fontColor": "#545C84", "fontSize": 14 },
"cat-extensions": { "type": "Text", "left": 152, "top": 388, "text": "Extensions", "fontColor": "#545C84", "fontSize": 14 },
"results-head": { "type": "Text", "left": 382, "top": 120, "text": "3 settings match “tab”", "fontColor": "#B1BBE3", "fontSize": 14 },
"grp-editor": { "type": "Text", "left": 382, "top": 160, "text": "EDITOR", "fontColor": "#78809C", "fontSize": 12 },
"card-editor": { "type": "Rectangle", "left": 382, "top": 178, "width": 648, "height": 132, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"r1-label": { "type": "Text", "left": 398, "top": 194, "text": "Tab width", "fontColor": "#E6E8F2", "fontSize": 15 },
"r1-help": { "type": "Text", "left": 398, "top": 218, "text": "Columns per indent level.", "fontColor": "#78809C", "fontSize": 13 },
"r1-number": { "type": "Rectangle", "left": 934, "top": 194, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r1-number-text": { "type": "Text", "left": 956, "top": 199, "text": "2", "fontColor": "#E6E8F2", "fontSize": 14 },
"r1-scope-body": { "type": "Rectangle", "left": 1004, "top": 198, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"r1-scope-tab": { "type": "Rectangle", "left": 1004, "top": 195, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"r2-label": { "type": "Text", "left": 398, "top": 260, "text": "Indent style", "fontColor": "#E6E8F2", "fontSize": 15 },
"r2-help": { "type": "Text", "left": 398, "top": 284, "text": "Spaces or tabs for new indentation.", "fontColor": "#78809C", "fontSize": 13 },
"r2-select": { "type": "Rectangle", "left": 870, "top": 260, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r2-select-text": { "type": "Text", "left": 882, "top": 265, "text": "Spaces", "fontColor": "#E6E8F2", "fontSize": 14 },
"r2-select-chevron": { "type": "Text", "left": 974, "top": 265, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"r2-scope-circle": { "type": "Ellipse", "left": 1004, "top": 264, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"r2-scope-eq": { "type": "Rectangle", "left": 1004, "top": 270, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"r2-scope-mer": { "type": "Rectangle", "left": 1010, "top": 264, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"grp-appearance": { "type": "Text", "left": 382, "top": 330, "text": "APPEARANCE", "fontColor": "#78809C", "fontSize": 12 },
"card-appearance": { "type": "Rectangle", "left": 382, "top": 348, "width": 648, "height": 66, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"r3-label": { "type": "Text", "left": 398, "top": 364, "text": "Workspace tab density", "fontColor": "#E6E8F2", "fontSize": 15 },
"r3-help": { "type": "Text", "left": 398, "top": 388, "text": "Spacing of the workspace tab strip.", "fontColor": "#78809C", "fontSize": 13 },
"r3-select": { "type": "Rectangle", "left": 870, "top": 364, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r3-select-text": { "type": "Text", "left": 882, "top": 369, "text": "Compact", "fontColor": "#E6E8F2", "fontSize": 14 },
"r3-select-chevron": { "type": "Text", "left": 974, "top": 369, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"r3-scope-circle": { "type": "Ellipse", "left": 1004, "top": 368, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"r3-scope-eq": { "type": "Rectangle", "left": 1004, "top": 374, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"r3-scope-mer": { "type": "Rectangle", "left": 1010, "top": 368, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"footer-note": { "type": "Text", "left": 382, "top": 444, "text": "Results span every category — edit a field inline, or open its category for the full list.", "fontColor": "#545C84", "fontSize": 12 }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+3 -2
View File
@@ -73,7 +73,7 @@ You might also want, project-permitting:
- [D-29: Pre-push gate — fast layer only](decisions/testing.md#d-29-pre-push-gate--fast-layer-only) — _testing_ - [D-29: Pre-push gate — fast layer only](decisions/testing.md#d-29-pre-push-gate--fast-layer-only) — _testing_
- [D-30: Tests are client-side only](decisions/testing.md#d-30-tests-are-client-side-only) — _testing_ - [D-30: Tests are client-side only](decisions/testing.md#d-30-tests-are-client-side-only) — _testing_
- [D-31: Prefer-zero-deps, exact-pin](decisions/tooling.md#d-31-prefer-zero-deps-exact-pin) — _tooling_ - [D-31: Prefer-zero-deps, exact-pin](decisions/tooling.md#d-31-prefer-zero-deps-exact-pin) — _tooling_
- [D-32: CI — Gitea primary, Linux-only runners, not yet activated](decisions/tooling.md#d-32-ci--gitea-primary-linux-only-runners-not-yet-activated) — _tooling_ - [D-32: CI — GitHub Actions, Linux + Windows runners, active](decisions/tooling.md#d-32-ci--github-actions-linux--windows-runners-active) — _tooling_
- [D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts](decisions/tooling.md#d-33-golden-output-ignore-pattern--coverage-excludes-output-not-scripts) — _tooling_ - [D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts](decisions/tooling.md#d-33-golden-output-ignore-pattern--coverage-excludes-output-not-scripts) — _tooling_
- [D-34: Q&D record system](decisions/process.md#d-34-qd-record-system) — _process_ - [D-34: Q&D record system](decisions/process.md#d-34-qd-record-system) — _process_
- [D-35: Kanban / waterfall, not Scrum](decisions/process.md#d-35-kanban--waterfall-not-scrum) — _process_ - [D-35: Kanban / waterfall, not Scrum](decisions/process.md#d-35-kanban--waterfall-not-scrum) — _process_
@@ -141,6 +141,7 @@ You might also want, project-permitting:
- [D-97: ssh:// workspace URI + system-ssh auth](decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth) — _architecture_ - [D-97: ssh:// workspace URI + system-ssh auth](decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth) — _architecture_
- [D-98: Remote-tool contract + connect preflight](decisions/architecture.md#d-98-remote-tool-contract--connect-preflight) — _architecture_ - [D-98: Remote-tool contract + connect preflight](decisions/architecture.md#d-98-remote-tool-contract--connect-preflight) — _architecture_
- [D-99: Remote session identity keyed on (host, workspace)](decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace) — _architecture_ - [D-99: Remote session identity keyed on (host, workspace)](decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace) — _architecture_
- [D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling](decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling) — _tooling_
## Open questions ## Open questions
@@ -182,7 +183,6 @@ You might also want, project-permitting:
- [Q-47: Live mixed documents — implement?](questions/design.md#q-47-live-mixed-documents--implement) — _design_ - [Q-47: Live mixed documents — implement?](questions/design.md#q-47-live-mixed-documents--implement) — _design_
- [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_ - [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_
- [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_ - [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
- [Q-51: Unify workspace lifecycle on a single fenced open primitive](questions/architecture.md#q-51-unify-workspace-lifecycle-on-a-single-fenced-open-primitive) — _architecture_ - [Q-51: Unify workspace lifecycle on a single fenced open primitive](questions/architecture.md#q-51-unify-workspace-lifecycle-on-a-single-fenced-open-primitive) — _architecture_
## Resolved questions ## Resolved questions
@@ -197,6 +197,7 @@ You might also want, project-permitting:
- [Q-28: Terminal strip scope — shell only or logs/errors/tests](questions/architecture.md#q-28-terminal-strip-scope--shell-only-or-logserrorstests) — _architecture_ - [Q-28: Terminal strip scope — shell only or logs/errors/tests](questions/architecture.md#q-28-terminal-strip-scope--shell-only-or-logserrorstests) — _architecture_
- [Q-32: MCP tool surface — minimum slash-ide or extended clide tools?](questions/architecture.md#q-32-mcp-tool-surface--minimum-slash-ide-or-extended-clide-tools) — _architecture_ - [Q-32: MCP tool surface — minimum slash-ide or extended clide tools?](questions/architecture.md#q-32-mcp-tool-surface--minimum-slash-ide-or-extended-clide-tools) — _architecture_
- [Q-33: MCP transport — SSE, WebSocket, stdio, or all?](questions/architecture.md#q-33-mcp-transport--sse-websocket-stdio-or-all) — _architecture_ - [Q-33: MCP transport — SSE, WebSocket, stdio, or all?](questions/architecture.md#q-33-mcp-transport--sse-websocket-stdio-or-all) — _architecture_
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
## Rejected ## Rejected
+14 -5
View File
@@ -11,11 +11,12 @@ Toolchain, supply chain, CI, ignore strategy.
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted. - **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory. - **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
### D-32: CI — Gitea primary, Linux-only runners, not yet activated ### D-32: CI — GitHub Actions, Linux + Windows runners, active
- **Date:** 2026-04-21 - **Date:** 2026-04-21 (amended 2026-06-15)
- **Decision:** CI config lives at `.gitea/workflows/test.yml` (Gitea Actions consumes GitHub-Actions syntax). Runners are Linux only; macOS is tested locally. The workflow is ready but Gitea Actions is not yet activated on the instance — the file is a staged pipeline for review. If the repo moves to GitHub, the file copies to `.github/workflows/test.yml` verbatim. - **Decision:** CI runs on **GitHub Actions** under `.github/workflows/`: `test.yml` (Linux — analyze + format + unit/widget/golden + coverage gate, with `pql` installed and `pql.db` rebuilt from the changelog), `windows.yml` + `windows-soak.yml` (ConPTY tests + the orphan-leak soak), and `release.yml` (version-tagged builds, the CHANGELOG section as release notes). macOS is tested locally (no macOS runner). The web-WASM Playwright e2e job is withheld pending the `dart:ffi` fence (D-100 / Q-50). Every job goes through `make` targets.
- **Rationale:** We want the CI story defined before we turn CI on — lower blast radius on early red builds. GitHub portability is free because the syntax is shared. - **Rationale:** The repo moved to GitHub (origin `postmeridiem/clide`); GitHub Actions consumes the same workflow syntax the staged Gitea pipeline used, so the move was near-verbatim. Defining the CI story before flipping it on kept early red builds low-blast-radius.
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation. - **Cost:** macOS coverage is local-only; the browser/e2e surface stays dark until D-100's fence lands.
- **Amended (2026-06-15):** Superseded the original "Gitea primary, not yet activated" posture — the Gitea staging pipeline was never activated and is gone (only `legacy/.gitea/`, the frozen Python tree, remains); CI is live on GitHub Actions (commits `8e0f33b` Linux, `45aa2d9` Windows/release). Reconciled while closing out T-384.
- **Raised by:** 2026-04-21 planning. - **Raised by:** 2026-04-21 planning.
### D-42: Dependencies documented in `licenses.yaml` ### D-42: Dependencies documented in `licenses.yaml`
@@ -101,3 +102,11 @@ Toolchain, supply chain, CI, ignore strategy.
- **Raised by:** 2026-06-11 — user: "pql updates live outside this repo and people have to first update the pql binaries and install them." - **Raised by:** 2026-06-11 — user: "pql updates live outside this repo and people have to first update the pql binaries and install them."
--- ---
### D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling
- **Date:** 2026-06-15
- **Decision:** Resolve [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) by **fencing** (option a): every module that imports `dart:ffi` — the native PTY (`lib/src/pty/`), tree-sitter (`lib/kernel/src/syntax/`), the Lua host (`lib/lua/`), the Windows watchdog, and the FFI `libc` shim — moves behind a conditional-import facade: `import 'x_native.dart' if (dart.library.ffi) 'x_native.dart' ... else 'x_stub.dart'` (io/ffi → native impl; web → a stub). Web stubs degrade gracefully (no PTY/terminal, no native git, no tree-sitter highlighting — the web target is a UI/e2e surface, not a functional desktop replacement) and never throw at import time. A `flutter build web --wasm` compile step is added to CI so the fence can't silently rot.
- **Rationale:** Keep the web/WASM build a live "happy accident" — a hopeful future target the maintainer wants to reach eventually — rather than letting the dart:ffi pivot quietly amputate it (option c, drop) or leaving it dark and rotting (option b, park). Crucially this does **not** compromise desktop fidelity (the CLAUDE.md guardrail): stubs exist only on the web target; the desktop build keeps the real FFI impls unchanged. Fencing is reversible and additive; dropping the target is effectively one-way.
- **Cost:** An ongoing tax — every new native binding needs a web stub + conditional import, and the wasm compile gate must stay green. Accepted deliberately: the maintainer values keeping the door open over avoiding that tax. Functional web parity is explicitly **not** promised — only that the tree compiles to wasm and the Playwright/e2e harness ([D-26](process.md)) can run again.
- **Cross-reference:** [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop), [D-32](#d-32-ci--github-actions-linux--windows-runners-active) (the withheld web-WASM e2e job lands once this fence is implemented), the tree-sitter FFI pivot.
- **Raised by:** 2026-06-15 — user, reconciling T-384: "a happy accident for the web-based UI lives a bit more hopeful for me than it does in CLAUDE.md … let's fence dart:ffi with web stubs."
+2 -1
View File
@@ -160,7 +160,8 @@ ticket persistence.
- **Source:** 2026-06-09 — split out of T-132 / T-158 (was "blocked on upstream"); project memory `claude-usage-budget-not-exposed`, GitHub anthropics/claude-code#44328. - **Source:** 2026-06-09 — split out of T-132 / T-158 (was "blocked on upstream"); project memory `claude-usage-budget-not-exposed`, GitHub anthropics/claude-code#44328.
### Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop? ### Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?
- **Status:** Open - **Status:** Resolved → [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling)
- **Resolved (2026-06-15):** **Fence** (option a). Every `dart:ffi` importer moves behind a conditional-import facade with a web stub, and a `flutter build web --wasm` compile gate is added so it can't rot. The maintainer wants the web/WASM build kept alive as a hopeful future target; desktop fidelity is untouched (stubs exist only on the web target). Functional web parity is explicitly not promised — only that the tree compiles to wasm and the e2e/Playwright harness can run again. Implementation tracked in T-438. See [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling).
- **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish. - **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish.
- **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked. - **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked.
- **Source:** T-384 / 2026-06-11 Fable review (epic T-359). - **Source:** T-384 / 2026-06-11 Fable review (epic T-359).
+10 -23
View File
@@ -21,11 +21,15 @@
/// IO wrapper the orchestrator calls. Flutter-free by design. /// IO wrapper the orchestrator calls. Flutter-free by design.
library; library;
import 'dart:ffi' show Abi;
import 'dart:io'; import 'dart:io';
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath; import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
// Web fence (T-438, D-100): `Abi.current()` (dart:ffi) is desktop-only; the web
// build gets a default dir name with no FFI introspection.
import 'native_abi_stub.dart' if (dart.library.ffi) 'native_abi_io.dart';
/// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a /// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a
/// hosted session (T-217), so the agent isn't prompted on every IDE call. /// hosted session (T-217), so the agent isn't prompted on every IDE call.
/// Claude Code's settings/flag syntax for a command-scoped Bash rule is /// Claude Code's settings/flag syntax for a command-scoped Bash rule is
@@ -90,26 +94,6 @@ String? resolveClideCliDir({required String? currentPath, required List<String>
return null; return null;
} }
/// The `native/<os>-<arch>/` directory name the Makefile builds the C client
/// into (e.g. `linux-x64`, `macos-arm64`) — used to find the dev-tree binary
/// when clide runs un-installed (dogfooding clide-on-clide).
String nativeClideDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macos today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
/// The result of [agentBootstrap]: the env delta to overlay and the extra /// The result of [agentBootstrap]: the env delta to overlay and the extra
/// spawn args (context note + allow rule) to prepend to a session's argv. /// spawn args (context note + allow rule) to prepend to a session's argv.
class AgentBootstrap { class AgentBootstrap {
@@ -125,10 +109,13 @@ class AgentBootstrap {
/// orchestrator merges both into one `--append-system-prompt`. /// orchestrator merges both into one `--append-system-prompt`.
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) { AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
final home = Platform.environment['HOME']; final home = Platform.environment['HOME'];
final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH']; // The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
// shells out to — find user-installed components on a desktop launch, not just
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
final currentPath = resolvedToolPath();
final candidates = <String>[ final candidates = <String>[
if (home != null && home.isNotEmpty) '$home/.local/bin', if (home != null && home.isNotEmpty) '$home/.local/bin',
'$workspaceRoot/native/${nativeClideDirName()}', '$workspaceRoot/native/${currentNativeDirName()}',
File(Platform.resolvedExecutable).parent.path, File(Platform.resolvedExecutable).parent.path,
]; ];
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile); final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
+4 -1
View File
@@ -420,7 +420,10 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return; if (!mounted) return;
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}'; final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail'); _kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart'); // Surface the CLI's own reason (e.g. "Session ID … is already in use")
// instead of an opaque "code 1" (T-437).
final why = end.reason.isEmpty ? '' : '${end.reason}';
setState(() => _statusLine = 'claude exited (code ${end.exitCode})$why · /clear to restart');
} }
// Send composed text to Claude over the stream-json channel. Commands clide // Send composed text to Claude over the stream-json channel. Commands clide
+28
View File
@@ -0,0 +1,28 @@
/// Resolve the `native/<os>-<arch>/` directory name via FFI ABI introspection
/// (T-438 web fence, D-100). Desktop-only; the web build uses
/// [native_abi_stub.dart], so `dart:ffi` (here, only `Abi`) stays out of the
/// wasm graph.
library;
import 'dart:ffi' show Abi;
import 'dart:io' show Platform;
/// The `<os>-<arch>` dir name for the current process (e.g. `linux-x64`,
/// `macos-arm64`) — used to find a dev-tree `clide` binary when running
/// un-installed. [abi] is an injection seam for tests; production passes none.
String currentNativeDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macOS today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
@@ -0,0 +1,6 @@
/// Web stub (T-438 web fence, D-100): no FFI ABI introspection on web. The
/// native dir name only matters for locating a dev-tree `clide` binary, which
/// doesn't exist on the web target — so a harmless default suffices.
library;
String currentNativeDirName() => 'linux-x64';
+18 -6
View File
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent /// Erase [sessionId]'s transcript under [projectDir] so a subsequent
/// `claude --session-id <sessionId>` re-creates it empty — the in-place /// `claude --session-id <sessionId>` re-creates it empty — the in-place
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl` /// `/clear` path for the primary pane (T-268). Removes the `<id>.jsonl`, plus
/// and the sidecar `<id>/` directory claude keeps beside it. Best-effort: /// a per-session `<id>/` sidecar dir if one exists (best-effort; missing
/// missing entries are not an error. The caller MUST have killed the session's /// entries are not an error). Note the shared per-project `memory/` dir that
/// process first, so claude is not mid-write. /// claude 2.1.x keeps beside transcripts is deliberately left alone — it is
/// not per-session.
///
/// The caller MUST have AWAITED the session's process death first (T-437): a
/// still-live claude re-flushes its transcript and keeps the id registered, so
/// the respawn's `--session-id` is rejected as "already in use" (exit 1).
/// [ClaudeSessionOrchestrator.close] now awaits that death before this runs.
Future<void> clearSessionTranscript(String projectDir, String sessionId) async { Future<void> clearSessionTranscript(String projectDir, String sessionId) async {
final file = File('$projectDir/$sessionId.jsonl'); final file = File('$projectDir/$sessionId.jsonl');
if (await file.exists()) await file.delete(); if (await file.exists()) await file.delete();
@@ -124,12 +130,18 @@ String freshSessionId() {
/// same id). Expands an FNV-1a stream into 16 bytes. /// same id). Expands an FNV-1a stream into 16 bytes.
String _deterministicUuid(String seed) { String _deterministicUuid(String seed) {
final bytes = <int>[]; final bytes = <int>[];
var h = 0xcbf29ce484222325; // FNV-1a 64-bit offset basis, split into two 32-bit halves so the dart2js
// web fallback accepts it (a full 64-bit literal "can't be represented
// exactly in JavaScript" — T-438). Correct on the VM/wasm; web never derives
// a session id (no claude process there).
var h = (0xcbf29ce4 << 32) | 0x84222325;
const prime = 0x100000001b3; const prime = 0x100000001b3;
for (var i = 0; i < 16; i++) { for (var i = 0; i < 16; i++) {
for (final c in utf8.encode('$seed:$i')) { for (final c in utf8.encode('$seed:$i')) {
h ^= c; h ^= c;
h = (h * prime) & 0xFFFFFFFFFFFFFFFF; // 64-bit modular wrap is implicit on the VM/wasm; the explicit
// `& 0xFFFFFFFFFFFFFFFF` was a no-op and a dart2js-incompatible literal.
h = h * prime;
} }
bytes.add(h & 0xff); bytes.add(h & 0xff);
} }
@@ -313,12 +313,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
} }
/// Kill and forget a session (the real teardown). The conversation's /// Kill and forget a session (the real teardown). The conversation's
/// onDispose kills the process + closes its streams. /// onDispose kills the process + closes its streams; we then AWAIT the
/// session's teardown so the `claude` process is genuinely dead before we
/// return (T-437). Callers respawn the primary on the same deterministic
/// `--session-id` right after /clear — if the old process were still alive,
/// claude 2.1.177 would reject the id as "already in use" and the respawn
/// would exit 1.
Future<void> close(String id) async { Future<void> close(String id) async {
final m = _sessions.remove(id); final m = _sessions.remove(id);
if (m == null) return; if (m == null) return;
broker.removeMember(id); broker.removeMember(id);
m.conversation.dispose(); m.conversation.dispose(); // cancels the item subscription; kicks off session teardown
await m.session.dispose(); // idempotent — awaits the real process exit
notifyListeners(); notifyListeners();
} }
@@ -107,7 +107,20 @@ class ClaudeStreamJsonProcess extends StreamJsonProcess {
@override @override
Future<void> kill() async { Future<void> kill() async {
// Await the process's ACTUAL death, not just the signal (T-437). clide
// respawns the primary on the SAME deterministic --session-id right after
// /clear; if the old process is still alive (or still flushing its
// transcript) when the new one starts, claude 2.1.177 rejects the id with
// "Session ID … is already in use" and the respawn exits 1. SIGTERM first
// (claude cleans its session registry on it), escalate to SIGKILL if it
// doesn't go, and only return once exitCode has resolved.
_proc.kill(); _proc.kill();
try {
await _proc.exitCode.timeout(const Duration(seconds: 2));
} on TimeoutException {
_proc.kill(ProcessSignal.sigkill);
await _proc.exitCode;
}
} }
@override @override
@@ -288,6 +301,20 @@ class SessionEnd {
final int exitCode; final int exitCode;
final List<String> stderrTail; final List<String> stderrTail;
/// The most recent non-empty stderr line — the CLI's own error message when
/// it dies (e.g. "Session ID … is already in use") — for surfacing in the
/// pane so a non-zero exit is never an opaque "code 1" (T-437). Empty when
/// stderr was silent; capped so a stray long line can't blow out the status
/// line.
String get reason {
for (final line in stderrTail.reversed) {
final t = line.trim();
if (t.isEmpty) continue;
return t.length > 200 ? '${t.substring(0, 200)}' : t;
}
return '';
}
} }
class StreamJsonSession { class StreamJsonSession {
@@ -965,10 +992,17 @@ class StreamJsonSession {
_endCtl.add(_end!); _endCtl.add(_end!);
} }
Future<void> dispose() async { /// Idempotent: the conversation controller's [dispose] fires this
/// unawaited while a caller (the orchestrator's [ClaudeSessionOrchestrator.close])
/// awaits it to know the process is truly dead (T-437). Caching the future
/// makes both paths share one teardown rather than killing/closing twice.
Future<void> dispose() => _disposeFuture ??= _dispose();
Future<void>? _disposeFuture;
Future<void> _dispose() async {
_disposed = true; // deliberate teardown — suppress the exit-watch path _disposed = true; // deliberate teardown — suppress the exit-watch path
await _sub?.cancel(); await _sub?.cancel();
await _proc.kill(); await _proc.kill(); // awaits the process's real exit (T-437)
await _items.close(); await _items.close();
await _statusCtl.close(); await _statusCtl.close();
await _workflowsCtl.close(); await _workflowsCtl.close();
@@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier {
_dirty = false; _dirty = false;
notifyListeners(); notifyListeners();
} }
case 'editor.selection-changed':
// An external setSelection moved the caret server-side (find-in-files
// line jump, ex-line `:N` goto — T-407). Mirror it onto the active
// buffer so the view's caret follows. Skipped while our own local edits
// are in flight — their echo already carries the authoritative caret.
final id = e.data['id'] as String?;
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
final sel = e.data['selection'];
if (sel is Map) {
_selection = Selection.fromJson(sel.cast<String, Object?>());
notifyListeners();
}
}
case 'editor.settings-changed': case 'editor.settings-changed':
// A source (e.g. a saved .editorconfig) re-resolved the buffer's // A source (e.g. a saved .editorconfig) re-resolved the buffer's
// settings. Refresh the active buffer's copy so indent/ruler update. // settings. Refresh the active buffer's copy so indent/ruler update.
+16 -2
View File
@@ -108,12 +108,20 @@ class _EditorViewState extends State<EditorView> {
final c = _controller!; final c = _controller!;
_syncTabs(c); _syncTabs(c);
_text.updatePath(c.activePath); _text.updatePath(c.activePath);
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
if (c.content != _lastRemoteContent) { if (c.content != _lastRemoteContent) {
_lastRemoteContent = c.content; _lastRemoteContent = c.content;
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
_text.removeListener(_onTextChanged); _text.removeListener(_onTextChanged);
_text.value = TextEditingValue(text: c.content, selection: sel); _text.value = TextEditingValue(text: c.content, selection: sel);
_text.addListener(_onTextChanged); _text.addListener(_onTextChanged);
} else if (sel != _text.value.selection) {
// Selection-only change from an external setSelection (ex-line `:N` goto,
// find-in-files line jump on the already-active buffer) — content is
// unchanged, so move just the caret. The focused field scrolls it into
// view (T-407).
_text.removeListener(_onTextChanged);
_text.value = _text.value.copyWith(selection: sel);
_text.addListener(_onTextChanged);
} }
setState(() {}); // tab/title refresh setState(() {}); // tab/title refresh
} }
@@ -274,7 +282,13 @@ class _EditorViewState extends State<EditorView> {
} }
void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) { void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) {
if (intent is! InvokeCommandIntent) return; if (intent is! InvokeCommandIntent) {
// A typed app intent the matcher fired (e.g. the ex-line `:` open or ZZ).
// The editor only owns editor.vim.* / mode commands; bubble anything else
// to the app-root Actions so it reaches its global handler (T-407).
Actions.maybeInvoke(context, intent);
return;
}
final id = intent.commandId; final id = intent.commandId;
if (!id.startsWith('editor.vim.')) { if (!id.startsWith('editor.vim.')) {
// Mode change (vim.mode.*) or any other command. // Mode change (vim.mode.*) or any other command.
+29
View File
@@ -0,0 +1,29 @@
/// FFI fd-inheritance probe for the testmode harness (T-438 web fence, D-100).
///
/// Desktop-only — [test_app.dart] selects [fd_check_stub.dart] on web so the
/// `dart:ffi` / `package:ffi` / libc imports stay out of the wasm graph.
library;
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'dart:io';
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'src/pty/ffi/libc.dart' as libc;
/// Probe whether `Process.start` inherits socket fds (the macOS question the
/// testmode harness answers). Returns a human-readable result line.
Future<String> fdInheritanceCheck() async {
final sv = pkg_ffi.calloc<ffi.Int32>(2);
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
final parent = sv[0];
final child = sv[1];
pkg_ffi.calloc.free(sv);
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
final stderr = await proc.stderr.transform(utf8.decoder).join();
final exit = await proc.exitCode;
libc.close(parent);
libc.close(child);
return 'exit=$exit stderr=${stderr.trim()}';
}
+4
View File
@@ -0,0 +1,4 @@
/// Web stub (T-438 web fence, D-100): no FFI fd-inheritance probe on web.
library;
Future<String> fdInheritanceCheck() async => 'skipped (no FFI on web)';
+1
View File
@@ -34,6 +34,7 @@ export 'src/keymap/pane_key_nav.dart';
export 'src/keymap/sequence_matcher.dart'; export 'src/keymap/sequence_matcher.dart';
export 'src/keymap/when_clause.dart'; export 'src/keymap/when_clause.dart';
export 'src/dialog.dart'; export 'src/dialog.dart';
export 'src/ex_line.dart';
export 'src/extensions_manager.dart'; export 'src/extensions_manager.dart';
export 'src/file_open.dart'; export 'src/file_open.dart';
export 'src/files.dart'; export 'src/files.dart';
+3 -14
View File
@@ -14,6 +14,8 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:clide/src/env/shell_env.dart' show expandToolPath;
/// State of the `clide` shell command relative to the running GUI. /// State of the `clide` shell command relative to the running GUI.
enum CliInstallState { enum CliInstallState {
/// No `clide` resolves on PATH. /// No `clide` resolves on PATH.
@@ -209,7 +211,7 @@ class CliInstaller {
static String get _pathSep => Platform.isWindows ? ';' : ':'; static String get _pathSep => Platform.isWindows ? ';' : ':';
String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? ''); String _expandedPath() => expandToolPath(env['PATH'] ?? '', isMac: Platform.isMacOS, isLinux: Platform.isLinux, home: env['HOME'] ?? '');
/// `~/.local/bin` on every platform — on Windows that is /// `~/.local/bin` on every platform — on Windows that is
/// `%USERPROFILE%\.local\bin`, the same convention the claude and /// `%USERPROFILE%\.local\bin`, the same convention the claude and
@@ -234,16 +236,3 @@ final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos|windows)-(x64|ar
/// PATH; it's a working client but not a packaged production install, so it's /// PATH; it's a working client but not a packaged production install, so it's
/// classified separately (T-256) rather than as a clean install. /// classified separately (T-256) rather than as a clean install.
bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path.replaceAll('\\', '/')); bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path.replaceAll('\\', '/'));
/// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps
/// launch with a sparse PATH that omits the usual user/homebrew bins, so on
/// macOS we prepend those (de-duplicated) before scanning. A top-level,
/// platform-parameterized function so both branches are testable off-platform.
String expandedPath(String base, {required bool macOS, String home = ''}) {
if (!macOS) return base;
final extras = <String>[if (home.isNotEmpty) '$home/.local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base;
return [...missing, ...existing].join(':');
}
+183
View File
@@ -0,0 +1,183 @@
/// State + grammar + execution for the Vim ex command-line overlay (T-407).
///
/// `:` (under `vim.normal`) opens a transient one-line prompt that runs a
/// small, fixed table of ex commands (`:w` `:q` `:wq` `:x` `:e <path>` `:N`).
/// It is NOT a vim *mode* — it's an overlay with its own `exline.open` scope
/// flag, dismissed with Esc, exactly the deferral `vim_mode_service.dart`
/// always named. The controller mirrors [QuickOpenController]'s open/close
/// shape so the overlay can reuse the quick-open chrome.
///
/// The command grammar ([parseExCommand]) is a pure switch — no parser, no
/// vimscript. Execution ([exWriteActive] etc.) goes through the editor IPC
/// verbs (the daemon is the source of truth for buffer state), so every ex
/// command is editor-targeted and no-ops when no buffer is active (the
/// 2026-06-13 decision on T-407).
library;
import 'dart:async';
import 'package:clide/clide.dart' show IpcResponse;
import 'package:clide/kernel/src/ipc/client.dart';
import 'package:flutter/foundation.dart';
class ExLineController extends ChangeNotifier {
bool _open = false;
String _input = '';
// Bumped each time an unknown command is rejected so the overlay can flash
// without closing. A monotonic nonce (not a bool) keeps repeated rejections
// individually observable by a listener.
int _invalidNonce = 0;
bool get isOpen => _open;
String get input => _input;
/// Increments whenever a typed command is rejected ([flashInvalid]); the
/// overlay watches it to flash the input and stay open.
int get invalidNonce => _invalidNonce;
void open() {
if (_open) return;
_open = true;
_input = '';
notifyListeners();
}
void close() {
if (!_open) return;
_open = false;
_input = '';
notifyListeners();
}
void setInput(String value) {
if (_input == value) return;
_input = value;
notifyListeners();
}
/// Signal that the submitted command was unknown — the overlay flashes and
/// stays open rather than executing or dismissing.
void flashInvalid() {
_invalidNonce++;
notifyListeners();
}
}
// --- Grammar ---------------------------------------------------------------
/// One parsed ex command. v1 table; anything off it is [ExUnknown].
sealed class ExCommand {
const ExCommand();
}
/// Empty input (`:` then Enter) — dismiss with no action.
class ExNoop extends ExCommand {
const ExNoop();
}
/// `:w` — write (save) the active buffer.
class ExWrite extends ExCommand {
const ExWrite();
}
/// `:q` (and `:q!`) — close the active editor tab.
class ExQuit extends ExCommand {
const ExQuit();
}
/// `:wq` / `:x` (and bang variants) and `ZZ` — save then close the active tab.
class ExWriteQuit extends ExCommand {
const ExWriteQuit();
}
/// `:e <path>` — open quick-open seeded with `<path>` (empty seed allowed).
class ExEdit extends ExCommand {
const ExEdit(this.query);
final String query;
@override
bool operator ==(Object other) => other is ExEdit && other.query == query;
@override
int get hashCode => query.hashCode;
}
/// `:<n>` — jump the active buffer to 1-based line `<n>`.
class ExGoto extends ExCommand {
const ExGoto(this.line);
final int line;
@override
bool operator ==(Object other) => other is ExGoto && other.line == line;
@override
int get hashCode => line.hashCode;
}
/// Anything not on the v1 table — the overlay flashes and stays open.
class ExUnknown extends ExCommand {
const ExUnknown();
}
/// Parse the text typed after `:` into an [ExCommand]. A leading colon is
/// tolerated (in case the user types it). v1 grammar only.
ExCommand parseExCommand(String raw) {
var s = raw.trim();
if (s.startsWith(':')) s = s.substring(1).trim();
if (s.isEmpty) return const ExNoop();
// `:e` / `:e <path>` — everything after the first token seeds quick-open.
if (s == 'e') return const ExEdit('');
if (s.startsWith('e ')) return ExEdit(s.substring(2).trim());
switch (s) {
case 'w':
return const ExWrite();
case 'q' || 'q!':
// No dirty-guard in v1, so `q!` is just `q`.
return const ExQuit();
case 'wq' || 'wq!' || 'x' || 'x!':
return const ExWriteQuit();
}
final line = int.tryParse(s);
if (line != null && line >= 1) return ExGoto(line);
return const ExUnknown();
}
// --- Execution (editor-targeted; no-op when no active buffer) ---------------
/// The active editor buffer id, or null when no buffer is active.
Future<String?> activeEditorBufferId(DaemonClient ipc) async {
final IpcResponse r = await ipc.request('editor.active');
if (!r.ok) return null;
final active = r.data['active'];
return active is Map ? active['id'] as String? : null;
}
/// `:w` — save the active buffer. `editor.save` resolves the active buffer
/// server-side, so a missing buffer is a silent no-op.
Future<void> exWriteActive(DaemonClient ipc) async {
await ipc.request('editor.save');
}
/// `:q` — close the active editor tab. The registry promotes the next buffer
/// (or collapses the split on the last one) — no separate split-close needed.
Future<void> exQuitActive(DaemonClient ipc) async {
final id = await activeEditorBufferId(ipc);
if (id == null) return;
await ipc.request('editor.close', args: {'id': id});
}
/// `:wq` / `:x` / `ZZ` — save the active buffer then close its tab.
Future<void> exWriteQuitActive(DaemonClient ipc) async {
final id = await activeEditorBufferId(ipc);
if (id == null) return;
await ipc.request('editor.save', args: {'id': id});
await ipc.request('editor.close', args: {'id': id});
}
/// `:<n>` — jump the active buffer to 1-based [line]. `editor.goto-line`
/// resolves the active buffer server-side and clamps out-of-range lines.
Future<void> exGotoLineActive(DaemonClient ipc, int line) async {
await ipc.request('editor.goto-line', args: {'line': line});
}
+8
View File
@@ -26,6 +26,7 @@ import 'package:clide/kernel/src/notify.dart';
import 'package:clide/kernel/src/os.dart'; import 'package:clide/kernel/src/os.dart';
import 'package:clide/kernel/src/panels/arrangement.dart'; import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/registry.dart'; import 'package:clide/kernel/src/panels/registry.dart';
import 'package:clide/kernel/src/ex_line.dart';
import 'package:clide/kernel/src/project.dart'; import 'package:clide/kernel/src/project.dart';
import 'package:clide/kernel/src/quick_open.dart'; import 'package:clide/kernel/src/quick_open.dart';
import 'package:clide/kernel/src/reader_nav.dart'; import 'package:clide/kernel/src/reader_nav.dart';
@@ -58,6 +59,7 @@ class KernelServices {
required this.commands, required this.commands,
required this.palette, required this.palette,
required this.quickOpen, required this.quickOpen,
required this.exLine,
required this.recentFiles, required this.recentFiles,
required this.readerNav, required this.readerNav,
required this.keybindings, required this.keybindings,
@@ -97,6 +99,9 @@ class KernelServices {
final CommandRegistry commands; final CommandRegistry commands;
final PaletteController palette; final PaletteController palette;
final QuickOpenController quickOpen; final QuickOpenController quickOpen;
/// Transient Vim ex command-line overlay state (T-407).
final ExLineController exLine;
final RecentFilesService recentFiles; final RecentFilesService recentFiles;
final ReaderNavRegistry readerNav; final ReaderNavRegistry readerNav;
final KeybindingResolver keybindings; final KeybindingResolver keybindings;
@@ -167,6 +172,7 @@ class KernelServices {
final palette = PaletteController(commands); final palette = PaletteController(commands);
final recentFiles = RecentFilesService(); final recentFiles = RecentFilesService();
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths); final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
final exLine = ExLineController();
final readerNav = ReaderNavRegistry(messages); final readerNav = ReaderNavRegistry(messages);
final clipboard = ClideClipboard(); final clipboard = ClideClipboard();
final files = FileServices(events); final files = FileServices(events);
@@ -253,6 +259,7 @@ class KernelServices {
commands: commands, commands: commands,
palette: palette, palette: palette,
quickOpen: quickOpen, quickOpen: quickOpen,
exLine: exLine,
recentFiles: recentFiles, recentFiles: recentFiles,
readerNav: readerNav, readerNav: readerNav,
keybindings: keybindings, keybindings: keybindings,
@@ -286,6 +293,7 @@ class KernelServices {
commands.dispose(); commands.dispose();
palette.dispose(); palette.dispose();
quickOpen.dispose(); quickOpen.dispose();
exLine.dispose();
toast.dispose(); toast.dispose();
recentFiles.dispose(); recentFiles.dispose();
readerNav.dispose(); readerNav.dispose();
+18
View File
@@ -77,6 +77,22 @@ class QuickOpenAcceptIntent extends Intent {
const QuickOpenAcceptIntent(); const QuickOpenAcceptIntent();
} }
// -- Vim ex command-line (T-407) --------------------------------------------
/// Open the Vim ex command-line overlay (`:`). A typed intent (not a
/// `command:` bridge) so it survives the editor's command-mode matcher and a
/// focused pane's nav matcher, both of which bubble unhandled typed intents to
/// the app-root Actions where this resolves to `services.exLine.open()`.
class ExLineOpenIntent extends Intent {
const ExLineOpenIntent();
}
/// Save the active buffer and close its tab (`ZZ`), without opening the
/// overlay — shares the `:wq` execution path.
class ExLineWriteQuitIntent extends Intent {
const ExLineWriteQuitIntent();
}
// -- Find in files ---------------------------------------------------------- // -- Find in files ----------------------------------------------------------
/// Reveal the find-in-files search panel in the sidebar. /// Reveal the find-in-files search panel in the sidebar.
@@ -193,6 +209,8 @@ final Map<String, Intent Function()> builtinIntents = {
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(), 'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(), 'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(), 'findInFiles.open': () => const FindInFilesIntent(),
'exline.open': () => const ExLineOpenIntent(),
'exline.writeQuit': () => const ExLineWriteQuitIntent(),
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc. // Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
'nav.down': () => const NavDownIntent(), 'nav.down': () => const NavDownIntent(),
'nav.up': () => const NavUpIntent(), 'nav.up': () => const NavUpIntent(),
+18 -4
View File
@@ -21,6 +21,8 @@
/// intent here and to the editor motion in the editor — see vim.yaml. /// intent here and to the editor motion in the editor — see vim.yaml.
library; library;
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -101,10 +103,22 @@ class _PaneKeyNavState extends State<PaneKeyNav> {
final r = _matcher!.feed(chord); final r = _matcher!.feed(chord);
switch (r.outcome) { switch (r.outcome) {
case SeqOutcome.fired: case SeqOutcome.fired:
// The vim preset also binds these keys to editor.vim.* motions; in a // The vim preset binds these keys to several intent kinds. In a pane:
// pane only nav.* applies. A non-nav fired intent (e.g. a stray // - nav.* drives the pane (onNav);
// editor.vim.* with no focus guard) is swallowed, never executed here. // - editor.vim.* buffer edits are swallowed never edit from a pane;
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count); // - other commands (workspace.tab.* gt/gT, panel.*) execute (T-405);
// - a typed app intent (ex-line `:` / ZZ) bubbles to the app-root
// Actions for its global handler (T-407).
final fired = r.intent;
if (fired is NavIntent) {
widget.onNav(fired, r.count);
} else if (fired is InvokeCommandIntent) {
if (!fired.commandId.startsWith('editor.vim.')) {
unawaited(kernel.commands.execute(fired.commandId));
}
} else if (fired != null) {
Actions.maybeInvoke(context, fired);
}
return KeyEventResult.handled; return KeyEventResult.handled;
case SeqOutcome.pending: case SeqOutcome.pending:
return KeyEventResult.handled; return KeyEventResult.handled;
+4 -2
View File
@@ -42,10 +42,12 @@ class QuickOpenController extends ChangeNotifier {
return _selectedIndex.clamp(0, n - 1); return _selectedIndex.clamp(0, n - 1);
} }
void open() { /// Open the picker. An optional [seed] pre-fills the filter — used by the
/// ex-line `:e <path>` command (T-407) to jump straight to a query.
void open({String? seed}) {
if (_open) return; if (_open) return;
_open = true; _open = true;
_filter = ''; _filter = seed ?? '';
_selectedIndex = 0; _selectedIndex = 0;
notifyListeners(); notifyListeners();
} }
+54
View File
@@ -0,0 +1,54 @@
/// Pure syntax-highlight result types + the capture-role→theme-color map
/// (T-438 web fence, D-100). No `dart:ffi`, so it is shared by the FFI-backed
/// [TreeSitterService] impl and its web stub — both expose identical data types.
library;
import 'dart:typed_data';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/theme/tokens.dart';
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
/// Throws on missing or unreadable assets.
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
/// Loads the highlight query (`.scm` source) for [language], or returns
/// null if no query is bundled for it.
typedef GrammarQueryLoader = Future<String?> Function(String language);
class SyntaxSpan {
const SyntaxSpan({required this.start, required this.end, required this.role});
final int start;
final int end;
final String role;
}
class SyntaxResult {
const SyntaxResult(this.spans);
final List<SyntaxSpan> spans;
static const empty = SyntaxResult([]);
}
/// Map a tree-sitter capture [role] to a theme color.
Color syntaxColorForRole(String role, SurfaceTokens tokens) {
return switch (role) {
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
'string' || 'string.special' => tokens.syntaxString,
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
'comment' => tokens.syntaxComment,
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
'property' || 'field' => tokens.syntaxMethod,
'constant' || 'constant.builtin' => tokens.syntaxNumber,
'tag' || 'attribute' => tokens.syntaxKeyword,
'namespace' || 'module' => tokens.syntaxType,
'text.title' => tokens.syntaxKeyword,
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
'text.emphasis' || 'text.strong' => tokens.syntaxType,
_ => tokens.globalForeground,
};
}
@@ -0,0 +1,8 @@
/// Desktop tree-sitter bootstrap (T-438 web fence, D-100): dlopen the vendored
/// libtree-sitter once at startup. The web build uses [tree_sitter_boot_stub.dart].
library;
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
/// Initialize the tree-sitter library; returns false if it can't be loaded.
bool initTreeSitter() => TreeSitterLib.init();
@@ -0,0 +1,4 @@
/// Web stub (T-438 web fence, D-100): no tree-sitter FFI to initialize.
library;
bool initTreeSitter() => false;
+5 -301
View File
@@ -1,303 +1,7 @@
/// Platform facade for the tree-sitter highlighter (T-438 web fence, D-100):
/// the FFI-backed [TreeSitterService] on desktop, a no-op stub on web. Both
/// re-export the shared [SyntaxSpan]/[SyntaxResult] types and `colorForRole`,
/// so consumers import this file unchanged.
library; library;
import 'dart:convert' show utf8; export 'tree_sitter_service_stub.dart' if (dart.library.ffi) 'tree_sitter_service_ffi.dart';
import 'dart:ffi';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
/// Throws on missing or unreadable assets.
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
/// Loads the highlight query (`.scm` source) for [language], or returns
/// null if no query is bundled for it.
typedef GrammarQueryLoader = Future<String?> Function(String language);
class SyntaxSpan {
const SyntaxSpan({required this.start, required this.end, required this.role});
final int start;
final int end;
final String role;
}
class SyntaxResult {
const SyntaxResult(this.spans);
final List<SyntaxSpan> spans;
static const empty = SyntaxResult([]);
}
class _LoadedGrammar {
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
final Pointer<Void> language;
final Pointer<TSQuery> query;
final List<String> captureNames;
}
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
: _injectedLib = lib,
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
final TreeSitterLib? _injectedLib;
final GrammarBytesLoader _grammarBytes;
final GrammarQueryLoader _grammarQuery;
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
static Future<Uint8List> _defaultGrammarBytes(String language) async {
final data = await rootBundle.load('assets/grammars/$language.wasm');
return data.buffer.asUint8List();
}
static Future<String?> _defaultGrammarQuery(String language) async {
try {
return await rootBundle.loadString('assets/queries/$language.scm');
} catch (_) {
return null;
}
}
final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {};
Pointer<TSWasmStore>? _store;
Pointer<TSParser>? _parser;
Pointer<TSQueryCursor>? _cursor;
bool _initDone = false;
bool _init() {
if (_initDone) return _parser != null;
_initDone = true;
final lib = _lib;
if (lib == null) return false;
final engine = lib.wasmEngineNew();
if (engine == nullptr) return false;
final error = calloc<TSWasmError>();
_store = lib.wasmStoreNew(engine, error);
lib.wasmEngineDelete(engine);
if (_store == null || _store == nullptr) {
calloc.free(error);
return false;
}
calloc.free(error);
_parser = lib.parserNew();
if (_parser == null || _parser == nullptr) return false;
lib.parserSetWasmStore(_parser!, _store!);
_cursor = lib.queryCursorNew();
return true;
}
Future<_LoadedGrammar?> _loadGrammar(String language) async {
if (_unavailable.contains(language)) return null;
final cached = _grammars[language];
if (cached != null) return cached;
if (!_init()) {
_unavailable.add(language);
return null;
}
final lib = _lib!;
try {
// Load grammar WASM bytes.
final wasmBytes = await _grammarBytes(language);
// Load into WASM store.
final nameNative = language.toNativeUtf8();
final wasmNative = calloc<Uint8>(wasmBytes.length);
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
final error = calloc<TSWasmError>();
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
calloc.free(wasmNative);
calloc.free(nameNative);
if (lang == nullptr) {
final msg = error.ref.message;
if (msg != nullptr) calloc.free(msg);
calloc.free(error);
_unavailable.add(language);
return null;
}
calloc.free(error);
// Load highlight query.
final querySource = await _grammarQuery(language);
Pointer<TSQuery> query = nullptr;
List<String> captureNames = [];
if (querySource != null) {
final queryNative = querySource.toNativeUtf8();
final queryLen = utf8.encode(querySource).length;
final errorOffset = calloc<Uint32>();
final errorType = calloc<Int32>();
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
calloc.free(queryNative);
calloc.free(errorOffset);
calloc.free(errorType);
if (query != nullptr) {
final count = lib.queryCaptureCount(query);
final lenOut = calloc<Uint32>();
for (var i = 0; i < count; i++) {
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
final len = lenOut.value;
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
}
calloc.free(lenOut);
}
}
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
_grammars[language] = grammar;
return grammar;
} catch (_) {
_unavailable.add(language);
return null;
}
}
Future<bool> hasGrammar(String path) async {
final lang = grammarForPath(path);
if (lang == null) return false;
return (await _loadGrammar(lang)) != null;
}
Future<String?> languageFor(String path) async {
final lang = grammarForPath(path);
if (lang == null) return null;
return (await _loadGrammar(lang)) != null ? lang : null;
}
List<String> get loadedLanguages => _grammars.keys.toList();
Future<SyntaxResult> highlight(String path, String source) async {
final lang = grammarForPath(path);
if (lang == null) return SyntaxResult.empty;
final grammar = await _loadGrammar(lang);
if (grammar == null || grammar.query == nullptr) {
return SyntaxResult.empty;
}
final lib = _lib!;
final parser = _parser!;
final cursor = _cursor!;
// Set language on parser for this parse.
lib.parserSetLanguage(parser, grammar.language);
// Parse source.
final sourceNative = source.toNativeUtf8();
final sourceLen = utf8.encode(source).length;
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
if (tree == nullptr) {
calloc.free(sourceNative);
return SyntaxResult.empty;
}
final root = lib.treeRootNode(tree);
// Run highlight query.
lib.queryCursorExec(cursor, grammar.query, root);
final match = calloc<TSQueryMatch>();
final spans = <SyntaxSpan>[];
while (lib.queryCursorNextMatch(cursor, match)) {
final m = match.ref;
for (var i = 0; i < m.captureCount; i++) {
final cap = m.captures[i];
final captureIndex = cap.index;
if (captureIndex < grammar.captureNames.length) {
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
}
}
}
calloc.free(match);
lib.treeDelete(tree);
calloc.free(sourceNative);
return SyntaxResult(spans);
}
void dispose() {
final lib = _lib;
if (lib == null) return;
for (final grammar in _grammars.values) {
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
}
_grammars.clear();
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
// Parser and WASM store are cleaned up together — deleting the parser
// does not delete the store, but the store owns the languages.
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
_parser = null;
_store = null;
_cursor = null;
_unavailable.clear();
}
/// Resets the service to a pre-init state. Tests use this to re-exercise
/// `_init()` without constructing a new singleton; production code never
/// needs it.
@visibleForTesting
void resetForTests() {
dispose();
_initDone = false;
}
static Color colorForRole(String role, SurfaceTokens tokens) {
return switch (role) {
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
'string' || 'string.special' => tokens.syntaxString,
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
'comment' => tokens.syntaxComment,
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
'property' || 'field' => tokens.syntaxMethod,
'constant' || 'constant.builtin' => tokens.syntaxNumber,
'tag' || 'attribute' => tokens.syntaxKeyword,
'namespace' || 'module' => tokens.syntaxType,
'text.title' => tokens.syntaxKeyword,
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
'text.emphasis' || 'text.strong' => tokens.syntaxType,
_ => tokens.globalForeground,
};
}
}
@@ -0,0 +1,269 @@
/// FFI-backed tree-sitter highlighter (T-438 web fence, D-100). Selected by the
/// [tree_sitter_service.dart] facade when `dart.library.ffi` is available; the
/// web build gets [tree_sitter_service_stub.dart] instead. Pure result types
/// live in [syntax_result.dart] (re-exported so consumers import only the
/// facade).
library;
import 'dart:convert' show utf8;
import 'dart:ffi';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/syntax/syntax_result.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
export 'package:clide/kernel/src/syntax/syntax_result.dart';
class _LoadedGrammar {
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
final Pointer<Void> language;
final Pointer<TSQuery> query;
final List<String> captureNames;
}
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
: _injectedLib = lib,
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
final TreeSitterLib? _injectedLib;
final GrammarBytesLoader _grammarBytes;
final GrammarQueryLoader _grammarQuery;
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
static Future<Uint8List> _defaultGrammarBytes(String language) async {
final data = await rootBundle.load('assets/grammars/$language.wasm');
return data.buffer.asUint8List();
}
static Future<String?> _defaultGrammarQuery(String language) async {
try {
return await rootBundle.loadString('assets/queries/$language.scm');
} catch (_) {
return null;
}
}
final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {};
Pointer<TSWasmStore>? _store;
Pointer<TSParser>? _parser;
Pointer<TSQueryCursor>? _cursor;
bool _initDone = false;
bool _init() {
if (_initDone) return _parser != null;
_initDone = true;
final lib = _lib;
if (lib == null) return false;
final engine = lib.wasmEngineNew();
if (engine == nullptr) return false;
final error = calloc<TSWasmError>();
_store = lib.wasmStoreNew(engine, error);
lib.wasmEngineDelete(engine);
if (_store == null || _store == nullptr) {
calloc.free(error);
return false;
}
calloc.free(error);
_parser = lib.parserNew();
if (_parser == null || _parser == nullptr) return false;
lib.parserSetWasmStore(_parser!, _store!);
_cursor = lib.queryCursorNew();
return true;
}
Future<_LoadedGrammar?> _loadGrammar(String language) async {
if (_unavailable.contains(language)) return null;
final cached = _grammars[language];
if (cached != null) return cached;
if (!_init()) {
_unavailable.add(language);
return null;
}
final lib = _lib!;
try {
// Load grammar WASM bytes.
final wasmBytes = await _grammarBytes(language);
// Load into WASM store.
final nameNative = language.toNativeUtf8();
final wasmNative = calloc<Uint8>(wasmBytes.length);
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
final error = calloc<TSWasmError>();
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
calloc.free(wasmNative);
calloc.free(nameNative);
if (lang == nullptr) {
final msg = error.ref.message;
if (msg != nullptr) calloc.free(msg);
calloc.free(error);
_unavailable.add(language);
return null;
}
calloc.free(error);
// Load highlight query.
final querySource = await _grammarQuery(language);
Pointer<TSQuery> query = nullptr;
List<String> captureNames = [];
if (querySource != null) {
final queryNative = querySource.toNativeUtf8();
final queryLen = utf8.encode(querySource).length;
final errorOffset = calloc<Uint32>();
final errorType = calloc<Int32>();
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
calloc.free(queryNative);
calloc.free(errorOffset);
calloc.free(errorType);
if (query != nullptr) {
final count = lib.queryCaptureCount(query);
final lenOut = calloc<Uint32>();
for (var i = 0; i < count; i++) {
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
final len = lenOut.value;
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
}
calloc.free(lenOut);
}
}
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
_grammars[language] = grammar;
return grammar;
} catch (_) {
_unavailable.add(language);
return null;
}
}
Future<bool> hasGrammar(String path) async {
final lang = grammarForPath(path);
if (lang == null) return false;
return (await _loadGrammar(lang)) != null;
}
Future<String?> languageFor(String path) async {
final lang = grammarForPath(path);
if (lang == null) return null;
return (await _loadGrammar(lang)) != null ? lang : null;
}
List<String> get loadedLanguages => _grammars.keys.toList();
Future<SyntaxResult> highlight(String path, String source) async {
final lang = grammarForPath(path);
if (lang == null) return SyntaxResult.empty;
final grammar = await _loadGrammar(lang);
if (grammar == null || grammar.query == nullptr) {
return SyntaxResult.empty;
}
final lib = _lib!;
final parser = _parser!;
final cursor = _cursor!;
// Set language on parser for this parse.
lib.parserSetLanguage(parser, grammar.language);
// Parse source.
final sourceNative = source.toNativeUtf8();
final sourceLen = utf8.encode(source).length;
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
if (tree == nullptr) {
calloc.free(sourceNative);
return SyntaxResult.empty;
}
final root = lib.treeRootNode(tree);
// Run highlight query.
lib.queryCursorExec(cursor, grammar.query, root);
final match = calloc<TSQueryMatch>();
final spans = <SyntaxSpan>[];
while (lib.queryCursorNextMatch(cursor, match)) {
final m = match.ref;
for (var i = 0; i < m.captureCount; i++) {
final cap = m.captures[i];
final captureIndex = cap.index;
if (captureIndex < grammar.captureNames.length) {
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
}
}
}
calloc.free(match);
lib.treeDelete(tree);
calloc.free(sourceNative);
return SyntaxResult(spans);
}
void dispose() {
final lib = _lib;
if (lib == null) return;
for (final grammar in _grammars.values) {
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
}
_grammars.clear();
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
// Parser and WASM store are cleaned up together — deleting the parser
// does not delete the store, but the store owns the languages.
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
_parser = null;
_store = null;
_cursor = null;
_unavailable.clear();
}
/// Resets the service to a pre-init state. Tests use this to re-exercise
/// `_init()` without constructing a new singleton; production code never
/// needs it.
@visibleForTesting
void resetForTests() {
dispose();
_initDone = false;
}
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
}
@@ -0,0 +1,27 @@
/// Web stub for [TreeSitterService] (T-438 web fence, D-100): no tree-sitter
/// FFI on web, so highlighting is a no-op — every query returns no spans and
/// the editor / code block render plain text. Mirrors the FFI impl's public
/// API (and re-exports the shared result types) so the facade is transparent.
library;
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/syntax_result.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
export 'package:clide/kernel/src/syntax/syntax_result.dart';
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
TreeSitterService();
Future<bool> hasGrammar(String path) async => false;
Future<String?> languageFor(String path) async => null;
List<String> get loadedLanguages => const [];
Future<SyntaxResult> highlight(String path, String source) async => SyntaxResult.empty;
void dispose() {}
void resetForTests() {}
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
}
+12 -22
View File
@@ -10,6 +10,13 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:clide/src/env/shell_env.dart';
// The canonical PATH-expansion logic now lives in shell_env (T-439, the single
// source of truth shared with git/pql/PTY/claude). Re-exported so existing
// importers/tests keep resolving it from here.
export 'package:clide/src/env/shell_env.dart' show expandToolPath;
/// Serializable result of tool resolution (crosses isolate boundary). /// Serializable result of tool resolution (crosses isolate boundary).
class ResolvedPaths { class ResolvedPaths {
const ResolvedPaths({this.git, this.pql, this.shell, this.gitEnv}); const ResolvedPaths({this.git, this.pql, this.shell, this.gitEnv});
@@ -144,25 +151,8 @@ String? _firstExisting(List<String> candidates) {
return null; return null;
} }
/// Build expanded PATH inline — must be self-contained for isolate use. /// The full tool search PATH — the login-shell PATH (probed once at startup)
String _expandedPath() => /// unioned with the well-known user/local bin dirs, shared with every other
expandToolPath(Platform.environment['PATH'] ?? '', isMac: Platform.isMacOS, isLinux: Platform.isLinux, home: Platform.environment['HOME']); /// spawn site via [shell_env] (T-439). In an isolate that never primed the
/// probe it degrades to the process PATH + the well-known dirs (T-347).
/// Pure PATH-expansion logic, extracted so it's testable without touching the String _expandedPath() => resolvedToolPath();
/// process environment.
///
/// A desktop-launched app (macOS or Linux) inherits a minimal PATH that lacks
/// the user bin dirs where tools like `pql` install (`~/.local/bin`), so tool
/// resolution fails even though a terminal launch would find them. Re-add the
/// common user/local bin dirs — that any are missing means they're prepended,
/// so they take precedence over a stale system copy (T-347). Homebrew dirs are
/// macOS-only. On other platforms the base PATH passes through unchanged.
String expandToolPath(String base, {required bool isMac, required bool isLinux, String? home}) {
if (!isMac && !isLinux) return base;
final h = home ?? '';
final extras = <String>[if (h.isNotEmpty) '$h/.local/bin', if (isMac) '/opt/homebrew/bin', if (isMac) '/opt/homebrew/sbin', '/usr/local/bin'];
final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base;
return [...missing, ...existing].join(':');
}
+3 -1
View File
@@ -25,7 +25,9 @@ library;
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'watchdog_windows.dart'; // Web fence (T-438, D-100): the FFI-backed Windows sampler is reachable only
// when `dart.library.ffi` is available; the web build gets an all-`-1` stub.
import 'watchdog_windows_stub.dart' if (dart.library.ffi) 'watchdog_windows.dart';
/// One resource sample of the current process. A field of `-1` means "not /// One resource sample of the current process. A field of `-1` means "not
/// available on this platform or the probe failed" — never an error. /// available on this platform or the probe failed" — never an error.
+15
View File
@@ -0,0 +1,15 @@
/// Web/non-FFI stub for the Windows resource sampler (T-438 web fence, D-100).
///
/// [watchdog.dart] selects this when `dart.library.ffi` is absent, keeping the
/// `kernel32`/`psapi` FFI bindings out of the wasm graph. The watchdog isolate
/// never spawns on web, and `forPlatform()` never returns the Windows sampler
/// there — this exists only to satisfy the import. Returns an all-unavailable
/// sample (every field `-1`) if ever called.
library;
import 'watchdog.dart';
class WindowsResourceSampler implements ResourceSampler {
@override
ResourceSample sample() => const ResourceSample();
}
+10 -2
View File
@@ -51,6 +51,7 @@ import 'package:clide/src/daemon/search_commands.dart';
import 'package:clide/src/editor/registry.dart' show EditorRegistry; import 'package:clide/src/editor/registry.dart' show EditorRegistry;
import 'package:clide/src/git/client.dart'; import 'package:clide/src/git/client.dart';
import 'package:clide/src/cli/argv_dispatch.dart'; import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath;
import 'package:clide/src/ipc/envelope.dart'; import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/mcp_server.dart'; import 'package:clide/src/ipc/mcp_server.dart';
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory; import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory;
@@ -59,7 +60,9 @@ import 'package:clide/src/ipc/server.dart';
import 'package:clide/src/panes/event_sink.dart'; import 'package:clide/src/panes/event_sink.dart';
import 'package:clide/src/panes/registry.dart'; import 'package:clide/src/panes/registry.dart';
import 'package:clide/src/pql/client.dart'; import 'package:clide/src/pql/client.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; // Web fence (T-438, D-100): tree-sitter init is FFI-backed on desktop, a no-op
// on web (highlighting degrades to plain text there).
import 'package:clide/kernel/src/syntax/tree_sitter_boot_stub.dart' if (dart.library.ffi) 'package:clide/kernel/src/syntax/tree_sitter_boot_io.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -81,7 +84,7 @@ Future<void> main() async {
binding.ensureSemantics(); binding.ensureSemantics();
TreeSitterLib.init(); initTreeSitter();
final appDir = await _resolveAppDir(); final appDir = await _resolveAppDir();
final themes = await _loadBundledThemes(); final themes = await _loadBundledThemes();
@@ -98,6 +101,11 @@ Future<void> main() async {
LogLevel bootLogLevel = kReleaseMode ? LogLevel.warn : LogLevel.info; LogLevel bootLogLevel = kReleaseMode ? LogLevel.warn : LogLevel.info;
List<LogSink> bootLogSinks = const []; List<LogSink> bootLogSinks = const [];
if (!kIsWeb) { if (!kIsWeb) {
// Resolve the user's real login-shell PATH once, before any tool resolution
// or spawn — a desktop/dock launch inherits a sparse PATH that misses
// ~/.local/bin, brew, nvm, etc. (T-439). Bounded + graceful: a slow/failed
// probe just falls back to the process PATH + well-known dirs.
await primeLoginShellPath();
final bootSettings = SettingsStore(appDir: appDir); final bootSettings = SettingsStore(appDir: appDir);
await bootSettings.load(); await bootSettings.load();
startupWorkRoot = resolveStartupWorkspace( startupWorkRoot = resolveStartupWorkspace(
+25 -1
View File
@@ -3,7 +3,7 @@
/// Verb list matches CLAUDE.md's tier-2 surface: /// Verb list matches CLAUDE.md's tier-2 surface:
/// editor.open editor.active editor.activate editor.insert /// editor.open editor.active editor.activate editor.insert
/// editor.replace-selection editor.save editor.close editor.list /// editor.replace-selection editor.save editor.close editor.list
/// editor.read editor.set-selection editor.set-content /// editor.read editor.set-selection editor.set-content editor.goto-line
/// ///
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map /// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
/// one-to-one onto these via the IPC dispatch layer. /// one-to-one onto these via the IPC dispatch layer.
@@ -50,6 +50,14 @@ void registerEditorCommands(DaemonDispatcher d, EditorRegistry registry) {
d.register('editor.set-content', (req) => _setContent(req, registry)); d.register('editor.set-content', (req) => _setContent(req, registry));
d.register('editor.save', (req) => _save(req, registry), schema: _idArg); d.register('editor.save', (req) => _save(req, registry), schema: _idArg);
d.register('editor.close', (req) => _close(req, registry), schema: _idArg); d.register('editor.close', (req) => _close(req, registry), schema: _idArg);
d.register(
'editor.goto-line',
(req) => _gotoLine(req, registry),
schema: const CommandSchema(
positional: ['line'],
args: {'line': ArgSpec(type: ArgType.number)},
),
);
} }
IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err( IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err(
@@ -220,3 +228,19 @@ Future<IpcResponse> _close(IpcRequest req, EditorRegistry r) async {
r.close(id); r.close(id);
return IpcResponse.ok(id: req.id, data: {'id': id}); return IpcResponse.ok(id: req.id, data: {'id': id});
} }
/// Jump the active (or [id]'d) buffer's caret to the start of a 1-based line —
/// the ex-line `:N` goto (T-407) and the CLI `clide editor goto-line <n>`.
/// Reuses the [_offsetForLine] mapping `editor.open --line` uses; out-of-range
/// lines clamp to the buffer end via setSelection.
Future<IpcResponse> _gotoLine(IpcRequest req, EditorRegistry r) async {
final id = _resolveId(req, r);
if (id == null) return _notFound(req.id, 'no active buffer');
final buf = r.get(id);
if (buf == null) return _notFound(req.id, 'no such buffer: $id');
final rawLine = req.args['line'];
final line = rawLine is num ? rawLine.toInt() : int.tryParse('$rawLine');
if (line == null || line < 1) return _userErr(req.id, 'line must be a positive integer');
r.setSelection(id, Selection.collapsed(_offsetForLine(buf.content, line)));
return IpcResponse.ok(id: req.id, data: {'id': id, 'line': line});
}
+93
View File
@@ -0,0 +1,93 @@
/// The single source of truth for the PATH clide hands to every tool it
/// spawns — git, pql, the toolchain probe, PTY children, hosted claude (T-439).
///
/// A desktop/dock-launched GUI process inherits a minimal PATH (roughly
/// `/usr/bin:/bin`): it never sources `~/.bashrc` / `~/.zprofile` /
/// `/etc/profile.d` / brew shellenv, so `~/.local/bin`, Homebrew, and any
/// user-customized dirs (nvm/pyenv/cargo/asdf/…) are absent and tool resolution
/// fails even though a terminal launch would find them. Two layers, in order:
///
/// 1. [primeLoginShellPath] probes the user's actual login shell once at
/// startup (`$SHELL -l -c …`) — the real PATH, not a guess — and caches it.
/// 2. [expandToolPath] additionally unions in the well-known user/local bin
/// dirs, so resolution still works when the probe is unavailable (Windows,
/// timeout, spawn failure) or the shell's profile omits a dir we know.
///
/// Flutter-free (used by `GitClient` / `PqlClient` under `dart test`).
library;
import 'dart:io';
String? _loginShellPath;
bool _primed = false;
/// Probe the user's login shell for its `PATH`, once, and cache it. Desktop-only
/// — the caller guards on `!kIsWeb`. Idempotent. Graceful: on Windows (no
/// login-shell convention), a missing `$SHELL`, a non-zero exit, a timeout, or a
/// spawn failure, the cache stays null and [currentSearchPath] falls back to the
/// process `PATH` (still hardcoded-merged by [expandToolPath]).
///
/// [run] is injectable for tests; [timeout] bounds the probe so a misbehaving
/// profile can never hang startup.
Future<void> primeLoginShellPath({
Future<ProcessResult> Function(String executable, List<String> arguments)? run,
String? shell,
Duration timeout = const Duration(seconds: 4),
}) async {
if (_primed) return;
_primed = true;
if (Platform.isWindows) return; // PowerShell has no `-l -c` PATH convention.
final sh = shell ?? Platform.environment['SHELL'];
if (sh == null || sh.isEmpty) return;
final runner = run ?? (e, a) => Process.run(e, a);
try {
// `-l -c`: a login shell (sources the profile files that set the real PATH)
// but non-interactive (no prompt, no hang). Frame the value in sentinels so
// any MOTD / profile chatter on stdout is stripped.
final res = await runner(sh, ['-l', '-c', r'printf "__CLIDE_PATH__%s__CLIDE_PATH__" "$PATH"']).timeout(timeout);
if (res.exitCode != 0) return;
final out = res.stdout is String ? res.stdout as String : '';
final m = RegExp(r'__CLIDE_PATH__(.*?)__CLIDE_PATH__', dotAll: true).firstMatch(out);
final path = m?.group(1)?.trim();
if (path != null && path.isNotEmpty) _loginShellPath = path;
} catch (_) {
// timeout / spawn failure → leave the cache null and fall back.
}
}
/// The base search PATH: the login-shell PATH if [primeLoginShellPath] resolved
/// one, else the process `PATH`. Not yet merged with the well-known dirs — use
/// [resolvedToolPath] for the full search path.
String currentSearchPath() => _loginShellPath ?? Platform.environment['PATH'] ?? '';
/// The full PATH clide should hand to spawned tools: the login-shell/process
/// PATH unioned with the well-known user/local bin dirs. The single resolver
/// every spawn site calls.
String resolvedToolPath() => expandToolPath(currentSearchPath(), isMac: Platform.isMacOS, isLinux: Platform.isLinux, home: Platform.environment['HOME']);
/// Pure PATH-expansion: prepend the well-known user/local bin dirs that a
/// desktop launch drops, de-duplicated, so they take precedence over a stale
/// system copy (T-347). Homebrew dirs are macOS-only. On platforms that aren't
/// macOS/Linux the base passes through unchanged. Extracted so it's testable
/// without touching the process environment.
String expandToolPath(String base, {required bool isMac, required bool isLinux, String? home}) {
if (!isMac && !isLinux) return base;
final h = home ?? '';
final extras = <String>[if (h.isNotEmpty) '$h/.local/bin', if (isMac) '/opt/homebrew/bin', if (isMac) '/opt/homebrew/sbin', '/usr/local/bin'];
final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base;
return [...missing, ...existing].join(':');
}
/// Test seam: force the cached login-shell PATH (and mark primed).
void debugSetLoginShellPath(String? value) {
_loginShellPath = value;
_primed = true;
}
/// Test seam: clear the cache so [primeLoginShellPath] probes again.
void debugResetLoginShellPath() {
_loginShellPath = null;
_primed = false;
}
+5 -1
View File
@@ -101,7 +101,11 @@ String logDirectory([Map<String, String>? env]) {
String fnv1a64Hex(String s) { String fnv1a64Hex(String s) {
// Desktop-only (the IPC server is desktop-only per D-56). Dart VM // Desktop-only (the IPC server is desktop-only per D-56). Dart VM
// ints are 64-bit; arithmetic wraps modulo 2^64 naturally. // ints are 64-bit; arithmetic wraps modulo 2^64 naturally.
var h = 0xcbf29ce484222325; // FNV-1a 64-bit offset basis. Split into two 32-bit halves so the dart2js
// web fallback accepts it — a 0xcbf2…2325 literal "can't be represented
// exactly in JavaScript" (T-438). Correct on the VM/wasm (int64); the web
// build never hashes a socket path (the IPC server is desktop-only).
var h = (0xcbf29ce4 << 32) | 0x84222325;
const prime = 0x100000001b3; const prime = 0x100000001b3;
final bytes = utf8.encode(s); final bytes = utf8.encode(s);
for (final b in bytes) { for (final b in bytes) {
+4
View File
@@ -11,6 +11,7 @@ import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
import '../env/shell_env.dart' show resolvedToolPath;
import '../ipc/envelope.dart'; import '../ipc/envelope.dart';
import '../pty/pty_log.dart'; import '../pty/pty_log.dart';
import '../pty/pty_session.dart'; import '../pty/pty_session.dart';
@@ -53,6 +54,9 @@ class PaneRegistry {
// Terminal defaults for the PTY child. // Terminal defaults for the PTY child.
final fullEnv = <String, String>{ final fullEnv = <String, String>{
...Platform.environment, ...Platform.environment,
// The login-shell-resolved PATH so PTY children find user-installed tools
// even on a desktop launch (T-439); an explicit caller PATH still wins.
'PATH': resolvedToolPath(),
'TERM': 'xterm-256color', 'TERM': 'xterm-256color',
'COLORTERM': 'truecolor', 'COLORTERM': 'truecolor',
'LANG': 'en_US.UTF-8', 'LANG': 'en_US.UTF-8',
+7 -22
View File
@@ -8,29 +8,14 @@
/// the renderer can do true colour. /// the renderer can do true colour.
library; library;
import 'dart:io'; import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
/// On macOS, GUI apps inherit a minimal PATH that omits Homebrew, /// The full tool search PATH for PTY children and PATH-resolved subprocess
/// ~/.local/bin, and similar directories. This getter returns the /// lookup — delegates to the shared resolver ([resolvedToolPath], T-439) so
/// platform PATH with those well-known directories merged in. /// every spawn site agrees: the login-shell PATH (probed once at startup)
/// On Linux/Windows it returns the PATH unchanged. /// unioned with the well-known user/local bin dirs. Previously this was a
String get expandedPath { /// macOS-only merge, so a Linux desktop launch left tools unresolvable.
_cachedPath ??= _buildExpandedPath(); String get expandedPath => resolvedToolPath();
return _cachedPath!;
}
String? _cachedPath;
String _buildExpandedPath() {
final base = Platform.environment['PATH'] ?? '';
if (!Platform.isMacOS) return base;
final home = Platform.environment['HOME'] ?? '';
final extras = <String>[if (home.isNotEmpty) '$home/.local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base;
return [...missing, ...existing].join(':');
}
/// Base env clide builds for every PTY child. Callers merge with the /// Base env clide builds for every PTY child. Callers merge with the
/// user's environment — a child that needs user env like `HOME` / /// user's environment — a child that needs user env like `HOME` /
+46
View File
@@ -0,0 +1,46 @@
/// Desktop PTY backend selection (T-438 web fence, D-100).
///
/// [pty_session.dart] imports this only when `dart.library.ffi` is available;
/// the web build gets [pty_backend_web.dart] instead, so the FFI-backed
/// [NativePty]/[WindowsPty] never enter the wasm compile graph.
library;
import 'dart:io' show Platform;
import 'native_pty.dart';
import 'pty_log.dart';
import 'pty_session.dart';
import 'windows_pty.dart';
/// Spawn a PTY child using the platform backend — ConPTY on Windows
/// ([WindowsPty]), `posix_openpt` + `posix_spawn` elsewhere ([NativePty]).
PtySession startPtyBackend({
required String executable,
List<String> arguments = const [],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) {
if (Platform.isWindows) {
return WindowsPty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
return NativePty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
+21
View File
@@ -0,0 +1,21 @@
/// Web stub for the PTY backend (T-438 web fence, D-100).
///
/// The web/WASM target has no PTY — spawning a child under a pseudo-terminal is
/// desktop-only (it needs `dart:ffi`). [pty_session.dart] selects this when
/// `dart.library.ffi` is absent, keeping [NativePty]/[WindowsPty] out of the
/// wasm graph. The web build is a UI/e2e surface, not a functional desktop
/// replacement, so a terminal is never spawned there; calling this is a bug.
library;
import 'pty_log.dart';
import 'pty_session.dart';
PtySession startPtyBackend({
required String executable,
List<String> arguments = const [],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) => throw UnsupportedError('PTY sessions are not available on the web target.');
+13 -25
View File
@@ -8,12 +8,13 @@
/// in → EOF on child exit → close() reaps. /// in → EOF on child exit → close() reaps.
library; library;
import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
import 'native_pty.dart'; // Web fence (T-438, D-100): the FFI-backed backends are reachable only when
// `dart.library.ffi` is available; the web build gets a throwing stub, so
// `dart:ffi` never enters the wasm compile graph.
import 'pty_backend_web.dart' if (dart.library.ffi) 'pty_backend_io.dart';
import 'pty_log.dart'; import 'pty_log.dart';
import 'windows_pty.dart';
abstract interface class PtySession { abstract interface class PtySession {
/// OS process id of the spawned child. /// OS process id of the spawned child.
@@ -51,25 +52,12 @@ PtySession startPtySession({
String? workingDirectory, String? workingDirectory,
Map<String, String> environment = const {}, Map<String, String> environment = const {},
PtyLog log = PtyLog.none, PtyLog log = PtyLog.none,
}) { }) => startPtyBackend(
if (Platform.isWindows) { executable: executable,
return WindowsPty.start( arguments: arguments,
executable: executable, columns: columns,
arguments: arguments, rows: rows,
columns: columns, workingDirectory: workingDirectory,
rows: rows, environment: environment,
workingDirectory: workingDirectory, log: log,
environment: environment, );
log: log,
);
}
return NativePty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
+14
View File
@@ -120,6 +120,19 @@ class RootShellState extends State<RootShell> {
return null; return null;
}, },
), ),
ExLineOpenIntent: CallbackAction<ExLineOpenIntent>(
onInvoke: (_) {
widget.services.exLine.open();
return null;
},
),
ExLineWriteQuitIntent: CallbackAction<ExLineWriteQuitIntent>(
onInvoke: (_) {
// ZZ — save+close the active tab without opening the overlay.
unawaited(exWriteQuitActive(widget.services.ipc));
return null;
},
),
FindInFilesIntent: CallbackAction<FindInFilesIntent>( FindInFilesIntent: CallbackAction<FindInFilesIntent>(
onInvoke: (_) { onInvoke: (_) {
widget.services.arrangement.setVisible(Slots.sidebar, true); widget.services.arrangement.setVisible(Slots.sidebar, true);
@@ -160,6 +173,7 @@ class RootShellState extends State<RootShell> {
const Positioned.fill(child: RootLayout()), const Positioned.fill(child: RootLayout()),
const ClidePalette(), const ClidePalette(),
const QuickOpenOverlay(), const QuickOpenOverlay(),
const ExLineOverlay(),
const Positioned.fill(child: _WelcomeOverlay()), const Positioned.fill(child: _WelcomeOverlay()),
const ToastOverlay(), const ToastOverlay(),
], ],
+7 -17
View File
@@ -25,10 +25,11 @@ import 'builtin/files/files.dart';
import 'builtin/git/git.dart'; import 'builtin/git/git.dart';
import 'builtin/terminal/terminal.dart'; import 'builtin/terminal/terminal.dart';
import 'extension/extension.dart' show ClideExtension; import 'extension/extension.dart' show ClideExtension;
import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'kernel/kernel.dart'; import 'kernel/kernel.dart';
import 'src/pty/ffi/libc.dart' as libc;
// Web fence (T-438, D-100): the FFI fd-inheritance probe is desktop-only; the
// web build gets a no-op stub so dart:ffi / package:ffi / libc stay out.
import 'fd_check_stub.dart' if (dart.library.ffi) 'fd_check_io.dart';
import 'src/daemon/pane_commands.dart'; import 'src/daemon/pane_commands.dart';
import 'src/ipc/envelope.dart'; import 'src/ipc/envelope.dart';
import 'src/ipc/paths.dart' show logDirectory; import 'src/ipc/paths.dart' show logDirectory;
@@ -375,20 +376,9 @@ class _ClideTestAppState extends State<ClideTestApp> {
return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)'; return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)';
}); });
// Test: does Dart's Process.start inherit socket fds on macOS? // Test: does Dart's Process.start inherit socket fds on macOS? (T-438: the
await _testAsync('fd inheritance check', () async { // FFI body lives in fd_check_io.dart so the web build can stub it out.)
final sv = pkg_ffi.calloc<ffi.Int32>(2); await _testAsync('fd inheritance check', fdInheritanceCheck);
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
final parent = sv[0];
final child = sv[1];
pkg_ffi.calloc.free(sv);
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
final stderr = await proc.stderr.transform(utf8.decoder).join();
final exit = await proc.exitCode;
libc.close(parent);
libc.close(child);
return 'exit=$exit stderr=${stderr.trim()}';
});
_say(''); _say('');
} }
+194
View File
@@ -0,0 +1,194 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
/// The Vim ex command-line overlay (T-407): a transient one-line `:` prompt
/// that runs the fixed v1 command table ([parseExCommand]). Modeled on the
/// quick-open chrome but single-line and result-less.
///
/// It is NOT a vim mode — while open it publishes the `exline.open` scope flag
/// (so `vim.yaml` can bind Esc → dismiss for it alone) and dismisses back to
/// normal mode with no mode churn. The `:` and `ZZ` entries open it / run
/// `:wq` from the keymap; this widget owns the input, dispatch, and the
/// rejected-command hint. Always mounted (like quick-open); inert unless the
/// keymap opens it, which only `vim.yaml` does.
class ExLineOverlay extends StatefulWidget {
const ExLineOverlay({super.key});
@override
State<ExLineOverlay> createState() => _ExLineOverlayState();
}
class _ExLineOverlayState extends State<ExLineOverlay> {
final _input = TextEditingController();
final _focus = FocusNode(debugLabel: 'ExLineOverlay.input');
ExLineController? _exLine;
KeymapService? _keymap;
KernelServices? _services;
/// True after a rejected (unknown) command, until the user edits the input.
/// Drives the red border + hint instead of a timed flash (test-friendly).
bool _rejected = false;
int _seenInvalidNonce = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final kernel = ClideKernel.of(context);
_services = kernel;
if (!identical(_exLine, kernel.exLine)) {
_exLine?.removeListener(_onChanged);
_exLine = kernel.exLine;
_seenInvalidNonce = _exLine!.invalidNonce;
_exLine!.addListener(_onChanged);
_syncFromController();
}
_keymap = kernel.keymap;
final isOpen = _exLine?.isOpen ?? false;
_keymap?.setScopeFlag('exline.open', isOpen);
if (isOpen && !_focus.hasFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && (_exLine?.isOpen ?? false)) _focus.requestFocus();
});
}
}
@override
void dispose() {
_exLine?.removeListener(_onChanged);
_keymap?.clearScopeFlag('exline.open');
_input.dispose();
_focus.dispose();
super.dispose();
}
void _onChanged() {
final controller = _exLine;
final isOpen = controller?.isOpen ?? false;
_keymap?.setScopeFlag('exline.open', isOpen);
if (isOpen) {
_focus.requestFocus();
} else {
_rejected = false;
}
if (controller != null && controller.invalidNonce != _seenInvalidNonce) {
_seenInvalidNonce = controller.invalidNonce;
_rejected = true;
}
_syncFromController();
}
void _syncFromController() {
final text = _exLine?.input ?? '';
if (_input.text != text) {
_input.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
if (mounted) setState(() {});
}
void _onInputChanged(String value) {
if (_rejected) setState(() => _rejected = false);
_exLine?.setInput(value);
}
Future<void> _submit() async {
final controller = _exLine;
final services = _services;
if (controller == null || services == null) return;
final cmd = parseExCommand(controller.input);
switch (cmd) {
case ExNoop():
controller.close();
case ExUnknown():
controller.flashInvalid(); // stay open; the hint + border show
case ExEdit(:final query):
controller.close();
services.quickOpen.open(seed: query.isEmpty ? null : query);
case ExWrite():
controller.close();
await exWriteActive(services.ipc);
case ExQuit():
controller.close();
await exQuitActive(services.ipc);
case ExWriteQuit():
controller.close();
await exWriteQuitActive(services.ipc);
case ExGoto(:final line):
controller.close();
await exGotoLineActive(services.ipc, line);
}
}
Object? _dismiss(DismissIntent _) {
_exLine?.close();
return null;
}
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.exLine,
builder: (ctx, _) {
if (!kernel.exLine.isOpen) return const SizedBox.shrink();
return Positioned(
top: 60,
left: 0,
right: 0,
child: Center(
child: Actions(
actions: <Type, Action<Intent>>{DismissIntent: CallbackAction<DismissIntent>(onInvoke: _dismiss)},
child: Container(
width: 480,
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border.all(color: _rejected ? tokens.statusError : tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
boxShadow: [BoxShadow(color: tokens.shadowAmbient, blurRadius: 12, offset: const Offset(0, 4))],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
ClideText(':', fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
const SizedBox(width: 4),
Expanded(
child: EditableText(
controller: _input,
focusNode: _focus,
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.dropdownForeground),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalFocus,
maxLines: 1,
onChanged: _onInputChanged,
onSubmitted: (_) => _submit(),
),
),
],
),
),
if (_rejected)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ClideText('Not an editor command', fontSize: clideFontCaption, color: tokens.statusError),
),
],
),
),
),
),
);
},
);
}
}
+1
View File
@@ -14,6 +14,7 @@ export 'src/clide_collapser_card.dart';
export 'src/clide_code_block.dart'; export 'src/clide_code_block.dart';
export 'src/clide_divider.dart'; export 'src/clide_divider.dart';
export 'src/clide_file_image.dart'; export 'src/clide_file_image.dart';
export 'src/ex_line_overlay.dart';
export 'src/clide_filter_box.dart'; export 'src/clide_filter_box.dart';
export 'src/clide_lightbox.dart'; export 'src/clide_lightbox.dart';
export 'src/clide_markdown.dart'; export 'src/clide_markdown.dart';
+1 -1
View File
@@ -13,7 +13,7 @@ description: >-
subsystem handlers (pane, files, editor, git, pql), and the subsystem handlers (pane, files, editor, git, pql), and the
extension framework. extension framework.
publish_to: none publish_to: none
version: 2.5.0 version: 2.6.0
repository: https://github.com/postmeridiem/clide repository: https://github.com/postmeridiem/clide
# Short user-facing tagline (the welcome subtitle, web meta # Short user-facing tagline (the welcome subtitle, web meta
# description, etc.). Baked into lib/src/build_info.g.dart by # description, etc.). Baked into lib/src/build_info.g.dart by
@@ -33,6 +33,26 @@ class _FakeProc extends StreamJsonProcess {
Future<void> kill() async => killed = true; Future<void> kill() async => killed = true;
} }
/// A fake whose [kill] blocks until [gate] completes — models a real `claude`
/// that hasn't actually exited yet, so a test can prove `close()` waits for the
/// process's real death before returning (T-437).
class _GatedProc extends StreamJsonProcess {
_GatedProc(this._gate);
final Completer<void> _gate;
final _ctl = StreamController<String>.broadcast();
int killCount = 0;
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) {}
@override
Future<void> kill() async {
killCount++;
await _gate.future;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -207,6 +227,37 @@ void main() {
}); });
}); });
// ---- close awaits the process's real death (T-437) ----------------------
group('close — awaits real process death before returning (T-437)', () {
test('close() does not complete until the process exit resolves', () async {
final gate = Completer<void>();
final created = <_GatedProc>[];
final orch = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _GatedProc(gate);
created.add(p);
return p;
},
);
await orch.spawn(_spec('primary'));
var closed = false;
final closing = orch.close('primary').then((_) => closed = true);
await pumpEventQueue();
// The process hasn't exited yet, so /clear must not have proceeded to
// delete the transcript + respawn — close() is still awaiting death.
expect(closed, isFalse, reason: 'close must block until the old process is truly dead');
gate.complete(); // the claude process finally exits
await closing;
expect(closed, isTrue);
expect(created.single.killCount, 1, reason: 'idempotent dispose kills exactly once');
orch.dispose();
});
});
// ---- claude.kill-all-sessions via orchestrator -------------------------- // ---- claude.kill-all-sessions via orchestrator --------------------------
group('claude.kill-all-sessions via orchestrator (T-167)', () { group('claude.kill-all-sessions via orchestrator (T-167)', () {
@@ -937,6 +937,24 @@ void main() {
}); });
}); });
group('SessionEnd.reason (T-437)', () {
test('is the last non-empty stderr line — the CLI error', () {
const end = SessionEnd(exitCode: 1, stderrTail: ['warming up', '', 'Error: Session ID abc is already in use.', ' ']);
expect(end.reason, 'Error: Session ID abc is already in use.');
});
test('is empty when stderr was silent', () {
expect(const SessionEnd(exitCode: 1, stderrTail: []).reason, isEmpty);
expect(const SessionEnd(exitCode: 1, stderrTail: ['', ' ']).reason, isEmpty);
});
test('caps a very long line so it cannot blow out the status line', () {
final end = SessionEnd(exitCode: 1, stderrTail: ['x' * 500]);
expect(end.reason.length, 201); // 200 chars + ellipsis
expect(end.reason.endsWith(''), isTrue);
});
});
group('BoundedLineBuffer', () { group('BoundedLineBuffer', () {
test('keeps only the last cap lines', () { test('keeps only the last cap lines', () {
final b = BoundedLineBuffer(cap: 3); final b = BoundedLineBuffer(cap: 3);
@@ -187,6 +187,59 @@ void main() {
expect(c.activeId, isNull); // cleared until an active-changed arrives expect(c.activeId, isNull); // cleared until an active-changed arrives
}); });
test('editor.selection-changed mirrors an external caret move onto the active buffer (T-407)', () 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', 'line1\nline2')));
await c.hydrate();
expect(c.selection.start, 0);
// An external setSelection (ex-line :N goto) jumps the caret server-side.
emitEditor(bus, 'editor.selection-changed', {
'id': 'b_1',
'selection': {'start': 6, 'end': 6},
});
await pumpEventQueue();
expect(c.selection.start, 6);
expect(c.selection.end, 6);
});
test('editor.selection-changed for a non-active buffer is ignored', () 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', 'hello')));
await c.hydrate();
emitEditor(bus, 'editor.selection-changed', {
'id': 'b_other',
'selection': {'start': 3, 'end': 3},
});
await pumpEventQueue();
expect(c.selection.start, 0); // untouched
});
test('editor.saved clears the dirty marker on the buffer', () async { test('editor.saved clears the dirty marker on the buffer', () async {
ipc.stub( ipc.stub(
'editor.list', 'editor.list',
+27
View File
@@ -6,6 +6,7 @@ library;
import 'package:clide/builtin/editor/src/editor_view.dart'; import 'package:clide/builtin/editor/src/editor_view.dart';
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -76,6 +77,32 @@ void main() {
expect(find.text('Open a file to begin editing.'), findsOneWidget); expect(find.text('Open a file to begin editing.'), findsOneWidget);
}); });
testWidgets('an external selection-changed moves the caret without retyping (T-407)', (tester) async {
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
await tester.pumpWidget(harness(f, const EditorView()));
await tester.pumpAndSettle();
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.selection.baseOffset, 0);
// An ex-line `:N` goto sets the selection server-side; the buffer content
// is unchanged, so only the caret should move (the selection-only branch).
f.services.events.emit(
DaemonEvent(
subsystem: 'editor',
kind: 'editor.selection-changed',
data: const {
'id': 'b_1',
'selection': {'start': 5, 'end': 5},
},
ts: DateTime.now().toUtc(),
),
);
await tester.pumpAndSettle();
final field = tester.widget<EditableText>(find.byType(EditableText));
expect(field.controller.selection.baseOffset, 5);
expect(field.controller.text, 'content of lib/a.dart'); // unchanged
});
testWidgets('tapping an inactive tab routes to editor.activate', (tester) async { 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'); stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
String? activated; String? activated;
+43
View File
@@ -89,6 +89,49 @@ void main() {
expect((r.data['selection'] as Map)['start'], 8); expect((r.data['selection'] as Map)['start'], 8);
}); });
test('editor.goto-line jumps the active buffer to a 1-based line (T-407)', () async {
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n');
await call('editor.open', {'path': 'multi.txt'});
final r = await call('editor.goto-line', {'line': 3});
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['line'], 3);
// Line 3 starts after 'one\n' + 'two\n' = 8 characters.
final read = await call('editor.read');
expect((read.data['selection'] as Map)['start'], 8);
});
test('editor.goto-line clamps an out-of-range line to the content end', () async {
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\n'); // 8 chars
await call('editor.open', {'path': 'multi.txt'});
final r = await call('editor.goto-line', {'line': 999});
expect(r.ok, isTrue);
final read = await call('editor.read');
expect((read.data['selection'] as Map)['start'], 8);
});
test('editor.goto-line rejects a non-positive line', () async {
await call('editor.open', {'path': 'doc.md'});
final r = await call('editor.goto-line', {'line': 0});
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('editor.goto-line with no active buffer is not-found', () async {
final r = await call('editor.goto-line', {'line': 2});
expect(r.ok, isFalse);
expect(r.error!.kind, 'not_found');
});
test('CLI positional line binds to editor.goto-line (T-232)', () async {
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n');
await call('editor.open', {'path': 'multi.txt'});
final r = await call('editor.goto-line', {
'positional': ['2'],
});
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['line'], 2);
});
test('editor.insert without id targets the active buffer', () async { test('editor.insert without id targets the active buffer', () async {
await call('editor.open', {'path': 'doc.md'}); await call('editor.open', {'path': 'doc.md'});
final r = await call('editor.insert', {'text': 'X '}); final r = await call('editor.insert', {'text': 'X '});
+2 -17
View File
@@ -130,23 +130,8 @@ void main() {
}); });
}); });
group('expandedPath', () { // PATH expansion moved to the shared resolver (T-439); its logic is covered by
test('non-macOS returns PATH unchanged', () { // expandToolPath in toolchain_paths_test + shell_env_test.
expect(expandedPath('/a:/b', macOS: false, home: '/home/x'), '/a:/b');
});
test('macOS prepends missing user + homebrew bins', () {
final out = expandedPath('/usr/bin', macOS: true, home: '/home/x').split(':');
expect(out, contains('/home/x/.local/bin'));
expect(out, contains('/opt/homebrew/bin'));
expect(out.last, '/usr/bin');
});
test('macOS does not duplicate entries already on PATH', () {
final out = expandedPath('/opt/homebrew/bin:/usr/bin', macOS: true, home: '');
expect('/opt/homebrew/bin'.allMatches(out).length, 1);
});
});
group('install', () { group('install', () {
test('fails clearly when no bundled client is present', () { test('fails clearly when no bundled client is present', () {
+174
View File
@@ -0,0 +1,174 @@
/// Unit tests for the Vim ex command-line (T-407): the [parseExCommand]
/// grammar, the [ExLineController] open/close/flash state, and the
/// editor-targeted executor functions (which no-op when no buffer is active).
library;
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);
void main() {
group('parseExCommand', () {
test('empty (and bare colon) is a no-op', () {
expect(parseExCommand(''), isA<ExNoop>());
expect(parseExCommand(' '), isA<ExNoop>());
expect(parseExCommand(':'), isA<ExNoop>());
});
test('write / quit / write-quit and their aliases', () {
expect(parseExCommand('w'), isA<ExWrite>());
expect(parseExCommand('q'), isA<ExQuit>());
expect(parseExCommand('q!'), isA<ExQuit>());
expect(parseExCommand('wq'), isA<ExWriteQuit>());
expect(parseExCommand('wq!'), isA<ExWriteQuit>());
expect(parseExCommand('x'), isA<ExWriteQuit>());
expect(parseExCommand('x!'), isA<ExWriteQuit>());
});
test('a leading colon is tolerated', () {
expect(parseExCommand(':w'), isA<ExWrite>());
expect(parseExCommand(':q'), isA<ExQuit>());
});
test('edit seeds quick-open with the rest of the line', () {
expect(parseExCommand('e'), const ExEdit(''));
expect(parseExCommand('e lib/main.dart'), const ExEdit('lib/main.dart'));
expect(parseExCommand('e spaced '), const ExEdit('spaced'));
});
test('a positive integer is a goto-line', () {
expect(parseExCommand('1'), const ExGoto(1));
expect(parseExCommand('42'), const ExGoto(42));
});
test('zero, negatives and junk are unknown', () {
expect(parseExCommand('0'), isA<ExUnknown>());
expect(parseExCommand('-3'), isA<ExUnknown>());
expect(parseExCommand('wat'), isA<ExUnknown>());
expect(parseExCommand('e'), isNot(isA<ExUnknown>())); // guard: e is ExEdit
});
});
group('ExLineController', () {
late ExLineController c;
setUp(() => c = ExLineController());
tearDown(() => c.dispose());
test('open clears input and flips isOpen; close resets', () {
var notifications = 0;
c.addListener(() => notifications++);
c.setInput('stale');
c.open();
expect(c.isOpen, isTrue);
expect(c.input, isEmpty);
c.setInput('w');
expect(c.input, 'w');
c.close();
expect(c.isOpen, isFalse);
expect(c.input, isEmpty);
expect(notifications, greaterThan(0));
});
test('open is idempotent and does not re-clear a second time', () {
c.open();
c.setInput('w');
c.open(); // no-op
expect(c.input, 'w');
});
test('flashInvalid bumps the nonce monotonically', () {
final start = c.invalidNonce;
c.flashInvalid();
c.flashInvalid();
expect(c.invalidNonce, start + 2);
});
});
group('executors', () {
late DaemonBus bus;
late FakeDaemonClient ipc;
late List<String> calls;
setUp(() {
bus = DaemonBus();
ipc = FakeDaemonClient(log: Logger(), events: bus);
calls = [];
});
tearDown(() => bus.dispose());
void record(String cmd, [Map<String, Object?> data = const {}]) {
ipc.stub(cmd, (a) async {
calls.add(cmd);
return _ok(data);
});
}
test(':w saves the active buffer', () async {
record('editor.save');
await exWriteActive(ipc);
expect(calls, ['editor.save']);
});
test(':q resolves the active id then closes it', () async {
ipc.stub('editor.active', (_) async {
calls.add('editor.active');
return _ok({
'active': {'id': 'b_7'},
});
});
ipc.stub('editor.close', (a) async {
calls.add('editor.close:${a['id']}');
return _ok({});
});
await exQuitActive(ipc);
expect(calls, ['editor.active', 'editor.close:b_7']);
});
test(':q is a no-op with no active buffer', () async {
ipc.stub('editor.active', (_) async => _ok({'active': null}));
record('editor.close');
await exQuitActive(ipc);
expect(calls, isEmpty); // never reached editor.close
});
test(':wq saves then closes the active tab', () async {
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_3'},
}),
);
ipc.stub('editor.save', (a) async {
calls.add('save:${a['id']}');
return _ok({});
});
ipc.stub('editor.close', (a) async {
calls.add('close:${a['id']}');
return _ok({});
});
await exWriteQuitActive(ipc);
expect(calls, ['save:b_3', 'close:b_3']);
});
test(':wq is a no-op with no active buffer', () async {
ipc.stub('editor.active', (_) async => _ok({'active': null}));
record('editor.save');
record('editor.close');
await exWriteQuitActive(ipc);
expect(calls, isEmpty);
});
test(':N dispatches editor.goto-line with the line', () async {
ipc.stub('editor.goto-line', (a) async {
calls.add('goto:${a['line']}');
return _ok({});
});
await exGotoLineActive(ipc, 42);
expect(calls, ['goto:42']);
});
});
}
@@ -79,6 +79,27 @@ void main() {
}); });
}); });
group('vim preset tab motions (T-405)', () {
bool seqEq(List<KeyChord> seq, List<String> chords) {
if (seq.length != chords.length) return false;
for (var i = 0; i < chords.length; i++) {
if (seq[i] != KeyChord.parse(chords[i])) return false;
}
return true;
}
bool hasSeq(KeymapService svc, List<String> chords, String cmd) =>
svc.keymap!.effectiveBindings.any((b) => seqEq(b.sequence, chords) && isCommand(b.intent, cmd));
test('gt / gT bind workspace tab cycling, sharing the g prefix with gg', () async {
final svc = await activate('vim');
expect(hasSeq(svc, ['g', 't'], 'workspace.tab.next'), isTrue, reason: 'gt → workspace.tab.next');
expect(hasSeq(svc, ['g', 'shift+t'], 'workspace.tab.previous'), isTrue, reason: 'gT → workspace.tab.previous');
// The shared `g` prefix must not break gg (docStart) — different final.
expect(hasSeq(svc, ['g', 'g'], 'editor.vim.docStart'), isTrue, reason: 'gg → docStart intact');
});
});
group('jetbrains preset (T-66)', () { group('jetbrains preset (T-66)', () {
test('setPreset activates it and the representative subset resolves', () async { test('setPreset activates it and the representative subset resolves', () async {
final svc = await activate('jetbrains'); final svc = await activate('jetbrains');
@@ -3,6 +3,8 @@
/// end-to-end against the real vim preset and scope flags. /// end-to-end against the real vim preset and scope flags.
library; library;
import 'package:clide/clide.dart';
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -67,6 +69,47 @@ void main() {
expect(got, isEmpty); expect(got, isEmpty);
}); });
testWidgets('gt executes a non-editor command (workspace.tab.next) from a focused pane (T-405)', (tester) async {
var ran = 0;
f.services.commands.register(
CommandContribution(
id: 'workspace.tab.next',
command: 'workspace.tab.next',
title: 'Next Workspace Tab',
run: (_) async {
ran++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyT);
await tester.pump();
expect(ran, 1);
});
testWidgets('editor.vim.* buffer edits never execute from a pane (T-405 guard)', (tester) async {
var ran = 0;
// If the pane wrongly ran editor.vim.*, this would fire on `d d`.
f.services.commands.register(
CommandContribution(
id: 'editor.vim.deleteLine',
command: 'editor.vim.deleteLine',
title: 'vim: delete line',
run: (_) async {
ran++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.pump();
expect(ran, 0);
});
testWidgets('keys pass through outside vim normal mode', (tester) async { testWidgets('keys pass through outside vim normal mode', (tester) async {
final got = await pump(tester, scope: {'vim.insert': true}); final got = await pump(tester, scope: {'vim.insert': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
@@ -8,7 +8,9 @@ import 'dart:ffi';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart'; // The FFI impl directly (not the facade): these tests inject a fake
// TreeSitterLib via the FFI-only constructor params (T-438).
import 'package:clide/kernel/src/syntax/tree_sitter_service_ffi.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -13,7 +13,9 @@ import 'dart:ffi';
import 'dart:io'; import 'dart:io';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart'; // The FFI impl directly (not the facade): the smoke test injects a fake
// TreeSitterLib via the FFI-only constructor params (T-438).
import 'package:clide/kernel/src/syntax/tree_sitter_service_ffi.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
const _libPath = 'native/linux-x64/libtree-sitter.so'; const _libPath = 'native/linux-x64/libtree-sitter.so';
+7 -11
View File
@@ -23,20 +23,16 @@ void main() {
}); });
}); });
group('expandedPath', () { group('expandedPath (delegates to the shared resolver, T-439)', () {
test('returns a non-empty string on every platform', () { test('non-empty on every platform', () {
expect(expandedPath, isNotEmpty); expect(expandedPath, isNotEmpty);
}); });
test('on Linux/Windows, equals Platform.environment[PATH]', () { test('is a superset of the process PATH (never drops entries)', () {
if (Platform.isMacOS) return; // macOS path-merge tested separately. final got = expandedPath.split(':').toSet();
expect(expandedPath, Platform.environment['PATH']); for (final dir in (Platform.environment['PATH'] ?? '').split(':').where((e) => e.isNotEmpty)) {
}); expect(got, contains(dir));
}
test('on macOS, includes the well-known extras', () {
if (!Platform.isMacOS) return;
// Homebrew is the canonical one; at least one of these should appear.
expect(expandedPath, anyOf(contains('/opt/homebrew/bin'), contains('/usr/local/bin')));
}); });
}); });
+102
View File
@@ -0,0 +1,102 @@
/// Tests for the consolidated PATH resolver (T-439): the login-shell probe,
/// its graceful fallbacks, and the shared `expandToolPath` merge.
library;
import 'dart:io';
import 'package:clide/src/env/shell_env.dart';
import 'package:test/test.dart';
ProcessResult _ok(String path) => ProcessResult(1, 0, '__CLIDE_PATH__${path}__CLIDE_PATH__', '');
void main() {
setUp(debugResetLoginShellPath);
tearDown(debugResetLoginShellPath);
group('primeLoginShellPath', () {
test('caches the login shell PATH so currentSearchPath returns it', () async {
await primeLoginShellPath(shell: '/bin/zsh', run: (e, a) async => _ok('/opt/tool/bin:/usr/bin'));
expect(currentSearchPath(), '/opt/tool/bin:/usr/bin');
});
test('strips profile chatter around the sentinel-framed PATH', () async {
await primeLoginShellPath(shell: '/bin/bash', run: (e, a) async => ProcessResult(1, 0, 'MOTD: hi\n__CLIDE_PATH__/a:/b__CLIDE_PATH__', ''));
expect(currentSearchPath(), '/a:/b');
});
test('falls back to the process PATH on a non-zero exit', () async {
await primeLoginShellPath(shell: '/bin/bash', run: (e, a) async => ProcessResult(1, 1, '', 'boom'));
expect(currentSearchPath(), Platform.environment['PATH'] ?? '');
});
test('falls back when the probe throws (e.g. spawn failure)', () async {
await primeLoginShellPath(shell: '/bin/bash', run: (e, a) async => throw const ProcessException('sh', []));
expect(currentSearchPath(), Platform.environment['PATH'] ?? '');
});
test('falls back when the probe times out', () async {
await primeLoginShellPath(
shell: '/bin/bash',
timeout: const Duration(milliseconds: 20),
run: (e, a) => Future.delayed(const Duration(seconds: 5), () => _ok('/never')),
);
expect(currentSearchPath(), Platform.environment['PATH'] ?? '');
});
test('falls back when SHELL is empty', () async {
await primeLoginShellPath(shell: '', run: (e, a) async => _ok('/should/not/run'));
expect(currentSearchPath(), Platform.environment['PATH'] ?? '');
});
test('is idempotent — a second call does not re-probe', () async {
var calls = 0;
await primeLoginShellPath(
shell: '/bin/bash',
run: (e, a) async {
calls++;
return _ok('/first');
},
);
await primeLoginShellPath(
shell: '/bin/bash',
run: (e, a) async {
calls++;
return _ok('/second');
},
);
expect(calls, 1);
expect(currentSearchPath(), '/first');
});
});
group('resolvedToolPath', () {
test('unions the well-known dirs onto the resolved base', () async {
await primeLoginShellPath(shell: '/bin/bash', run: (e, a) async => _ok('/usr/bin'));
final got = resolvedToolPath();
// The resolved base is preserved; on macOS/Linux the user/local dirs are
// unioned in. (Windows passes through, so only assert the base is kept.)
expect(got.split(':'), contains('/usr/bin'));
if (Platform.isLinux || Platform.isMacOS) {
expect(got.split(':'), contains('/usr/local/bin'));
}
});
});
group('expandToolPath', () {
test('prepends missing user/local dirs (Linux), de-duplicated, base last', () {
final out = expandToolPath('/usr/bin', isMac: false, isLinux: true, home: '/home/u').split(':');
expect(out, contains('/home/u/.local/bin'));
expect(out, contains('/usr/local/bin'));
expect(out.last, '/usr/bin');
});
test('does not duplicate dirs already present', () {
final out = expandToolPath('/usr/local/bin:/usr/bin', isMac: false, isLinux: true, home: '');
expect('/usr/local/bin'.allMatches(out).length, 1);
});
test('passes the base through unchanged off macOS/Linux', () {
expect(expandToolPath('/a:/b', isMac: false, isLinux: false, home: '/home/u'), '/a:/b');
});
});
}
+143
View File
@@ -0,0 +1,143 @@
/// Widget tests for the Vim ex command-line overlay (T-407): each v1 command
/// row dispatches the right editor IPC verb (or seeds quick-open), unknown
/// commands keep the overlay open with a hint, and Esc dismisses it.
///
/// Built on a tight, sized Stack rather than the shared `harness()` — the
/// overlay is a `Positioned` child and needs a bounded Stack ancestor (the
/// canSizeOverlay harness mis-sizes positioned content).
library;
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.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';
IpcResponse _ok([Map<String, Object?> data = const {}]) => IpcResponse.ok(id: '', data: data);
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
Widget mount() => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: const MediaQuery(
data: MediaQueryData(size: Size(800, 600)),
child: SizedBox(width: 800, height: 600, child: Stack(children: [ExLineOverlay()])),
),
),
),
);
/// Open the overlay and type [text] into it (no submit yet).
Future<void> openAndType(WidgetTester tester, String text) async {
await tester.pumpWidget(mount());
f.services.exLine.open();
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText), text);
await pumpAsync(tester);
}
Future<void> submit(WidgetTester tester) async {
await tester.testTextInput.receiveAction(TextInputAction.done);
await pumpAsync(tester);
}
testWidgets('closed: renders nothing', (tester) async {
await tester.pumpWidget(mount());
expect(find.byType(EditableText), findsNothing);
});
testWidgets(':w saves the active buffer and closes', (tester) async {
var saved = false;
f.ipc.stub('editor.save', (_) async {
saved = true;
return _ok();
});
await openAndType(tester, 'w');
await submit(tester);
expect(saved, isTrue);
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets(':q closes the active tab via its id', (tester) async {
String? closed;
f.ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_9'},
}),
);
f.ipc.stub('editor.close', (a) async {
closed = a['id'] as String?;
return _ok();
});
await openAndType(tester, 'q');
await submit(tester);
expect(closed, 'b_9');
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets(':42 dispatches editor.goto-line', (tester) async {
Object? line;
f.ipc.stub('editor.goto-line', (a) async {
line = a['line'];
return _ok();
});
await openAndType(tester, '42');
await submit(tester);
expect(line, 42);
});
testWidgets(':e seeds quick-open and closes the ex-line', (tester) async {
await openAndType(tester, 'e lib/main.dart');
await submit(tester);
expect(f.services.exLine.isOpen, isFalse);
expect(f.services.quickOpen.isOpen, isTrue);
expect(f.services.quickOpen.filter, 'lib/main.dart');
});
testWidgets('unknown command keeps the overlay open and shows the hint', (tester) async {
await openAndType(tester, 'nope');
await submit(tester);
expect(f.services.exLine.isOpen, isTrue);
expect(find.text('Not an editor command'), findsOneWidget);
});
testWidgets('editing after a rejection clears the hint', (tester) async {
await openAndType(tester, 'nope');
await submit(tester);
expect(find.text('Not an editor command'), findsOneWidget);
await tester.enterText(find.byType(EditableText), 'w');
await pumpAsync(tester);
expect(find.text('Not an editor command'), findsNothing);
});
testWidgets('DismissIntent closes the overlay', (tester) async {
await openAndType(tester, 'w');
final ctx = tester.element(find.byType(EditableText));
Actions.invoke(ctx, const DismissIntent());
await pumpAsync(tester);
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets('opening publishes the exline.open scope flag; closing clears it', (tester) async {
await tester.pumpWidget(mount());
f.services.exLine.open();
await pumpAsync(tester);
expect(f.services.keymap.scope['exline.open'], isTrue);
f.services.exLine.close();
await pumpAsync(tester);
expect(f.services.keymap.scope['exline.open'], isNot(true));
});
}