Compare commits
@@ -112,9 +112,37 @@ tooltip → tooltipBackground / tooltipForeground / tooltipBorder
|
||||
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
|
||||
|
||||
- `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`
|
||||
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
|
||||
- Tab active border = `globalFocus` → use `panelActiveBorder`
|
||||
|
||||
@@ -114,13 +114,21 @@ jobs:
|
||||
path: ${{ github.workspace }}/clide-logs
|
||||
if-no-files-found: ignore
|
||||
|
||||
# The web-WASM Playwright job is withheld: `flutter build web --wasm` cannot
|
||||
# compile the tree since the tree-sitter/PTY dart:ffi pivot (dart:ffi is
|
||||
# unavailable on the wasm target). Whether the web target gets conditional-
|
||||
# import fences or is dropped is an open question — see Q-50 in
|
||||
# governance/questions/architecture.md. Re-add the job (setup-node, npm
|
||||
# install + playwright install in tools/ui, `make test-e2e`) when Q-50
|
||||
# resolves toward keeping it.
|
||||
web-wasm:
|
||||
name: web build (wasm compile gate)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
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:
|
||||
name: dart doc (lib API)
|
||||
|
||||
@@ -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 ('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 ('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 today→editor.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 today→editor.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;
|
||||
|
||||
@@ -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 ('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 ('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);
|
||||
|
||||
@@ -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', '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
@@ -16,8 +16,23 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.6.0] — 2026-06-16
|
||||
|
||||
### 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
|
||||
persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`,
|
||||
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
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -50,6 +50,11 @@ bindings:
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
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
|
||||
keys: [ctrl+shift+f, meta+shift+f]
|
||||
- intent: focus.nextPanel
|
||||
@@ -128,6 +133,31 @@ bindings:
|
||||
keys: [ctrl+w q, ctrl+w c]
|
||||
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 ------------------------------------------------
|
||||
- intent: command:vim.mode.visual
|
||||
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 |
@@ -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-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-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-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_
|
||||
@@ -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-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-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
|
||||
|
||||
@@ -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-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-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_
|
||||
|
||||
## 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-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-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
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
|
||||
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
|
||||
|
||||
### D-32: CI — Gitea primary, Linux-only runners, not yet activated
|
||||
- **Date:** 2026-04-21
|
||||
- **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.
|
||||
- **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.
|
||||
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
|
||||
### D-32: CI — GitHub Actions, Linux + Windows runners, active
|
||||
- **Date:** 2026-04-21 (amended 2026-06-15)
|
||||
- **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:** 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:** 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.
|
||||
|
||||
### 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."
|
||||
|
||||
---
|
||||
|
||||
### 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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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.
|
||||
- **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).
|
||||
|
||||
@@ -21,11 +21,15 @@
|
||||
/// IO wrapper the orchestrator calls. Flutter-free by design.
|
||||
library;
|
||||
|
||||
import 'dart:ffi' show Abi;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
|
||||
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
|
||||
/// 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
|
||||
@@ -90,26 +94,6 @@ String? resolveClideCliDir({required String? currentPath, required List<String>
|
||||
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
|
||||
/// spawn args (context note + allow rule) to prepend to a session's argv.
|
||||
class AgentBootstrap {
|
||||
@@ -125,10 +109,13 @@ class AgentBootstrap {
|
||||
/// orchestrator merges both into one `--append-system-prompt`.
|
||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
|
||||
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>[
|
||||
if (home != null && home.isNotEmpty) '$home/.local/bin',
|
||||
'$workspaceRoot/native/${nativeClideDirName()}',
|
||||
'$workspaceRoot/native/${currentNativeDirName()}',
|
||||
File(Platform.resolvedExecutable).parent.path,
|
||||
];
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
|
||||
@@ -420,7 +420,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (!mounted) return;
|
||||
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
|
||||
_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
|
||||
|
||||
@@ -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';
|
||||
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
|
||||
|
||||
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent
|
||||
/// `claude --session-id <sessionId>` re-creates it empty — the in-place
|
||||
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl`
|
||||
/// and the sidecar `<id>/` directory claude keeps beside it. Best-effort:
|
||||
/// missing entries are not an error. The caller MUST have killed the session's
|
||||
/// process first, so claude is not mid-write.
|
||||
/// `/clear` path for the primary pane (T-268). Removes the `<id>.jsonl`, plus
|
||||
/// a per-session `<id>/` sidecar dir if one exists (best-effort; missing
|
||||
/// entries are not an error). Note the shared per-project `memory/` dir that
|
||||
/// 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 {
|
||||
final file = File('$projectDir/$sessionId.jsonl');
|
||||
if (await file.exists()) await file.delete();
|
||||
@@ -124,12 +130,18 @@ String freshSessionId() {
|
||||
/// same id). Expands an FNV-1a stream into 16 bytes.
|
||||
String _deterministicUuid(String seed) {
|
||||
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;
|
||||
for (var i = 0; i < 16; i++) {
|
||||
for (final c in utf8.encode('$seed:$i')) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -313,12 +313,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final m = _sessions.remove(id);
|
||||
if (m == null) return;
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,20 @@ class ClaudeStreamJsonProcess extends StreamJsonProcess {
|
||||
|
||||
@override
|
||||
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();
|
||||
try {
|
||||
await _proc.exitCode.timeout(const Duration(seconds: 2));
|
||||
} on TimeoutException {
|
||||
_proc.kill(ProcessSignal.sigkill);
|
||||
await _proc.exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -288,6 +301,20 @@ class SessionEnd {
|
||||
|
||||
final int exitCode;
|
||||
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 {
|
||||
@@ -965,10 +992,17 @@ class StreamJsonSession {
|
||||
_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
|
||||
await _sub?.cancel();
|
||||
await _proc.kill();
|
||||
await _proc.kill(); // awaits the process's real exit (T-437)
|
||||
await _items.close();
|
||||
await _statusCtl.close();
|
||||
await _workflowsCtl.close();
|
||||
|
||||
@@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier {
|
||||
_dirty = false;
|
||||
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':
|
||||
// A source (e.g. a saved .editorconfig) re-resolved the buffer's
|
||||
// settings. Refresh the active buffer's copy so indent/ruler update.
|
||||
|
||||
@@ -108,12 +108,20 @@ class _EditorViewState extends State<EditorView> {
|
||||
final c = _controller!;
|
||||
_syncTabs(c);
|
||||
_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) {
|
||||
_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.value = TextEditingValue(text: c.content, selection: sel);
|
||||
_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
|
||||
}
|
||||
@@ -274,7 +282,13 @@ class _EditorViewState extends State<EditorView> {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!id.startsWith('editor.vim.')) {
|
||||
// Mode change (vim.mode.*) or any other command.
|
||||
|
||||
@@ -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()}';
|
||||
}
|
||||
@@ -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)';
|
||||
@@ -34,6 +34,7 @@ export 'src/keymap/pane_key_nav.dart';
|
||||
export 'src/keymap/sequence_matcher.dart';
|
||||
export 'src/keymap/when_clause.dart';
|
||||
export 'src/dialog.dart';
|
||||
export 'src/ex_line.dart';
|
||||
export 'src/extensions_manager.dart';
|
||||
export 'src/file_open.dart';
|
||||
export 'src/files.dart';
|
||||
|
||||
@@ -14,6 +14,8 @@ library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/env/shell_env.dart' show expandToolPath;
|
||||
|
||||
/// State of the `clide` shell command relative to the running GUI.
|
||||
enum CliInstallState {
|
||||
/// No `clide` resolves on PATH.
|
||||
@@ -209,7 +211,7 @@ class CliInstaller {
|
||||
|
||||
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
|
||||
/// `%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
|
||||
/// classified separately (T-256) rather than as a clean install.
|
||||
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(':');
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import 'package:clide/kernel/src/notify.dart';
|
||||
import 'package:clide/kernel/src/os.dart';
|
||||
import 'package:clide/kernel/src/panels/arrangement.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/quick_open.dart';
|
||||
import 'package:clide/kernel/src/reader_nav.dart';
|
||||
@@ -58,6 +59,7 @@ class KernelServices {
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.quickOpen,
|
||||
required this.exLine,
|
||||
required this.recentFiles,
|
||||
required this.readerNav,
|
||||
required this.keybindings,
|
||||
@@ -97,6 +99,9 @@ class KernelServices {
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final QuickOpenController quickOpen;
|
||||
|
||||
/// Transient Vim ex command-line overlay state (T-407).
|
||||
final ExLineController exLine;
|
||||
final RecentFilesService recentFiles;
|
||||
final ReaderNavRegistry readerNav;
|
||||
final KeybindingResolver keybindings;
|
||||
@@ -167,6 +172,7 @@ class KernelServices {
|
||||
final palette = PaletteController(commands);
|
||||
final recentFiles = RecentFilesService();
|
||||
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
|
||||
final exLine = ExLineController();
|
||||
final readerNav = ReaderNavRegistry(messages);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
@@ -253,6 +259,7 @@ class KernelServices {
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
quickOpen: quickOpen,
|
||||
exLine: exLine,
|
||||
recentFiles: recentFiles,
|
||||
readerNav: readerNav,
|
||||
keybindings: keybindings,
|
||||
@@ -286,6 +293,7 @@ class KernelServices {
|
||||
commands.dispose();
|
||||
palette.dispose();
|
||||
quickOpen.dispose();
|
||||
exLine.dispose();
|
||||
toast.dispose();
|
||||
recentFiles.dispose();
|
||||
readerNav.dispose();
|
||||
|
||||
@@ -77,6 +77,22 @@ class QuickOpenAcceptIntent extends Intent {
|
||||
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 ----------------------------------------------------------
|
||||
|
||||
/// 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.accept': () => const QuickOpenAcceptIntent(),
|
||||
'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.
|
||||
'nav.down': () => const NavDownIntent(),
|
||||
'nav.up': () => const NavUpIntent(),
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
/// intent here and to the editor motion in the editor — see vim.yaml.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -101,10 +103,22 @@ class _PaneKeyNavState extends State<PaneKeyNav> {
|
||||
final r = _matcher!.feed(chord);
|
||||
switch (r.outcome) {
|
||||
case SeqOutcome.fired:
|
||||
// The vim preset also binds these keys to editor.vim.* motions; in a
|
||||
// pane only nav.* applies. A non-nav fired intent (e.g. a stray
|
||||
// editor.vim.* with no focus guard) is swallowed, never executed here.
|
||||
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count);
|
||||
// The vim preset binds these keys to several intent kinds. In a pane:
|
||||
// - nav.* drives the pane (onNav);
|
||||
// - editor.vim.* buffer edits are swallowed — never edit from a pane;
|
||||
// - 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;
|
||||
case SeqOutcome.pending:
|
||||
return KeyEventResult.handled;
|
||||
|
||||
@@ -42,10 +42,12 @@ class QuickOpenController extends ChangeNotifier {
|
||||
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;
|
||||
_open = true;
|
||||
_filter = '';
|
||||
_filter = seed ?? '';
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
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/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
export 'tree_sitter_service_stub.dart' if (dart.library.ffi) 'tree_sitter_service_ffi.dart';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,6 +10,13 @@ library;
|
||||
|
||||
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).
|
||||
class ResolvedPaths {
|
||||
const ResolvedPaths({this.git, this.pql, this.shell, this.gitEnv});
|
||||
@@ -144,25 +151,8 @@ String? _firstExisting(List<String> candidates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build expanded PATH inline — must be self-contained for isolate use.
|
||||
String _expandedPath() =>
|
||||
expandToolPath(Platform.environment['PATH'] ?? '', isMac: Platform.isMacOS, isLinux: Platform.isLinux, home: Platform.environment['HOME']);
|
||||
|
||||
/// Pure PATH-expansion logic, extracted so it's testable without touching the
|
||||
/// 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(':');
|
||||
}
|
||||
/// The full tool search PATH — the login-shell PATH (probed once at startup)
|
||||
/// unioned with the well-known user/local bin dirs, shared with every other
|
||||
/// 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).
|
||||
String _expandedPath() => resolvedToolPath();
|
||||
|
||||
@@ -25,7 +25,9 @@ library;
|
||||
import 'dart:convert';
|
||||
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
|
||||
/// available on this platform or the probe failed" — never an error.
|
||||
|
||||
@@ -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
@@ -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/git/client.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/mcp_server.dart';
|
||||
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/registry.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/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -81,7 +84,7 @@ Future<void> main() async {
|
||||
|
||||
binding.ensureSemantics();
|
||||
|
||||
TreeSitterLib.init();
|
||||
initTreeSitter();
|
||||
|
||||
final appDir = await _resolveAppDir();
|
||||
final themes = await _loadBundledThemes();
|
||||
@@ -98,6 +101,11 @@ Future<void> main() async {
|
||||
LogLevel bootLogLevel = kReleaseMode ? LogLevel.warn : LogLevel.info;
|
||||
List<LogSink> bootLogSinks = const [];
|
||||
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);
|
||||
await bootSettings.load();
|
||||
startupWorkRoot = resolveStartupWorkspace(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/// Verb list matches CLAUDE.md's tier-2 surface:
|
||||
/// editor.open editor.active editor.activate editor.insert
|
||||
/// 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
|
||||
/// 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.save', (req) => _save(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(
|
||||
@@ -220,3 +228,19 @@ Future<IpcResponse> _close(IpcRequest req, EditorRegistry r) async {
|
||||
r.close(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});
|
||||
}
|
||||
|
||||
Vendored
+93
@@ -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;
|
||||
}
|
||||
@@ -101,7 +101,11 @@ String logDirectory([Map<String, String>? env]) {
|
||||
String fnv1a64Hex(String s) {
|
||||
// Desktop-only (the IPC server is desktop-only per D-56). Dart VM
|
||||
// ints are 64-bit; arithmetic wraps modulo 2^64 naturally.
|
||||
var h = 0xcbf29ce484222325;
|
||||
// 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;
|
||||
final bytes = utf8.encode(s);
|
||||
for (final b in bytes) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../env/shell_env.dart' show resolvedToolPath;
|
||||
import '../ipc/envelope.dart';
|
||||
import '../pty/pty_log.dart';
|
||||
import '../pty/pty_session.dart';
|
||||
@@ -53,6 +54,9 @@ class PaneRegistry {
|
||||
// Terminal defaults for the PTY child.
|
||||
final fullEnv = <String, String>{
|
||||
...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',
|
||||
'COLORTERM': 'truecolor',
|
||||
'LANG': 'en_US.UTF-8',
|
||||
|
||||
+7
-22
@@ -8,29 +8,14 @@
|
||||
/// the renderer can do true colour.
|
||||
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,
|
||||
/// ~/.local/bin, and similar directories. This getter returns the
|
||||
/// platform PATH with those well-known directories merged in.
|
||||
/// On Linux/Windows it returns the PATH unchanged.
|
||||
String get expandedPath {
|
||||
_cachedPath ??= _buildExpandedPath();
|
||||
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(':');
|
||||
}
|
||||
/// The full tool search PATH for PTY children and PATH-resolved subprocess
|
||||
/// lookup — delegates to the shared resolver ([resolvedToolPath], T-439) so
|
||||
/// every spawn site agrees: the login-shell PATH (probed once at startup)
|
||||
/// unioned with the well-known user/local bin dirs. Previously this was a
|
||||
/// macOS-only merge, so a Linux desktop launch left tools unresolvable.
|
||||
String get expandedPath => resolvedToolPath();
|
||||
|
||||
/// Base env clide builds for every PTY child. Callers merge with the
|
||||
/// user's environment — a child that needs user env like `HOME` /
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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.');
|
||||
@@ -8,12 +8,13 @@
|
||||
/// in → EOF on child exit → close() reaps.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
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 'windows_pty.dart';
|
||||
|
||||
abstract interface class PtySession {
|
||||
/// OS process id of the spawned child.
|
||||
@@ -51,25 +52,12 @@ PtySession startPtySession({
|
||||
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,
|
||||
);
|
||||
}
|
||||
}) => startPtyBackend(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
log: log,
|
||||
);
|
||||
|
||||
@@ -120,6 +120,19 @@ class RootShellState extends State<RootShell> {
|
||||
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>(
|
||||
onInvoke: (_) {
|
||||
widget.services.arrangement.setVisible(Slots.sidebar, true);
|
||||
@@ -160,6 +173,7 @@ class RootShellState extends State<RootShell> {
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const QuickOpenOverlay(),
|
||||
const ExLineOverlay(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
const ToastOverlay(),
|
||||
],
|
||||
|
||||
+7
-17
@@ -25,10 +25,11 @@ import 'builtin/files/files.dart';
|
||||
import 'builtin/git/git.dart';
|
||||
import 'builtin/terminal/terminal.dart';
|
||||
import 'extension/extension.dart' show ClideExtension;
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
import 'kernel/kernel.dart';
|
||||
import 'src/pty/ffi/libc.dart' as libc;
|
||||
|
||||
// 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/ipc/envelope.dart';
|
||||
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)';
|
||||
});
|
||||
|
||||
// Test: does Dart's Process.start inherit socket fds on macOS?
|
||||
await _testAsync('fd inheritance check', () 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()}';
|
||||
});
|
||||
// Test: does Dart's Process.start inherit socket fds on macOS? (T-438: the
|
||||
// FFI body lives in fd_check_io.dart so the web build can stub it out.)
|
||||
await _testAsync('fd inheritance check', fdInheritanceCheck);
|
||||
|
||||
_say('');
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export 'src/clide_collapser_card.dart';
|
||||
export 'src/clide_code_block.dart';
|
||||
export 'src/clide_divider.dart';
|
||||
export 'src/clide_file_image.dart';
|
||||
export 'src/ex_line_overlay.dart';
|
||||
export 'src/clide_filter_box.dart';
|
||||
export 'src/clide_lightbox.dart';
|
||||
export 'src/clide_markdown.dart';
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ description: >-
|
||||
subsystem handlers (pane, files, editor, git, pql), and the
|
||||
extension framework.
|
||||
publish_to: none
|
||||
version: 2.5.0
|
||||
version: 2.6.0
|
||||
repository: https://github.com/postmeridiem/clide
|
||||
# Short user-facing tagline (the welcome subtitle, web meta
|
||||
# description, etc.). Baked into lib/src/build_info.g.dart by
|
||||
|
||||
@@ -33,6 +33,26 @@ class _FakeProc extends StreamJsonProcess {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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 --------------------------
|
||||
|
||||
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', () {
|
||||
test('keeps only the last cap lines', () {
|
||||
final b = BoundedLineBuffer(cap: 3);
|
||||
|
||||
@@ -187,6 +187,59 @@ void main() {
|
||||
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 {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
|
||||
@@ -6,6 +6,7 @@ library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/editor_view.dart';
|
||||
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';
|
||||
@@ -76,6 +77,32 @@ void main() {
|
||||
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 {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
|
||||
String? activated;
|
||||
|
||||
@@ -89,6 +89,49 @@ void main() {
|
||||
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 {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
final r = await call('editor.insert', {'text': 'X '});
|
||||
|
||||
@@ -130,23 +130,8 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('expandedPath', () {
|
||||
test('non-macOS returns PATH unchanged', () {
|
||||
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);
|
||||
});
|
||||
});
|
||||
// PATH expansion moved to the shared resolver (T-439); its logic is covered by
|
||||
// expandToolPath in toolchain_paths_test + shell_env_test.
|
||||
|
||||
group('install', () {
|
||||
test('fails clearly when no bundled client is present', () {
|
||||
|
||||
@@ -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)', () {
|
||||
test('setPreset activates it and the representative subset resolves', () async {
|
||||
final svc = await activate('jetbrains');
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
/// end-to-end against the real vim preset and scope flags.
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -67,6 +69,47 @@ void main() {
|
||||
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 {
|
||||
final got = await pump(tester, scope: {'vim.insert': true});
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
|
||||
@@ -8,7 +8,9 @@ import 'dart:ffi';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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:flutter_test/flutter_test.dart';
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
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';
|
||||
|
||||
const _libPath = 'native/linux-x64/libtree-sitter.so';
|
||||
|
||||
+7
-11
@@ -23,20 +23,16 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('expandedPath', () {
|
||||
test('returns a non-empty string on every platform', () {
|
||||
group('expandedPath (delegates to the shared resolver, T-439)', () {
|
||||
test('non-empty on every platform', () {
|
||||
expect(expandedPath, isNotEmpty);
|
||||
});
|
||||
|
||||
test('on Linux/Windows, equals Platform.environment[PATH]', () {
|
||||
if (Platform.isMacOS) return; // macOS path-merge tested separately.
|
||||
expect(expandedPath, Platform.environment['PATH']);
|
||||
});
|
||||
|
||||
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')));
|
||||
test('is a superset of the process PATH (never drops entries)', () {
|
||||
final got = expandedPath.split(':').toSet();
|
||||
for (final dir in (Platform.environment['PATH'] ?? '').split(':').where((e) => e.isNotEmpty)) {
|
||||
expect(got, contains(dir));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Vendored
+102
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user