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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:23:09 +02:00
jpmschweitzer 0fbe13003e Merge pull request #1 from postmeridiem/windows-support
Windows desktop support + crash-survivable observability + CI/release pipeline (v2.5.0)
2026-06-15 12:48:41 +02:00
jpmschweitzerandClaude Opus 4.8 d42fdea23a chore(plan): close T-433 + epic T-425; cancel duplicate breakdown T-426-430
T-425 had a pre-existing breakdown (T-426-430) I didn't check for and
re-filed as T-432-436, which is what got implemented. Cancelled T-426-430 as
duplicates (each notes its implemented twin) and closed the epic — the
crash-survivable logging / observability work is complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:34:44 +02:00
jpmschweitzerandClaude Opus 4.8 432a5f2d5a feat(log): live verbosity toggle — dock Level chip + clide log level (T-433)
The boot-time toggle (CLIDE_LOG / app.log.level) existed; this makes it
adjustable at runtime, from the two surfaces that fit — D-6 parity — and NOT a
Claude-composer slash command (log verbosity isn't a Claude-session concept).

- Output dock Level chip: was a view-only filter; now also drives the kernel
  Logger.minLevel and persists app.log.level, so the choice is real (changes
  what's captured) and survives restart. Initialized from the logger's current
  level so it reflects a CLI change. A null callback keeps it a pure view
  filter (tests / no kernel).
- `clide log level [<level>]` (lib/src/daemon/log_commands.dart): no arg reports
  the level + vocabulary; a valid level sets the live logger and persists;
  unknown → bad_arg (code 64), logger untouched. Wired in buildDispatcher with
  the kernel logger + settings (captured post-boot).

Tested: the command (get/set/case-insensitive/bad-arg, live + persist) and the
controller (initial level, callback fires, same-level no-op, filter-only when
unwired). Coverage gate 95.05%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:32:56 +02:00
jpmschweitzerandClaude Opus 4.8 f8477816c4 fix(pty): IsolateCrumbFile creates its parent dir (soak crumb capture)
The T-436 soak run came back green but produced no conpty-kill-crumbs artifact:
IsolateCrumbFile opened its append handle without creating the parent dir, and
the standalone orphan probe points it at a fresh CLIDE_LOG_DIR that nothing
else had made — so openSync failed, the crumb file was disabled, and no crumbs
were written. In the app this was masked because FileLogSink already creates
logDirectory() at boot. Create the parent ourselves (no-op when it exists).
Verified by the bundle-smoke artifact, which DID capture a real watchdog sample
(threads=31, handles=25, rssMB=175) because the release app makes the dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:13:07 +02:00
jpmschweitzerandClaude Opus 4.8 514feefccf chore(plan): mark T-436 (CI crash-evidence artifacts) done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:59:05 +02:00
jpmschweitzerandClaude Opus 4.8 f40be4f758 feat(ci): capture crash evidence as artifacts when a run wedges (T-436)
Closes the observability loop: the log + breadcrumb + watchdog files are now
collected by CI so a wedged run leaves downloadable evidence instead of
nothing.

- logDirectory(): CLIDE_LOG_DIR overrides the per-platform default, so CI can
  point the logs at an uploadable workspace dir (and tests at a temp dir).
  Now takes an injectable env map; tested.
- test_app.dart: when CLIDE_LOG_DIR is set, the testmode harness tees its
  logger to a FileLogSink + spawns the watchdog (off by default — normal
  run-testmode keeps the stderr-only path, no isolate). _say breadcrumbs each
  test into the file.
- conpty_orphan_probe.dart: with CLIDE_LOG_DIR set it passes a verbose PtyLog,
  so when soak-conpty-kill.ps1 force-kills the parent, the reader/waiter
  isolates' LAST crumb is fsynced to disk — naming what the wedged isolate was
  doing at the instant of death.
- bundle-smoke job: runs the real release app with CLIDE_LOG=debug +
  CLIDE_LOG_DIR, uploads clide-logs (watchdog heartbeat/sample + FileLogSink)
  in an always() step.
- windows-soak kill-probe job: sets CLIDE_LOG_DIR, uploads the FFI crumbs.

Coverage gate 95.08%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:58:48 +02:00
jpmschweitzerandClaude Opus 4.8 ee8c9dcc11 docs(watchdog): drop unresolved [IsolateCrumbFile] doc links
watchdog.dart doesn't import pty_log.dart (only references the pattern), so the
[IsolateCrumbFile] doc references couldn't resolve and the dart-doc gate fails
on the warning. Plain-code-font instead of a doc link — no import added just for
a cross-reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:30:02 +02:00
jpmschweitzerandClaude Opus 4.8 16953653eb chore(plan): mark T-435 (crash-diagnostic watchdog) done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:16:01 +02:00
jpmschweitzerandClaude Opus 4.8 59c5c32b5b feat(watchdog): dedicated-isolate heartbeat + resource sampler (T-435)
A main-isolate Timer would freeze WITH the main isolate and tell us nothing,
so the watchdog runs in its own isolate: it fsyncs a heartbeat every ~500ms
(so the last on-disk heartbeat bounds a freeze to ~500ms) and every ~2s samples
this process's thread / handle-or-fd / child-host / RSS counts. A monotonically
climbing child or thread count is the leak signature the soak couldn't
reproduce on CI but a real freeze would show. Output is JSON-lines in
clide-watchdog.log, bounded by the same truncate-on-cap scheme as the crumb
files.

- watchdog.dart (Flutter-free, tested): ResourceSample, ResourceSampler
  (forPlatform), PosixResourceSampler (/proc/self: Threads, fd count, task
  children, ProcessInfo.currentRss), WatchdogFile (bounded fsynced JSON-lines),
  runWatchdog (the loop, bounded by maxTicks for tests), watchdogEntry (the
  sendable Isolate.spawn entry).
- watchdog_windows.dart (coverage:ignore — Win32 FFI, validated only at runtime
  on Windows): one CreateToolhelp32Snapshot for thread + conhost/OpenConsole
  child count, GetProcessHandleCount, ProcessInfo.currentRss. Exhaustively
  defensive: any failure yields a -1 field, snapshot handle always closed, never
  throws.
- main.dart: spawn the watchdog at boot (desktop only), non-fatal. Per-line
  fsync means the OS reaping the isolate at exit loses nothing.

Tests: ResourceSample.toJson, Posix sampler against real /proc, WatchdogFile
(JSON shape, cap, disabled), runWatchdog (immediate baseline tick). Coverage
gate 95.08%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:15:49 +02:00
jpmschweitzerandClaude Opus 4.8 b7d6d6f6f2 chore(plan): mark T-434 (PTY FFI breadcrumbs) done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:57:12 +02:00
jpmschweitzerandClaude Opus 4.8 9837473ca7 feat(pty): FFI breadcrumbs around the syscalls that wedge (T-434)
The freeze hypothesis is a wedged FFI call — a reader isolate blocked forever
in ReadFile, a waiter in WaitForSingleObject, Isolate.kill unable to interrupt
either. To NAME the wedge after a power-cycle, each backend now drops a
breadcrumb before/after every risky syscall.

- pty_log.dart (new, Flutter-free, tested): PtyLog — an injectable, no-op-by-
  default breadcrumb hook for the MAIN isolate (wired to the kernel Logger,
  source 'conpty'/'pty' = an eager FileLogSink source) — and IsolateCrumbFile,
  which the SPAWNED reader/waiter isolates use to open their OWN append handle
  and flushSync per line, so a wedged isolate's last crumb survives even a
  frozen main isolate (the whole point). Bounded by a truncating size cap.
- native_pty.dart + windows_pty.dart: crumbs around posix_spawn/read and
  CreatePseudoConsole/CreateProcessW/ReadFile/WaitForSingleObject; the reader/
  waiter isolates carry a sendable crumb path + verbose flag. Per-syscall crumbs
  only at debug/trace; lifecycle crumbs always.
- Wiring: startPtySession → PaneRegistry → buildDispatcher build the PtyLog from
  the kernel Logger + a crumb file under logDirectory(); verbose follows the log
  level. Default everywhere is PtyLog.none — zero behaviour change off the wire.

Tested: PtyLog/IsolateCrumbFile units (cap-truncation, append, no-op) + an
end-to-end real-PTY test asserting the reader isolate writes its own crumbs
('reader started' / 'read -> n=' / 'reader exiting'), which validates the
identical Windows structure that can't run here. Coverage gate 95.10%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:56:53 +02:00
jpmschweitzerandClaude Opus 4.8 1faa047393 release: sync licenses.yaml self.version to 2.5.0
gen-build-info auto-syncs assets/licenses.yaml `self.version` from pubspec on
every build/run/test; this is the generated catch-up to the 2.5.0 cut (it ran
during the coverage build). Also persists the T-432 done status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:30:24 +02:00
jpmschweitzerandClaude Opus 4.8 85cc34e09c feat(log): crash-survivable FileLogSink + dev/prod verbosity toggle (T-432)
First increment of the observability epic (T-425), the productive pivot after
the ConPTY freeze refused to reproduce on CI: if we can't reproduce it, make
the next occurrence leave evidence.

- FileLogSink (lib/kernel/src/file_log_sink.dart): synchronous, crash-survivable
  LogSink. Appends each record as one JSON line to a size-rotated file; fsyncs
  warn/error + risky-source (pty/ffi/conpty/watchdog) records immediately so the
  last breadcrumb is on disk before a hard death, batches the rest on a timer.
  Never throws. Flutter-free → unit-tested under dart test against a temp dir.
- logDirectory() (paths.dart): persistent per-platform log dir (LOCALAPPDATA /
  ~/Library/Logs / $XDG_STATE_HOME) — durable across reboot, unlike the
  ephemeral socketDirectory.
- resolveLogLevel() (log.dart): the requested dev/prod toggle. CLIDE_LOG
  dart-define → CLIDE_LOG env → app.log.level setting → warn(release)/info(debug).
  Lenient parse; an invalid source falls through.
- Boot wiring (facade.boot + main.dart): FileLogSink leads the sink chain (so a
  crash records before the volatile stderr/ring sinks) and the resolved level
  sets Logger.minLevel.

Tests: FileLogSink (JSON shape, error/stack, rotation cap, append-across-restart,
timer-cancel), resolveLogLevel precedence + fall-through, logDirectory per-OS.
Coverage gate 95.11%.

Follow-ups under T-425: live toggle CLI/command/chip (T-433), FFI breadcrumbs
(T-434), watchdog isolate (T-435), CI artifact wiring (T-436).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:29:41 +02:00
jpmschweitzerandClaude Opus 4.8 b3acb8a34c docs(changelog): mark 2.5.0 Windows support experimental / preview
The ConPTY child-reaping freeze (T-424) is still open, so the 2.5.0 headline
shouldn't read as production-ready. Reframe to "Experimental Windows desktop
support" with an explicit preview-quality caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:00:32 +02:00
jpmschweitzerandClaude Opus 4.8 74649ad0d1 release: cut 2.5.0 (arms the first automated release build on merge to main)
release.yml fires when pubspec.yaml's version changes on main and the v<version>
tag doesn't yet exist. Bump 2.4.1 -> 2.5.0 and move the Unreleased entries under
a dated 2.5.0 heading (per the changelog discipline: version + cut in one
commit), so merging windows-support to main triggers the first versioned
Linux + Windows release build and publishes the 2.5.0 notes.

Minor bump: the release is all additive — Windows desktop support, vim window /
workspace-tab commands, the Claude sidebar + TUI command work, tmux dropped as a
required tool, and the ClaudeConfig teardown-race fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:58:23 +02:00
jpmschweitzerandClaude Opus 4.8 2953207960 ci(soak): abrupt-death ConPTY orphan probe (the failure-mode the clean soak misses)
The clean-path soak found no leak on windows-latest — because orderly close()
reaps every host. That never exercises the freeze hypothesis (T-424), which is
the parent dying WITHOUT teardown while a child is live.

This probe does. conpty_orphan_probe.dart starts N real WindowsPty sessions on
long-lived children and blocks WITHOUT ever calling close(); soak-conpty-kill.ps1
force-kills only the dart.exe parent (taskkill /F, no /T) once the hosts are up
and counts the conhost/OpenConsole/cmd processes that SURVIVE. Absent a
kill-on-close Job Object, abrupt parent death should orphan them — a survivor
count that climbs across cycles is the leak signature. The same probe will prove
the T-424 fix: with the job, survivors should drop to ~0.

Wired as a second job in windows-soak.yml (workflow_dispatch + when the kit
changes). Diagnostic only, never a gate, always exits 0; publishes the verdict
to the job summary and uploads the CSV. Not part of the regular test suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:58:14 +02:00
jpmschweitzerandClaude Opus 4.8 c1d14bbaf0 fix(soak): invalid composite-format alignment {3,+4} crashed the readout
First real execution of soak-conpty.ps1 on GitHub's windows-latest proved the
harness works — it spawned real ConPTY children, ran the suite (6 tests), and
measured the first iteration — then died printing the per-iter line: `{3,+4}`
is a printf-ism, not valid .NET composite formatting (the `+` is rejected with
"Expected an ASCII digit"). The script had never run on Windows before, so the
typo was latent. Drop to `{3,4}` (plain width).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:04:19 +02:00
jpmschweitzerandClaude Opus 4.8 4b55bc23f1 ci: run the ConPTY orphan-leak soak on a GitHub Windows runner (no VM)
The freeze diagnosis (T-424) was going to need a dedicated Windows VM to soak-
test. It doesn't: GitHub's windows-latest already runs our ConPTY suite green,
and tools/windows-verify/soak-conpty.ps1 detects the leak by counting the
conhost/OpenConsole hosts that survive each run — it never tries to crash the
box, so a throwaway runner works. The repeated runs happen inside one job, so
the orphan count can climb there even though the runner is discarded after.

New windows-soak.yml: workflow_dispatch + triggers when the soak kit changes.
Diagnostic only (always exits 0); publishes the verdict to the job summary and
uploads the CSV. This removes the GCP/VM requirement from the human entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:50:48 +02:00
jpmschweitzerandClaude Opus 4.8 704bdc35db ci: fix integration + bundle jobs on their first GitHub run
These two jobs moved from the old Gitea runner and ran for the first time on
GitHub ubuntu-latest in this branch's test workflow; both failed on
environment differences, not on product regressions.

integration_test: the GitHub runner exposes BOTH a `linux` desktop and a
`chrome` web device, so a bare `flutter test integration_test/...` aborts
with "More than one device connected" before compiling. The Gitea runner /
dev box only had the one device, so it was latent. Pin `-d linux` in
ci/test_integration.sh. Verified locally: the full suite boots and passes.

smoke-bundle: the `smoke-bundle` make target was the only test target
missing the `gen-build-info` prerequisite, so build_info.g.dart was absent
and the release build failed to compile (clideVersion/clideCommit/clideName/
clideTagline/... undefined). Add the prereq. Verified locally: the release
bundle now builds (`✓ Built .../release/bundle/clide`); the xvfb run step is
CI-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:42:33 +02:00
jpmschweitzerandClaude Opus 4.8 ebea0dd846 docs(pty): correct the coverage-ignore rationale; note T-431 + windows.yml gap
A testability audit of the ignore span found the original comment overclaimed
("none of it can execute on Linux") and credited the wrong test file. Reality,
verified fragment by fragment:

- The span is excluded at FILE granularity but is not 100% syscall — _Coord /
  _StartupInfoExW struct packing and write()'s empty-guard are pure transforms
  that could be unit-tested on Linux if extracted from the binding-touching
  methods. Tracked in T-431 (also covers the mirror gap in native_pty.dart's
  POSIX marshalling).
- The pure helpers are tested by windows_pty_args_test.dart (not _test.dart).
- The FFI path's BEHAVIOUR is validated on windows-latest (real ConPTY spawn),
  but windows.yml collects no coverage — so there is intentionally no line-
  coverage metric for this span anywhere; correctness rests on that functional
  suite + the VM soak, not on coverage.

Comment-only; no code or coverage change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:38:29 +02:00
jpmschweitzerandClaude Opus 4.8 1114db1c06 ci: exclude Windows-only ConPTY FFI from the Linux coverage denominator
windows_pty.dart's Win32 structs, kernel32 bindings, and the WindowsPty
session (CreatePseudoConsole / CreateProcessW / WaitForSingleObject) cannot
execute on the ubuntu-latest runner that produces the coverage report —
there is no kernel32 to bind. They were being counted as 214 uncovered
lines (the file sat at 13.7%), dragging total line coverage to 94.17% and
failing the 95% gate for code the gate's platform structurally cannot reach.

Wrap exactly that FFI span in `// coverage:ignore-start/end` (flutter test
--coverage honours the markers, verified). The pure, platform-agnostic spawn
helpers — resolveExecutable / quoteArg / composeEnvironmentBlock — sit after
the ignore-end and stay measured (covered by windows_pty_args_test.dart on
every platform). FFI-path behaviour is validated on the Windows runner
(windows.yml) and end-to-end in the Windows VM soak (tools/windows-verify/).

Gate back to green: 95.16% (floor 95%). Floor unchanged — the exclusion
restores the denominator to Linux-runnable code rather than lowering the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:28:25 +02:00
jpmschweitzerandClaude Opus 4.8 425a87ae9f ci: run goldens in regenerate mode on CI to keep paint coverage
The earlier "skip goldens when CI is set" guard kept the font-mismatch
failures away but pulled all the widget-paint code out of the coverage
run, dropping below the 95% floor and failing `make coverage-gate`.

Run the goldens on CI after all, but with forceUpdateGoldenFiles: the
paint code executes (so it stays covered) and the goldens regenerate
instead of comparing, so cross-machine font/freetype differences can't
fail them. The throwaway runner's regenerated PNGs are discarded; pixel
validation still happens locally before merge (CI unset -> normal
compare).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:15:18 +02:00
jpmschweitzerandClaude Opus 4.8 7136e33fdf ci: sync decisions from the DQR tree too (decision-query tests)
`pql plan import` materializes tickets from the changelog; decisions come from
the governance/ DQR markdown via `pql decisions sync`. The remaining 4 unit
failures were decisionList/decisionShow/decisionRead hitting exit 66. Run the
sync after the import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:00:33 +02:00
jpmschweitzerandClaude Opus 4.8 e9247c1761 ci: rebuild .pql/pql.db from the changelog after installing pql
Installing pql cut the failures 36→8, but the remaining ticket-query tests run
against the working directory's `.pql/pql.db`, which is gitignored — the
post-checkout hook normally rebuilds it from the committed `.pql/changelog/`.
A fresh CI checkout has the changelog but no db, so pql reports vault-not-found
(exit 66). Run `pql plan import` after the install to materialize it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:50:23 +02:00
jpmschweitzerandClaude Opus 4.8 f3d2b8bb57 ci: install pql in the test / integration / bundle jobs
The daemon pql command tests (test/daemon/pql_commands_test.dart, 36 cases)
shell out to the real pql binary, and the app boot exercises it too — GitHub
runners don't ship pql. Every prior run failed in the parallel pool before
reaching the serial pass that holds these tests; now that the pool is green,
they surface. Download the latest pql Linux release onto PATH in the three jobs
that run tests / boot the app. (The repo is pql-centric; this also future-proofs
adding the decisions gate to CI.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:35:02 +02:00
jpmschweitzerandClaude Opus 4.8 89c02b910d test(toolchain): don't require pql installed on the host
The 'resolvePaths resolves pql from PATH' test asserted paths.pql isNotNull —
true on the dev box (pql on PATH) but not on a GitHub runner, which doesn't ship
pql. Assert the resolver's contract instead: pql is null when absent, or a path
that really exists. Was the last red in the Linux unit job after the deflake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:22:18 +02:00
jpmschweitzerandClaude Opus 4.8 41d77fddb2 ci(release): use the version's CHANGELOG section as release notes
The version-gated build already builds + attaches the version-stamped Linux and
Windows bundles. Now the GitHub Release body is the `## [<version>]` CHANGELOG
section (the cut the version-bump commit lands per the changelog discipline),
with the auto-generated commit list appended — tying pubspec version, changelog,
and built artifacts together in one release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:13:22 +02:00
jpmschweitzerandClaude Opus 4.8 e55b4a9173 test: deflake the suite — pumpEventQueue instead of Future.delayed(Duration.zero)
~173 `await Future<void>.delayed(Duration.zero)` async-settle waits across 25
test files yield the microtask queue exactly once; when an event→handler chain
needs more than one hop they lose the race under CI's parallel load, so the
failing set varied run to run. Replace with `await pumpEventQueue()` (the
deterministic drain already used elsewhere in the suite); rewired the shared
settle()/tick() helpers in one shot. menu_bar's toggle-close test gets a bounded
extra pump. Verified green under CI=true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:13:13 +02:00
jpmschweitzerandClaude Opus 4.8 3dce5c614d fix(claude): guard ClaudeConfig.notifyListeners against use-after-dispose
setProjectDir / refresh / ensureProbe each await disk I/O then call
notifyListeners(), but — unlike load(), which already guards — they didn't
re-check _disposed afterward. A project switch (or watcher refresh) racing the
config's disposal fires notifyListeners() on a disposed ChangeNotifier and
throws "used after disposed". Surfaced deterministically by the test deflake
(pumpEventQueue drains the async that Future.delayed(Duration.zero) was masking).
Add the same `if (_disposed) return;` guard to all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:13:01 +02:00
jpmschweitzerandClaude Opus 4.8 04bb512cb7 test: green the first Windows CI run + skip non-portable goldens on CI
Two fixes from the first real CI execution of these suites:
- windows_pty_test: the non-existent-executable test asserted errno==2
  (ERROR_FILE_NOT_FOUND), but Dart FFI doesn't reliably preserve GetLastError
  across the lookupFunction boundary (CI Windows returned 0). Assert the
  PtyException op instead. The ConPTY suite otherwise passed 21/22 on real
  Windows with no stall — supporting the accumulation (not single-run) freeze
  theory.
- golden_harness: platform goldens are font-render-dependent across machines
  (dev Fedora vs GitHub ubuntu-latest), so run them locally only and skip on CI
  (the CI env var). Goldens stay a local pre-merge check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:43:13 +02:00
jpmschweitzerandClaude Opus 4.8 42d1d4acea ci(windows): drop redundant flutter analyze, reach the ConPTY tests
The Windows job failed at `flutter analyze` on the missing (gitignored,
generated) lib/src/build_info.g.dart — it never ran gen-build-info. But analyze
is platform-agnostic: the Linux job already statically analyzes windows_pty.dart
and everything else, and the flutter-build-windows release job catches
Windows-specific compile errors. Dropping it unblocks the job's real purpose —
running ConPTY under dart test — without needing build_info (the pty tests
import the pty libraries directly, not the build_info-bearing barrel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:28:07 +02:00
jpmschweitzerandClaude Opus 4.8 ba02727c5e ci: dartdoc_options ignores the broken-link category
The dart-doc gate (test.yml) should fail on real doc-comment defects
(unresolved [symbol] references — kept strict), not on dartdoc rendering
artifacts. clide is an app, not a published library: its README landing-page
relative links and the generated 1512-glyph Phosphor icon font produce broken
cross-links with no fixable cause. Ignore the broken-link category; verified
`dart doc --validate-links` now emits zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:14:43 +02:00
jpmschweitzerandClaude Opus 4.8 c08f249b00 docs: fix 41 unresolved dartdoc references
Convert non-resolving [refs] in doc comments to backtick code-spans across 24
lib/ files (param/field names out of doc scope, method refs on other classes,
non-API strings like regex char-classes and command ids). Verified 0
"unresolved doc reference" warnings via `dart doc --validate-links`. The
dart-doc CI gate (test.yml) never ran before — Gitea Actions was inactive — so
this debt had accumulated unchecked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:09:55 +02:00
jpmschweitzerandClaude Opus 4.8 8e0f33b79f ci: move Linux CI to GitHub Actions (.github/workflows/test.yml)
CI consolidates onto GitHub (primary remote); Gitea (secondary) has Actions
disabled. Move the Linux pipeline — unit + widget + golden + a11y + coverage
gate, integration_test (xvfb), bundle smoke, dart doc — verbatim from
.gitea/workflows/test.yml (its own header said to copy it here on GitHub), and
delete the dead Gitea copy. Joins windows.yml + release.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:44:20 +02:00
jpmschweitzerandClaude Opus 4.8 45aa2d924e ci: GitHub Actions — Windows tests + version-tagged release builds
- windows.yml: flutter analyze + the ConPTY/pty suite on windows-latest (the
  first real execution of lib/src/pty/windows_pty.dart). Keep out of required
  checks until reliably green (after T-424); uploads artifacts.
- release.yml: on a pubspec.yaml version bump on main, build Linux + Windows
  bundles via `make build` and publish a v<version> GitHub Release. First cut —
  Windows has never been built, so expect to iterate from the first run's logs.

GitHub is the primary remote and the only host with Windows runners; the Gitea
secondary has Actions disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:41:40 +02:00
jpmschweitzer e2b1abea56 windows-verify: headless run-headless.ps1 (no-RDP soak via GCE startup script) 2026-06-14 21:30:40 +02:00
jpmschweitzerandClaude Opus 4.8 1c7fc7d786 windows-verify: GCP / Windows Server support
GCP only offers Windows Server images, which lack winget. Rewrite
bootstrap-windows.ps1 to be winget-free (Chocolatey for git + VS Build Tools,
direct-from-Google for the Flutter SDK) and add a -SkipVS fast path — the
ConPTY soak only needs Flutter/Dart. Runs under Windows PowerShell 5.1. README
gains a self-contained GCP Cloud Shell runbook (create / reset-password / RDP /
stop / delete).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:23:12 +02:00
jpmschweitzer faacb732bc T-424: note pre-VM progress (clamp + timeout landed; FFI deferred to VM) 2026-06-14 20:55:29 +02:00
jpmschweitzerandClaude Opus 4.8 606d3df98f harden the ConPTY backend (Linux-verifiable pre-VM work)
Pre-Windows-VM hardening — the parts validatable on Linux, leaving the
unrunnable FFI (Job Object, T-424) for the VM session:

- Clamp PTY cols/rows to >= 2 in both backends' spawn + resize (new
  pty_size.dart). A 1-column ConPTY makes conhost spin emitting CRLF
  (microsoft/terminal#19922); 0 is invalid on both platforms.
- ci/test.sh: --timeout 60s on the dart-test pty line (matches the flutter
  lines) so a wedged ConPTY reader fails fast instead of hanging the run.
- Make windows_pty.dart's pure helpers public + testable off-Windows:
  quoteArg (MSVCRT quoting), composeEnvironmentBlock, and resolveExecutable
  (now takes an injectable existence probe). New windows_pty_args_test.dart
  + pty_size_test.dart give 15 cross-platform assertions over the trickiest
  Windows logic the on-Windows smoke suite can't reach off-platform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:54:50 +02:00
jpmschweitzerandClaude Opus 4.8 9c288a618b add windows-verify kit: provision + bootstrap + instrumented ConPTY soak
Verification tooling for the Windows freeze assessment (T-424).
provision-vm.sh stands up a Windows KVM guest (dry-run unless --go);
bootstrap-windows.ps1 installs Flutter + VS C++ Build Tools and checks out
the branch; soak-conpty.ps1 runs the pty suite in a fresh dart.exe per
iteration and measures the orphaned conhost/cmd count that survives each
exit (the leak signature), with a per-iteration timeout so a wedged test
can't stall the run. Verifies the ConPTY leak (#1-#4); the GPU/TDR
hypothesis (#5) needs passthrough/bare metal (README appendix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:54:46 +02:00
jpmschweitzerandClaude Opus 4.8 154c2c8e43 file T-424 (ConPTY Job-Object reaping) + T-425 logging epic
Follow-ups from the Windows test-freeze analysis:
- T-424 (bug, high): place each WindowsPty child in a kill-on-close Windows
  Job Object so the child AND its conhost.exe are reaped on session/test-
  process exit (rank-1 freeze culprit). Sibling ConPTY-teardown fixes noted
  in the description.
- T-425 (epic, high): crash-survivable logging & observability, so the next
  freeze leaves on-disk evidence. Children: T-426 FileLogSink, T-427 FFI
  breadcrumbs, T-428 watchdog isolate, T-429 dev/prod verbosity toggle,
  T-430 testmode/CI wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:16:55 +02:00
jpmschweitzerandClaude Opus 4.8 431e26d659 remove tmux as a required tool (dead since D-77)
Nothing has spawned tmux since D-77 moved Claude session persistence to
`--resume`; Claude and terminal panes spawn `claude` or the shell directly.
But the toolchain still resolved tmux and listed it in `missing`, so on
mac/linux a box without tmux showed a spurious "tmux not found" warning in
the welcome view + status bar. The windows-support branch had special-cased
that away with a `!Platform.isWindows` guard — the tell that the requirement
was dead everywhere, not platform-specific.

Drop tmux from ResolvedPaths / ToolchainView / Toolchain (field, getter,
`missing`, PATH resolution) on every platform, removing the Windows guards
with it. Strip the testmode tmux probes and the comments / CLAUDE.md line
that claimed clide spawns tmux. (The dead ToolCheck class that also gated on
tmux was already deleted on main and dropped in the preceding merge.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:22:04 +02:00
jpmschweitzerandClaude Opus 4.8 84a6ef7c77 Merge main into windows-support
Brings windows-support up to date with main (T-404/405/406, T-413–416,
T-421, the T-422 workspace-lifecycle epic, and the 2.4.0 release).

Conflict resolutions:
- terminal_pane.dart: keep the Windows PowerShell shell selection and
  main's workspace-cwd fix (T-381) together.
- tool_check.dart: accept main's deletion (dead, unreferenced code).
- CHANGELOG.md: keep both Unreleased sections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:21:41 +02:00
jpmschweitzerandClaude Opus 4.8 bb0d78f8de Q-51 + T-422 epic: unify workspace lifecycle on one fenced primitive
The status-bar branch bleed (T-421) is a symptom of a deeper gap: there
is no single "open workspace X" primitive — only project.open() (in-place,
same process, shared daemonBus) and newWindow() (blank detached process,
no repo arg, no env scrub). T-367 and T-269 are the same root.

- Q-51 (architecture): unify on WorkspaceService.open(root, target);
  open question of whether in-place switching survives at all vs a
  strict workspace⇒window⇒process⇒socket⇒bus⇒session-id 1:1 mapping.
- T-422 epic owns the unification; T-421 reparented under it; T-423
  builds the primitive and routes all entry points through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:47:33 +02:00
dehlakandClaude 03cc0603b0 port clide runtime to Windows (ConPTY, AF_UNIX, PATHEXT)
Bring the runtime up on Windows without disturbing the POSIX paths.

PTY: introduce a platform-neutral PtySession contract with a factory
that picks NativePty (posix_openpt/posix_spawn) or the new WindowsPty
(ConPTY via CreatePseudoConsole). The pane registry programs against
the interface; NativePty now implements it.

IPC: the per-workspace AF_UNIX socket lives under %LOCALAPPDATA% and
is hashed from a canonical workspace key (backslash + ASCII-folded
case) so the Dart server and the C client agree despite NTFS case-
insensitivity. The C client grows a Win32 shim (winsock afunix);
chmod is a no-op on Windows where the per-user ACL is the gate.

Toolchain: PATH probing splits on ';' and tries PATHEXT extensions;
the shell defaults to PowerShell (pwsh, then powershell); tmux is
treated as optional since it has no Windows build; dugite falls back
to PATH git for now.

Build: add `make build-windows`, a clide-cli MSVC build wrapped by
ci/build_cli_windows.sh, and a ConPTY smoke-test suite that self-
skips off-platform.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-14 17:42:55 +02:00
dehlakandClaude 3a59f4f2e6 scaffold Flutter Windows desktop target
Add the generated Windows runner tree and register the windows
platform in .metadata. The Flutter SDK revision bump that came with
`flutter create` for windows also re-resolved the transitive pins in
pubspec.lock. .gitignore now excludes the built clide-cli binary on
Windows, matching the existing linux/macos entries.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-14 17:42:29 +02:00
jpmschweitzerandClaude Opus 4.8 07b5133e99 file T-421: status-bar git branch bleeds across parallel windows
Bug: the status-bar branch sometimes shows another open window's
branch. Filed high-priority with investigation notes — contradicts the
T-269 cross-window isolation invariant. Two candidate root causes
captured (shared in-memory DaemonBus vs inherited CLIDE_SOCK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:31:26 +02:00
jpmschweitzerandClaude Opus 4.8 cbe76938a8 vim ctrl+w window-command family + global multi-chord matcher (T-404)
The structural piece: a global SequenceMatcher in root_shell, at the
HardwareKeyboard level so a focused editor/pane can't swallow the second
chord. It only STARTS on a modified chord that prefixes a sequence (ctrl+w),
so bare-key sequences (gg, dd) stay editor/pane-local and single-chord presets
are untouched; bare ctrl+w still fires editor.close after the D-82 timeout.

vim.yaml binds the window family under vim.normal||vim.visual: ctrl+w h/l →
panel.focus.left/right, j → dock.toggle, w / ctrl+w → focus.nextPanel,
shift+w → focus.previousPanel, o → panel.focusMode, q/c → editor.close.

Tests: ctrl+w sequence resolution at the keymap layer, plus app-level
integration (ctrl+w o toggles focus mode; bare ctrl+w closes the editor after
the timeout; a bare g is not grabbed globally).

This is the global matcher T-405 part 2 (gt/gT) was waiting on — though bare-g
sequences need more thought (g is editor-local), noted for that follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:19:32 +02:00
jpmschweitzerandClaude Opus 4.8 5a21ea949a workspace tab cycle commands + ctrl+pagedown/up (T-405 part 1)
Add workspace.tab.next / workspace.tab.previous commands that cycle the
Slots.workspace tab strip with wraparound (no-op under two tabs), bound
ctrl+pagedown / ctrl+pageup across every preset via defaultBindings. Single-
chord, so no global matcher needed. Activating a tab also focuses the
workspace slot.

Part 2 (vim gt/gT) is deferred — it needs the global multi-chord matcher
T-404 introduces. T-405 stays open for that follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:06:36 +02:00
jpmschweitzerandClaude Opus 4.8 054eaf6cd4 post-checkout hook: guard with if + force exit 0 (worktree-safe)
The pql post-checkout hook is untracked (local `pql init` install), so a fresh
`git worktree add` has none — and `[ -f x ] && . x` returns 1 when absent (the
script's last statement), which worktree add propagates as a hard failure.
Use an if-guard and always exit 0: post-checkout is best-effort and must never
abort a checkout / worktree creation. (Worth reporting upstream to pql.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:31:34 +02:00
jpmschweitzerandClaude Opus 4.8 008779cb1e make post-checkout hook tolerant of fresh worktrees
The pql post-checkout hook unconditionally sourced .pql/hooks/post-checkout
from the worktree toplevel, which doesn't exist in a fresh `git worktree add`
— aborting the checkout. Guard on the file existing so worktree creation
(used by parallel agent workflows) no longer fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:24:18 +02:00
jpmschweitzerandClaude Opus 4.8 25db19fc0e vim normal-mode navigation in non-editor panes (T-406)
The structural T-403 child: make vim normal mode mean navigation in panes
that were mouse-only. The passive global key path can't run multi-chord
sequences (D-82), so each pane hosts its own SequenceMatcher — factored into
a reusable PaneKeyNav that resolves the live keymap and dispatches nav.*
intents while a pane holds focus under the vim preset.

- nav.* intents (down/up/pageDown/pageUp/top/bottom/expandOrRight/
  collapseOrLeft/activate) — preset-neutral; vim.yaml binds j/k/ctrl+d/ctrl+u/
  gg/G/l/h/[o,enter] under `vim.normal && !editor.focused`.
- The editor publishes an `editor.focused` scope flag from its focus node, so
  the same keys stay buffer motions while the editor is focused and become nav
  when a pane is — resolved by file order + the guard (no change to the editor
  motion bindings).
- File tree: a flattened visible-index selection cursor in FileTreeController
  (j/k move, h collapse-or-out, l expand-or-into, o/enter open), with a focus
  ring + scroll-into-view.
- Conversation: j/k line-scroll, ctrl+d/u half-page, gg top, G bottom — G
  re-arms follow-tail.

Foundation for T-404/T-405/T-407, which build on the per-pane matcher and the
editor.focused guard. Git panel + ticket board list nav deferred to a
follow-up (the ticket says lists can trail). Tests: keymap resolution under
both scopes, PaneKeyNav dispatch, the controller selection model, and
end-to-end key-driven nav in both panes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:46:04 +02:00
jpmschweitzerandClaude Opus 4.8 9a44001fbf sync pql changelog (T-407 decisions, T-406 export)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:43:48 +02:00
jpmschweitzerandClaude Opus 4.8 b3136cab79 resolve T-407 no-active-buffer behavior: :q no-ops when no editor
When no editor buffer is active (tree/conversation focused, split closed),
:q / :w / :wq / :x / ZZ no-op for v1 — no other pane touched. Closes the
last open question on the ticket; v1 stays strictly editor-targeted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:41:30 +02:00
jpmschweitzerandClaude Opus 4.8 0e715f871d record :q close-active-tab decision on T-407
:q closes the active editor tab and focuses the next; the last :q
collapses the split for free via the existing editor.active-changed{id:null}
→ arrangement.closeEditor() path, so :q never dispatches command:editor.close.
The one gap is registry close() re-focusing first-not-next; recommend the
UI-side next-tab activate-then-close.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:39:53 +02:00
jpmschweitzerandClaude Opus 4.8 68acb89e83 refine T-403 vim cross-pane tickets + file workflow-card polish (T-419)
Ran a two-agent parallel workflow to refine the four T-403 children against
the actual code, appending a sharpened scope / acceptance-criteria / files /
dependencies / open-questions block to each:
- T-404 (ctrl+w window family), T-405 (tab cycle + gt/gT),
  T-406 (normal-mode list/scroll nav), T-407 (ex `:` overlay).

Both agents independently surfaced the shared structural blocker — no global
multi-chord SequenceMatcher exists today (the global key path is single-chord;
only the editor has a matcher) — and a recommended sequencing, now recorded as
a coordination note on the parent T-403 (build the matcher once; T-406 first).

Also files T-419 under the UI tracker (T-276): keep the workflow run card's
agent rows + usage visible while collapsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:07:13 +02:00
jpmschweitzerandClaude Opus 4.8 b399acedbf surface Claude Code Workflow runs in convo + sidebar (T-416)
A `Workflow` tool-use launches its multi-agent run in the background and
returns immediately; the real fan-out arrives out-of-band on stream-json
`type:"system"` task_* events (task_started / task_progress / task_updated /
task_notification) keyed by the launching tool-use id — which clide was
dropping. (Wire shape captured by two live stream-json probes; recorded on
the ticket.)

- workflow_run.dart: a pure, Flutter-free WorkflowRun/WorkflowAgent model
  that folds those events (phases, per-agent start→progress→done deltas,
  usage) into a snapshot.
- StreamJsonSession recognises the events, accumulates a
  Map<toolUseId, WorkflowRun>, and exposes `workflows` + `workflowsStream`.
- A `Workflow` tool-use with a live run renders a dedicated run card —
  phase groups, per-agent rows with spinner/check status, usage, and the
  script — falling back to the generic tool card pre-progress or on reload.
  The run breaks the activity cluster so it's always first-class (like T-342).
- The sidebar Activity tab adds a WORKFLOWS section: one row per run with its
  done/total agent count, tinted by running/done state.

Closes T-416 and the T-410 epic (all children done).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:44:41 +02:00
jpmschweitzerandClaude Fable 5 57978c54c5 cover the T-410 epic's pane handlers and Activity tab
The epic's new lib surface dropped coverage to 94.87% (floor 95). Add
the missing tests: the command-bus → _send path end-to-end in the pane
(effort respawn with --effort, invalid-level notice, both pickers,
set_permission_mode write, sidebar navigation messages, /memory
editor.open, /help summary, TUI-only notice without a session write)
and ActivityTabView's USAGE block + placeholder branch. 95.14% after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:34:07 +02:00
jpmschweitzerandClaude Fable 5 5a5b60a148 trim the T-411 CHANGELOG bullet under the 60-word cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:15:01 +02:00
jpmschweitzerandClaude Fable 5 ac21f5cb69 adjust composer typeahead test for the grown owned-command set
T-413 added 'mcp' (among others) to kClideOwnedCommands, which the
composer unions onto the suggestion list (T-162) — '/m' now yields
[mcp, memory, model], so reaching 'model' takes two arrow-downs. The
test's intent (selection moves; Enter completes, never submits) is
unchanged.

Board: T-158 annotated — /usage is answerable headless on 2.1.175,
unblocking its upstream blocker (see T-415).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:11:28 +02:00
jpmschweitzerandClaude Fable 5 0fd8a241db Activity tab: session controls + live usage block (T-415)
The Activity tab gains the power-panel's session strip and a usage block:

- SESSION controls (clear / compact / fork / resume + refresh-usage)
  publish their slash command on builtin.claude/command — the same path
  as typing it, so /clear semantics (and any future confirm behavior)
  live in exactly one place.
- The usage block revisits T-158's "blocked on upstream": probed against
  claude 2.1.175, a forwarded /usage IS answered headless, free
  (num_turns 0), as parseable text. parseUsageText() extracts session /
  week / week-Sonnet percentages (timezone parentheticals stripped); the
  sidebar watches the primary session's synthetic output for
  usage-shaped responses and renders them as a USAGE section. Refresh is
  user-initiated (the control sends /usage) — no polling, no background
  calls (D-64).
- The runtime row gains the session's effort level (T-412's status
  field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:04:55 +02:00
jpmschweitzerandClaude Fable 5 1674d3021a tint the Claude sidebar tab icon with the Claude accent (T-418)
TabContribution gains an optional iconColor honoured by the icon rail:
full-strength when active/hovered, dimmed (70%) when idle, so the tint
reads as identity without outshouting the active-state border. The
Claude Activity tab sets claudeAccent (#D97757) — nominative use per the
licenses.yaml trademark note (it marks Claude's own panel).

Filed and closed as a try-it-out (user request); trivially revertible if
the accent doesn't land visually.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:30:22 +02:00
jpmschweitzerandClaude Fable 5 959ec7ddc4 Config tab → live control panel; sidebar styling pass (T-414)
The Claude sidebar's settings table was read-only 12px rows. It becomes
the power panel's core:

- model / effort / permission-mode rows are popover controls on the
  owned anchored-menu primitive (ClideAnchoredOverlay + ClideMenu),
  showing the LIVE session values (SessionStatus, falling back to the
  probe/settings) with the active option marked.
- Picking an option publishes the explicit slash command (`/effort
  xhigh`) on builtin.claude/command; the PRIMARY pane subscribes and
  executes it through the same _send routing the composer uses — the
  control and the typed command are one code path (D-6), which is also
  what lets the sidebar drive /effort's respawn flow without reaching
  into the pane. Only the primary pane listens (controls target the
  primary session; a second listener would double-execute).
- Styling pass (user request): shared meta tables move from 12px-
  everything to 13px labels/values, accent-coloured section headers,
  wider row pitch; control rows get hover affordance + caret.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:25:49 +02:00
jpmschweitzerandClaude 7c4ac182f6 file status-bar styling cohesion bug (T-417)
Ticket-only commit so the pql changelog write-through persists. T-417
captures the font/size/token/vertical-alignment drift across the five
bottom status bar items, filed under the UI tracker epic T-276.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 13:25:04 +02:00
jpmschweitzerandClaude Fable 5 52d90be730 own the open-in-clide command family (T-413)
/permissions, /status, /config, /mcp, /agents, /hooks, /memory, and
/help move from the TUI-only notice catalog to clide-owned commands
with real behavior:

- /permissions <mode> sets the mode over set_permission_mode; bare
  /permissions opens a picker in the interaction zone — the same card
  /model and /effort use (kPermissionModes, bypass last and explicit
  per T-181).
- /status → Claude sidebar Activity tab; /config, /mcp, /agents,
  /hooks → Config tab. The pane activates the claude.meta sidebar tab
  and publishes a meta.tab message; the sidebar subscribes and switches
  its sub-tab — the same MessageBus addressing `clide ui open` uses
  (D-6), so the CLI can drive it too.
- /memory opens the workspace CLAUDE.md via editor.open.
- /help renders a local summary card (clide-owned + advertised
  commands) — the CLI's TUI help doesn't exist headless.

The catalog keeps empty-hint entries for these tokens as safety nets if
they're ever removed from owned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:06:10 +02:00
jpmschweitzerandClaude Fable 5 1bdd88f4ab own /effort: respawn-with-resume carrying --effort, picker UX (T-412)
Spike result (probed claude 2.1.175 over stream-json): there is NO
set_effort/set_thinking_effort control subtype — both are rejected. The
lever is the `--effort <level>` spawn flag (low/medium/high/xhigh/max;
settings.json effortLevel is the persisted default). So changing effort
restarts the process: respawn-with-resume keeps the conversation and
carries the flag — the same continuity /clear and /resume already rely on.

- SpawnSpec.effort → orchestrator appends `--effort <level>`.
- claude_pane: /effort <level> validates and respawns (toast explains the
  restart); bare /effort opens a picker; the pane re-applies its effort on
  every later respawn. Invalid level → local notice listing levels.
- ModelPickerCard generalised minimally (title + isCurrent predicate) so
  the effort picker reuses it; effort needs exact matching because `high`
  is a substring of `xhigh` and alias-containment would mis-mark it.
- SessionStatus.effort + StreamJsonSession.noteEffort: the wire never
  reports effort, so the spawner records what it set; status/sidebar read
  it from the normal status stream.
- Routing: effort moves from the TUI-only catalog to kClideOwnedCommands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:56:09 +02:00
jpmschweitzerandClaude Fable 5 02c6dd4cf0 route slash commands: TUI-only builtins become local notices (T-411)
clide forwards composer input to a headless (stream-json) CLI, where the
TUI's interactive commands don't exist. A known-but-TUI-only command
errored raw ("/x isn't available in this environment", rendered as fake
claude prose); an un-advertised one (e.g. /effort on 2.1.175) was worse —
bracket-pasted to the model as literal text, burning a real turn.

Probed claude 2.1.175 for ground truth: the initialize handshake's
slash_commands advertises skills + the headless builtins only; forwarded
local-command output comes back as an assistant message with model
"<synthetic>"; set_effort is not a control subtype; /usage works headless.

- slash_commands.dart: SlashRoute routing table (owned > advertised >
  TUI-only catalog > forward) + kTuiOnlyCommands with clide-native hints
  + tuiOnlyNotice(). One source of truth replacing ad-hoc checks.
- claude_pane._send routes 'unavailable' to a local notice card; nothing
  reaches the session.
- transcript_reader: AssistantTextMessage.synthetic ("<synthetic>" model)
  so CLI-local output is distinguishable; "<synthetic>" no longer
  clobbers the tracked model in SessionStatus (latent /usage bug).
- conversation_view: synthetic output renders as a muted framed "clide"
  card (T-306 styling), never coral Claude prose.
- kFallbackSlashCommands trimmed to the genuinely-headless builtin set —
  it doubles as the router's advertised fallback, and the old list's
  TUI-only entries would have routed to a raw CLI error.

Board (rides this commit): T-414 gains the user's sidebar styling-pass
note; T-416 filed — surface Claude Code Workflow runs in convo/status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:25:19 +02:00
jpmschweitzerandClaude Fable 5 91c5501cf3 file the Claude power-control-panel epic (T-410 + five stories)
Design: capture every TUI-only harness slash command properly instead of
leaking the CLI's raw "isn't available in this environment" error, and
grow the Claude sidebar into an interactive control panel.

Grounded in the version-keyed initialize probes (~/.config/clide/claude):
the advertised slash_commands list is the authoritative "forwards safely"
set; TUI-only builtins are absent from it. Three layers: a declarative
routing table (forward/owned/unavailable) replacing kClideOwnedCommands,
a reactive catch-all that renders unknown TUI-command errors as hint
cards, and D-6 parity controls in the sidebar Config/Activity tabs
(model/effort/permission pickers, session controls, usage block).

T-411 routing+capture, T-412 /effort spike, T-413 open-in-clide family,
T-414 Config-tab controls, T-415 Activity controls + usage (revisits
T-158).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:59:42 +02:00
jpmschweitzerandClaude Fable 5 1d6682df18 intercept /model: arg sets the model, bare opens a picker (T-408)
Typed into the conversation view, /model was forwarded to the session's
stdin as message text — the CLI's interactive picker only exists in its
own TUI, so nothing happened. clide now owns it like /clear//resume//fork
(T-156).

/model <name> sends a set_model control_request (verified against
claude 2.1.175: subtype accepted alongside set_permission_mode;
"default" resets to the CLI's configured model) with an optimistic
status merge, rolled back with a toast if the CLI rejects the name.
Bare /model swaps a picker card into the interaction zone (D-78) —
numbers / arrows + Enter / Esc, mirroring the prompt card's shortcuts.

The model list comes from the `initialize` handshake response, which
the session now always sends — the spike verified it is side-effect-
free, and it previously went out only when MCP servers were hosted.
Until the response lands the picker falls back to the stable aliases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:04:29 +02:00
jpmschweitzerandClaude Fable 5 073c0893b5 release v2.4.1
Patch release: double-Shift quick-open (new in 2.4.0) no longer fires
on chorded Shift, so Shift+; types a colon again in the editor and the
Claude composer (T-409).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:49:54 +02:00
jpmschweitzerandClaude Fable 5 8966a159db require a clean release for double-tap modifier detection (T-409)
Typing Shift+; opened quick-open instead of a colon. Two flaws in the
T-341 detector: it counted a tap on the Shift keydown (so a chorded
press could complete the gesture before the chord key arrived), and it
relied on the chorded key bubbling to the root KeyboardListener to
break the gesture — but a focused editor or text field consumes that
event, so the tracker never saw it.

The tracker now models press/release: a tap is a press with no other
key going down while the modifier is held, and the gesture fires on
the second clean release. The root shell feeds it from a
HardwareKeyboard handler, which observes every event before focus
dispatch regardless of who consumes it, and treats a modifier pressed
while a non-modifier is already held as a chord.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:49:20 +02:00
225 changed files with 15938 additions and 1120 deletions
@@ -112,9 +112,37 @@ tooltip → tooltipBackground / tooltipForeground / tooltipBorder
dropdown → dropdownBackground / dropdownForeground / dropdownBorder dropdown → dropdownBackground / dropdownForeground / dropdownBorder
``` ```
## Settings & grouped lists — sectioned cards
Settings surfaces and any long grouped list (e.g. the Claude config lists)
read as **sectioned cards**, not bare rows floating on the panel. Each logical
group gets its own card; the small-caps section label (+ optional count) sits
just **above** the card.
```
panel bg → panelBackground (#20202C)
card surface → surface (#242838) fill + dividerColor/border (1px), ~6px corners
section head → sidebarSectionHeader (small-caps), with the count muted to its right
control inset → inputs INSIDE a card recede to panelBackground, so they still
read as fields against the elevated card
```
- **One card per group** — a settings table, each config list. The card's
elevated fill + border do the visual separation; don't rely on spacing alone.
- **Field row inside a card:** label (`globalForeground`) + help
(`globalTextMuted`) + the control right-aligned, with the per-field scope tag
in the far-right column.
- **Scroll, don't cram:** when stacked cards exceed the modal/pane viewport, the
pane scrolls vertically (sticky header, scrolling body) — prefer that over
shrinking content to fit one screen.
- Pattern reference: the settings wireframes under
`docs/design/wireframes/settings/` (T-302).
## Anti-patterns ## Anti-patterns
- `globalBackground` for panel fill → use `panelBackground` - `globalBackground` for panel fill → use `panelBackground`
- Bare settings rows on the panel where a group of them should be one card →
see "Settings & grouped lists".
- `listItemHoverBackground` in sidebar → use `sidebarItemHover` - `listItemHoverBackground` in sidebar → use `sidebarItemHover`
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome) - Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
- Tab active border = `globalFocus` → use `panelActiveBorder` - Tab active border = `globalFocus` → use `panelActiveBorder`
-91
View File
@@ -1,91 +0,0 @@
# Gitea Actions workflow for clide.
#
# NOT YET ACTIVATED. Gitea Actions must be enabled in the instance
# settings before this runs; until then the file is just a ready-made
# pipeline Claude + the user can review.
#
# When the repo eventually lands on GitHub, copy this file verbatim to
# `.github/workflows/test.yml` — Gitea Actions consumes GitHub-Actions
# syntax, so no rewrite is needed.
#
# Steps go through the make targets (the repo's tooling-discipline rule:
# the make layer sets up the environment — gen-build-info etc. — and
# stays correct if a wrapped script moves). T-384 fixed three latent
# breaks here: a `cd app` into the flattened-away app/ directory, a
# coverage gate with no coverage run before it, and raw ci/ script
# invocations that skipped build-info generation.
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
name: unit + widget + golden + a11y + coverage gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
# test-coverage runs the full fast suite WITH coverage (it includes
# the a11y suite — see the push-check note in the Makefile), which
# is what coverage-gate consumes.
- run: make test-coverage
- run: make coverage-gate
integration:
name: integration_test (xvfb)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: flutter pub get
- uses: coactions/setup-xvfb@v1
with: { run: make test-integration }
startup-bundle:
name: bundle smoke (xvfb 5s)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: flutter pub get
- run: make smoke-bundle
# 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
# (steps: setup-node, npm install + playwright install in tools/ui,
# `make test-e2e`) when Q-50 resolves toward keeping it.
docs:
name: dart doc (lib API)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
- name: dart doc --validate-links (fail on warning)
run: |
set -o pipefail
dart doc --validate-links 2>&1 | tee dartdoc.log
if grep -q "^ warning:" dartdoc.log; then
echo "::error::dartdoc emitted warnings — see log above"
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: dart-api-docs
path: doc/api/
+8 -2
View File
@@ -1,3 +1,9 @@
#!/bin/sh #!/bin/sh
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout) # pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout).
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout" # The pql hook is untracked (a local `pql init` install), so a fresh
# `git worktree add` has no .pql/hooks — source it only when present, and
# always exit 0: post-checkout is best-effort and must never abort the
# checkout / worktree creation.
hook="$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout"
if [ -f "$hook" ]; then . "$hook"; fi
exit 0
+104
View File
@@ -0,0 +1,104 @@
name: release
# Build + publish versioned Windows and Linux release bundles when the version
# in pubspec.yaml changes on main. The `version` job only proceeds when the
# v<version> tag doesn't already exist, so an unrelated pubspec edit is a no-op.
#
# FIRST CUT — neither build has run in CI yet (Windows has never been built at
# all), so expect to iterate on these from the first run's logs. The repo's own
# `make` targets are the build contract (gen-build-info + clide-cli + flutter
# build, all wired in `make build`).
on:
push:
branches: [main]
paths: ['pubspec.yaml']
workflow_dispatch:
permissions:
contents: write # create the tag + the release
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
fresh: ${{ steps.v.outputs.fresh }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # tags, to tell new vs. already-released
- id: v
shell: bash
run: |
version=$(awk -F': *' '/^version:/ {gsub(/[" ]/,"",$2); print $2; exit}' pubspec.yaml)
echo "version=$version" >> "$GITHUB_OUTPUT"
if git rev-parse "v$version" >/dev/null 2>&1; then
echo "fresh=false" >> "$GITHUB_OUTPUT"
echo "v$version already tagged — nothing to release."
else
echo "fresh=true" >> "$GITHUB_OUTPUT"
echo "v$version is new — building."
fi
build-linux:
needs: version
if: needs.version.outputs.fresh == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: sudo apt-get update && sudo apt-get install -y ninja-build libgtk-3-dev
- run: make dugite-fetch
- run: make build # gen-build-info + clide-cli + flutter build linux
- name: package
run: tar -C build/linux/x64/release/bundle -czf clide-linux-x64-${{ needs.version.outputs.version }}.tar.gz .
- uses: actions/upload-artifact@v4
with: { name: linux, path: clide-linux-x64-*.tar.gz }
build-windows:
needs: version
if: needs.version.outputs.fresh == 'true'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: choco install -y make
- name: build
shell: bash
run: make dugite-fetch && make build # MSVC + bash already on windows-latest
- name: package
shell: pwsh
run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath clide-windows-x64-${{ needs.version.outputs.version }}.zip
- uses: actions/upload-artifact@v4
with: { name: windows, path: clide-windows-x64-*.zip }
publish:
needs: [version, build-linux, build-windows]
if: needs.version.outputs.fresh == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: changelog notes for this version
shell: bash
run: |
ver="${{ needs.version.outputs.version }}"
# Pull the entries under `## [<version>]` — the changelog cut that the
# version-bump commit lands per the changelog discipline — as the
# release body; fall back to a one-liner if the section is absent.
awk -v ver="$ver" '
$0 ~ "^## \\[" ver "\\]" {grab=1; next}
grab && /^## \[/ {exit}
grab {print}
' CHANGELOG.md > release-notes.md
[ -s release-notes.md ] || echo "Release v$ver." > release-notes.md
- uses: actions/download-artifact@v4
with: { path: dist }
- uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.version.outputs.version }}
name: clide v${{ needs.version.outputs.version }}
body_path: release-notes.md # the version's CHANGELOG section
generate_release_notes: true # + auto commit list appended
files: dist/**/* # the built versioned bundles
+152
View File
@@ -0,0 +1,152 @@
name: test
# Linux CI for clide (GitHub Actions). Moved here from .gitea/workflows/ when CI
# consolidated onto GitHub (the primary remote); the Gitea secondary has Actions
# disabled. Pairs with windows.yml (ConPTY suite on windows-latest) and
# release.yml (versioned release bundles).
#
# Steps go through the make targets (the repo's tooling-discipline rule: the
# make layer sets up the environment — gen-build-info etc. — and stays correct
# if a wrapped script moves).
on:
push:
branches: [main]
pull_request:
jobs:
unit:
name: unit + widget + golden + a11y + coverage gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
- name: install pql
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download --repo postmeridiem/pql --pattern 'pql_*_Linux_x86_64.tar.gz' --output /tmp/pql.tgz
tar -xzf /tmp/pql.tgz -C /tmp
sudo install -m 0755 /tmp/pql /usr/local/bin/pql
pql --version
# The pql tests query the repo's vault, but .pql/pql.db is gitignored
# (the post-checkout hook rebuilds it from the committed changelog).
# A fresh CI checkout has the changelog but no db — materialize it,
# and sync decision records from the governance/ DQR markdown tree
# (tickets come from the changelog; decisions from `decisions sync`).
pql plan import
pql decisions sync
# test-coverage runs the full fast suite WITH coverage (it includes the
# a11y suite — see the push-check note in the Makefile), which is what
# coverage-gate consumes.
- run: make test-coverage
- run: make coverage-gate
integration:
name: integration_test (xvfb)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: flutter pub get
- name: install pql
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download --repo postmeridiem/pql --pattern 'pql_*_Linux_x86_64.tar.gz' --output /tmp/pql.tgz
tar -xzf /tmp/pql.tgz -C /tmp
sudo install -m 0755 /tmp/pql /usr/local/bin/pql
pql --version
# The pql tests query the repo's vault, but .pql/pql.db is gitignored
# (the post-checkout hook rebuilds it from the committed changelog).
# A fresh CI checkout has the changelog but no db — materialize it,
# and sync decision records from the governance/ DQR markdown tree
# (tickets come from the changelog; decisions from `decisions sync`).
pql plan import
pql decisions sync
- uses: coactions/setup-xvfb@v1
with: { run: make test-integration }
startup-bundle:
name: bundle smoke (xvfb 5s)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: flutter pub get
- name: install pql
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download --repo postmeridiem/pql --pattern 'pql_*_Linux_x86_64.tar.gz' --output /tmp/pql.tgz
tar -xzf /tmp/pql.tgz -C /tmp
sudo install -m 0755 /tmp/pql /usr/local/bin/pql
pql --version
# The pql tests query the repo's vault, but .pql/pql.db is gitignored
# (the post-checkout hook rebuilds it from the committed changelog).
# A fresh CI checkout has the changelog but no db — materialize it,
# and sync decision records from the governance/ DQR markdown tree
# (tickets come from the changelog; decisions from `decisions sync`).
pql plan import
pql decisions sync
# Point the real release app's crash logs at an uploadable workspace dir
# (T-436): if the bundle wedges on boot, the watchdog heartbeat/sample +
# FileLogSink land here and get uploaded below. CLIDE_LOG=debug so the
# file sink captures info/debug, not just the release-default warn.
- name: bundle smoke (logs → artifact)
env:
CLIDE_LOG: debug
CLIDE_LOG_DIR: ${{ github.workspace }}/clide-logs
run: make smoke-bundle
- name: Upload crash logs
if: always()
uses: actions/upload-artifact@v4
with:
name: bundle-crash-logs
path: ${{ github.workspace }}/clide-logs
if-no-files-found: ignore
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)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
- name: dart doc --validate-links (fail on warning)
run: |
set -o pipefail
dart doc --validate-links 2>&1 | tee dartdoc.log
if grep -q "^ warning:" dartdoc.log; then
echo "::error::dartdoc emitted warnings — see log above"
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: dart-api-docs
path: doc/api/
+115
View File
@@ -0,0 +1,115 @@
name: windows-soak
# ConPTY orphan-leak soak on a GitHub-hosted Windows runner — the cheap
# alternative to a dedicated Windows VM. The freeze hypothesis (T-424) is that
# each WindowsPty.start() leaks its conhost/OpenConsole host because the child
# is not in a kill-on-close Job Object; across many runs those hosts pile up
# until the box starves. tools/windows-verify/soak-conpty.ps1 reproduces that
# WITHOUT crashing: it runs the ConPTY suite many times IN ONE job and counts
# the hosts that survive each dart.exe exit. A throwaway runner is fine — we
# watch the accumulation (the leading indicator), not the reboot. The repeated
# runs happen inside this single job, so the leak can build up here even though
# the runner is discarded afterwards (cf. the note in windows.yml, which only
# runs the suite once).
#
# Diagnostic, never a gate: it always exits 0 and just publishes the verdict +
# CSV. Runs on demand (workflow_dispatch) and when the soak kit itself changes.
on:
workflow_dispatch:
inputs:
iterations:
description: How many times to run the ConPTY suite (clean-path soak)
default: "25"
kill_iterations:
description: Spawn+force-kill cycles (abrupt-death orphan probe)
default: "15"
ptys_per_iter:
description: WindowsPty sessions spawned per kill cycle
default: "2"
push:
branches: [windows-support]
paths:
- tools/windows-verify/**
- .github/workflows/windows-soak.yml
jobs:
conpty-soak:
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
- name: ConPTY orphan-leak soak
shell: pwsh
run: |
$iters = "${{ github.event.inputs.iterations }}"
if (-not $iters) { $iters = "25" }
tools/windows-verify/soak-conpty.ps1 -Iterations ([int]$iters) -OutDir "$env:GITHUB_WORKSPACE/soak-out"
- name: Publish verdict to job summary
if: always()
shell: pwsh
run: |
$s = Get-ChildItem "$env:GITHUB_WORKSPACE/soak-out/*.summary.txt" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($s) { Get-Content $s.FullName | Add-Content $env:GITHUB_STEP_SUMMARY }
- name: Upload soak CSV + summary
if: always()
uses: actions/upload-artifact@v4
with:
name: conpty-soak
path: soak-out
if-no-files-found: warn
conpty-kill-probe:
# Abrupt-death half: force-kill the parent dart.exe mid-life (no close(),
# no Job Object) and count the ConPTY hosts that survive. This is the path
# the freeze hypothesis (T-424) actually implicates — the clean-path soak
# above never exercises it. Diagnostic only; always succeeds.
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
- name: ConPTY abrupt-death orphan probe
shell: pwsh
# CLIDE_LOG_DIR makes the probe emit FFI breadcrumbs (T-436): when a
# parent is force-killed mid-life, its reader/waiter isolates' last
# crumb ("ReadFile enter" / "WaitForSingleObject enter") is fsynced to
# clide-pty.crumbs.log and uploaded below — naming what the wedged
# isolate was doing at the instant of death.
env:
CLIDE_LOG_DIR: ${{ github.workspace }}/kill-crumbs
run: |
$iters = "${{ github.event.inputs.kill_iterations }}"
if (-not $iters) { $iters = "15" }
$ptys = "${{ github.event.inputs.ptys_per_iter }}"
if (-not $ptys) { $ptys = "2" }
tools/windows-verify/soak-conpty-kill.ps1 -Iterations ([int]$iters) -PtysPerIter ([int]$ptys) -OutDir "$env:GITHUB_WORKSPACE/kill-out"
- name: Publish verdict to job summary
if: always()
shell: pwsh
run: |
$s = Get-ChildItem "$env:GITHUB_WORKSPACE/kill-out/*.summary.txt" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($s) { Get-Content $s.FullName | Add-Content $env:GITHUB_STEP_SUMMARY }
- name: Upload kill-probe CSV + summary
if: always()
uses: actions/upload-artifact@v4
with:
name: conpty-kill-probe
path: kill-out
if-no-files-found: warn
- name: Upload FFI breadcrumbs (last act of each killed reader/waiter)
if: always()
uses: actions/upload-artifact@v4
with:
name: conpty-kill-crumbs
path: ${{ github.workspace }}/kill-crumbs
if-no-files-found: ignore
+48
View File
@@ -0,0 +1,48 @@
name: windows
# Windows CI on GitHub-hosted runners — the only hosted Windows available, and
# GitHub is clide's primary remote (the Gitea secondary is self-hosted Linux and
# keeps running the Linux suite). This is the first real execution of the ConPTY
# backend (lib/src/pty/windows_pty.dart), so expect genuine failures until the
# Windows fixes land (T-424). Keep this OUT of required status checks until it's
# reliably green — it reports + uploads artifacts without blocking merges. Each
# run is a fresh, discarded runner, so the accumulation freeze (which needs
# repeated runs on one machine) can't build up here.
on:
push:
branches: [main, windows-support]
pull_request:
workflow_dispatch:
jobs:
windows-pty:
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
# No `flutter analyze` here: it's platform-agnostic — the Linux job already
# analyzes windows_pty.dart and everything else statically, and the
# `flutter build windows` release job catches Windows-specific compile
# errors. Skipping it also avoids needing `make gen-build-info`, since the
# pty tests import the pty libraries directly, not the build_info-bearing
# barrel (lib/clide.dart). This job's unique value is running real ConPTY.
- name: ConPTY + Windows-arg unit tests
# windows_pty_test.dart drives real ConPTY (it self-skips off-Windows);
# the args/size suites are the pure-logic coverage. --timeout 60s so a
# wedged reader fails fast instead of hanging the runner.
run: dart test --concurrency=1 --timeout 60s test/pty/windows_pty_test.dart test/pty/windows_pty_args_test.dart test/pty/pty_size_test.dart
- name: Upload test output / logs
if: always()
uses: actions/upload-artifact@v4
with:
name: windows-test-output
# T-425 (crash-survivable FileLogSink) writes under %LOCALAPPDATA%\clide\logs;
# add that path here once it lands so a freeze leaves a downloadable log.
path: test/.test-output
if-no-files-found: ignore
+2
View File
@@ -58,6 +58,8 @@ tools/ui/.serve.pid
/native/linux-x64/clide /native/linux-x64/clide
/native/macos-arm64/clide /native/macos-arm64/clide
/native/macos-x64/clide /native/macos-x64/clide
/native/windows-x64/clide.exe
/native/windows-x64/clide.obj
# -- Test, coverage, profile output ------------------------------------ # -- Test, coverage, profile output ------------------------------------
*.test *.test
+6 -6
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7" revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
channel: "stable" channel: "stable"
project_type: app project_type: app
@@ -13,11 +13,11 @@ project_type: app
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
- platform: web - platform: windows
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
# User provided section # User provided section
File diff suppressed because it is too large Load Diff
+51
View File
@@ -232,3 +232,54 @@ 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'T-405', '2026-06-12 03:21:31', '2026-06-12 03:21:31', NULL, 'e4e1695f838b8fbf02aae49a6f2df4fe', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'T-405', '2026-06-12 03:21:31', '2026-06-12 03:21:31', NULL, 'e4e1695f838b8fbf02aae49a6f2df4fe', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'T-406', '2026-06-12 03:21:49', '2026-06-12 03:21:49', NULL, '689352238d2050a3769b2a9613f0a793', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'T-406', '2026-06-12 03:21:49', '2026-06-12 03:21:49', NULL, '689352238d2050a3769b2a9613f0a793', 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 ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'T-407', '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '129d2b3c31022d53025a3e28169a060e', 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 ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'T-407', '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '129d2b3c31022d53025a3e28169a060e', 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 ('06FBN3VTK2MYQQ173MSJN6E1DM', 'T-408', '2026-06-12 06:40:25', '2026-06-12 06:40:25', NULL, '0353aaab57a40900b00883fb12e135f7', 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 ('06FBN3VYR84023Z5XFEX9DS0S0', 'T-409', '2026-06-12 06:40:26', '2026-06-12 06:40:26', NULL, '69c280319335a8d0ef646998f4531e96', 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 ('06FBP3EZC7AJANXZVF3D91QYWM', 'T-410', '2026-06-12 08:58:29', '2026-06-12 08:58:29', NULL, '4f726d1a66d38d14018a62c8d24ffe62', 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 ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'T-411', '2026-06-12 08:58:42', '2026-06-12 08:58:42', NULL, 'bdd3f8b13caf25677ac661ec29599488', 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 ('06FBP3J7TXMG0F9E2WQDENPVJG', 'T-412', '2026-06-12 08:58:55', '2026-06-12 08:58:55', NULL, 'e56d50bc6fc04f6d646f22c104e63183', 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 ('06FBP3KRWM65MD3DS251NN9YX0', 'T-413', '2026-06-12 08:59:08', '2026-06-12 08:59:08', NULL, 'b5247ea05c106500682be978a47e61ee', 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 ('06FBP3P8YERJ5R7ENSD675BX00', 'T-414', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, '7d973f16c99441dbba0f8df89a665b32', 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 ('06FBP3P91QQQDT5J50F52FPCKM', 'T-415', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, 'a2dea9268d44b9e8a1fd746bbb947c92', 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 ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'T-416', '2026-06-12 10:25:00', '2026-06-12 10:25:00', NULL, 'f8c2a125e661607d5dd0c73cd2c3f2ab', 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 ('06FBQ4BYD4STCKCY8JNKF23Q4W', 'T-417', '2026-06-12 11:22:15', '2026-06-12 11:22:15', NULL, '15aa9b25417162126cbcde174d3537da', 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 ('06FBQ595H08JFTRFSR90GSZQ0G', 'T-418', '2026-06-12 11:26:14', '2026-06-12 11:26:14', NULL, '000e07ae64b08273a2d2d9f8a77d193f', 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 ('06FBTTMGKSYMTF8M1KQWTG774W', 'T-419', '2026-06-12 19:58:58', '2026-06-12 19:58:58', NULL, 'd2b01a2c3d1ce24cc863ac6d9d814d3d', 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 ('06FC2XY1T85A65YY9SG25VVEY4', 'T-420', '2026-06-13 14:51:51', '2026-06-13 14:51:51', NULL, '16ea4c9353a56798b894ab3d85fb7b56', 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 ('06FCDG3T4A7CYPTG535KVAVH6C', 'T-421', '2026-06-14 15:29:23', '2026-06-14 15:29:23', NULL, 'a28eed4b57104034c5216344a326195c', 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 ('06FCDKX4CVHWVGDAJC6X09602M', 'T-422', '2026-06-14 15:45:57', '2026-06-14 15:45:57', NULL, '5701c634f5737a2ba1612deab8df7049', 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 ('06FCDM61KAA3GV3CVTE8PAZ8N0', 'T-423', '2026-06-14 15:47:10', '2026-06-14 15:47:10', NULL, 'ce7dfb8b9bd088b4c2e8ddfacc8d2124', 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 ('06FCENXW1VF7VXQX64982X171R', 'T-424', '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'ecbed75dcd34c39bcbd86e60d6f4a2c2', 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 ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'T-425', '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'b09fc55465f7de02cf98f69c99e0e6e3', 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 ('06FCEP60AS6AF654SWA189A5ZR', 'T-426', '2026-06-14 18:15:42', '2026-06-14 18:15:42', NULL, '91f50c6e38332047f8619db428d4b376', 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 ('06FCEP642C8ZZ1T20RXQQ3143M', 'T-427', '2026-06-14 18:15:43', '2026-06-14 18:15:43', NULL, '55a937def177025ef6b61a222d88b142', 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 ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'T-428', '2026-06-14 18:15:44', '2026-06-14 18:15:44', NULL, '185e7d3ce8529fbe1f4543f4c635f200', 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 ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'T-429', '2026-06-14 18:15:45', '2026-06-14 18:15:45', NULL, 'ef268614a716384a7597b3e304a6c176', 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 ('06FCEP6EVN9S35T02MHA2AS7YW', 'T-430', '2026-06-14 18:15:46', '2026-06-14 18:15:46', NULL, 'd6333df4da5b7ab8958149a9f0d17974', 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 ('06FCGJ30V24BJB001GZCR5QKTC', 'T-431', '2026-06-14 22:37:27', '2026-06-14 22:37:27', NULL, 'b886820e87f5abd329126bf5e9f1a3da', 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 ('06FCM9ER04JVFW8CN3JW1AWYA8', 'T-432', '2026-06-15 07:18:58', '2026-06-15 07:18:58', NULL, '71f3e4c95f66abc5e7e5820548201e41', 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 ('06FCM9F446MZFXVHH65Q6CKTPM', 'T-433', '2026-06-15 07:19:01', '2026-06-15 07:19:01', NULL, 'b9a361f2c29b286bc808dbea57a05a7e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'T-434', '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, '403c4c8aa5659bb379cb8add9dff800b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'T-435', '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, '7c2ed604aecea99b742b341166cf2257', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('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);
+8
View File
@@ -0,0 +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
+141
View File
@@ -16,6 +16,147 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased] ## [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
a freeze leaves on-disk evidence. `CLIDE_LOG` (dart-define / env) or the
`app.log.level` setting sets verbosity (warn in release, info in debug);
`CLIDE_LOG_DIR` redirects where the logs land. (T-432, T-436)
- **PTY FFI breadcrumbs.** Each PTY backend drops a breadcrumb before/after
every risky syscall (`CreatePseudoConsole`/`CreateProcessW`/`ReadFile`,
`posix_spawn`/`read`); the reader/waiter isolates fsync their OWN file handle
so a wedged isolate's last crumb survives a freeze that also froze the main
isolate — naming the wedge after the fact. Per-syscall crumbs at debug level.
(T-434)
- **Crash-diagnostic watchdog.** A dedicated isolate fsyncs a heartbeat every
~500ms (bounding a freeze to ~500ms) and every ~2s samples this process's
thread / handle / child-host / RSS counts to `clide-watchdog.log` — a climbing
child or thread count is the leak signature. Survives a frozen main isolate;
spawn failure is non-fatal. (T-435)
- **Live log-verbosity toggle.** The output dock's Level chip now sets the
running logger's level and persists `app.log.level` (not just a view filter),
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
- **Experimental Windows desktop support.** clide builds and runs on Windows —
ConPTY-backed terminals, an AF_UNIX `clide` CLI client, PowerShell as the
default shell, and a `make build-windows` target. Preview quality: ConPTY
child-process reaping under sustained use is still being hardened. (T-424)
- **Vim `ctrl+w` window commands.** Under the vim preset, `ctrl+w` followed by
h/l (focus left/right panel), j (toggle dock), w / ctrl+w (cycle panels),
shift+w (cycle back), o (focus mode), or q/c (close editor). A new global
multi-chord matcher in the shell resolves these from any focus; bare `ctrl+w`
still closes the editor after the ambiguity timeout. (T-404)
- **Workspace tab cycling with ctrl+pagedown / ctrl+pageup.** New
`workspace.tab.next` / `workspace.tab.previous` commands cycle the workspace
tab strip with wraparound, bound across every preset. (T-405)
- **Vim normal-mode navigation works outside the editor.** Under the vim preset,
a focused file tree or conversation now responds to j/k, ctrl+d/ctrl+u, gg/G,
and (tree) h/l/o — a selection cursor in the tree, scrolling in the
conversation. Each pane runs its own sequence matcher; an `editor.focused`
flag keeps these keys as buffer motions while the editor holds focus. (T-406)
- **Claude Code Workflow runs surface in the conversation and sidebar.** A
`Workflow` tool-use renders a dedicated run card — phase groups, per-agent
rows with live spinner/check status, usage, and the script — driven by the
harness's out-of-band progress events. The Activity tab adds a WORKFLOWS
section showing each run's done/total agent count. (T-416)
- **Session controls and live usage in the Claude sidebar Activity tab.** A
SESSION strip offers clear/compact/fork/resume buttons (same code path as the
typed commands), and a refresh control fetches `/usage` — plan usage renders
as a USAGE block (session and weekly percentages). The runtime row now also
shows the session's effort level. (T-415)
- **The Claude sidebar Config tab is a live control panel.** Model, effort, and
permission mode are popover controls showing the running session's values;
picking an option drives the session through the same path as the typed slash
command. The sidebar tables also got a visual pass — larger type, accent
section headers, more breathing room. (T-414)
- **The TUI command family opens clide surfaces.** `/permissions` sets the mode
directly or opens a picker; `/status`, `/config`, `/mcp`, `/agents`, `/hooks`
jump to the matching Claude sidebar tab; `/memory` opens CLAUDE.md in the
editor; `/help` shows clide's own command summary. (T-413)
- **`/effort` works in the Claude pane.** With a level (`/effort xhigh`) the
session restarts in place carrying `--effort` — resume keeps the
conversation; bare `/effort` opens a picker with the five levels and the
current one marked. The active effort shows in the session status. (T-412)
- **TUI-only slash commands get a helpful notice instead of failing.** A typed
`/cost` or `/doctor` no longer errors raw from the CLI or leaks to the model
as literal text — known TUI-only commands route to a muted notice card with
the clide-native way. CLI-local output (like `/usage`) renders as a "clide"
card, never fake Claude prose. (T-411)
- **`/model` works in the Claude pane.** With a name (`/model sonnet`) it
switches the live session's model over the control channel; bare `/model`
opens a picker in the interaction zone with the CLI's model list and the
current model marked. A rejected name rolls back and raises a toast. (T-408)
### Removed
- **tmux is no longer a required tool.** clide stopped spawning tmux when Claude
session persistence moved to `--resume` (D-77); the toolchain no longer probes
for it or warns when it's absent, on any platform.
### Fixed
- **`ClaudeConfig` no longer crashes on a project switch that races teardown.**
`setProjectDir` / `refresh` / `ensureProbe` now skip `notifyListeners()` if the
config was disposed during their async load (the guard `load()` already had).
## [2.4.1] — 2026-06-12
### Fixed
- **`Shift+;` types a colon again — double-Shift no longer fires on chorded
Shift.** The double-tap detector counted any Shift press as a tap, even
mid-chord, and never saw keys the focused editor consumed; a tap now
requires a clean press-and-release, observed at the raw-keyboard level.
(T-409)
## [2.4.0] — 2026-06-12 ## [2.4.0] — 2026-06-12
### Added ### Added
+1 -1
View File
@@ -9,7 +9,7 @@ An IDE for Claude Code CLI. Single Flutter package at the repo root.
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly. - **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly.
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it. - **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages. Claude session persistence is `--resume <session-id>` against Claude Code's transcript files (D-77, superseding the original tmux-backed D-41) — the app re-attaches on restart, no tmux required. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`governance/`](governance/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`governance/README.md`](governance/README.md)). Python Textual predecessor under [`legacy/`](legacy/). Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`governance/`](governance/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`governance/README.md`](governance/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
+17 -2
View File
@@ -148,7 +148,7 @@ changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bulle
ci/changelog_gate.sh ci/changelog_gate.sh
.PHONY: smoke-bundle .PHONY: smoke-bundle
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s. smoke-bundle: gen-build-info ## Build Linux release bundle and run it under xvfb for 5s.
ci/smoke_bundle.sh ci/smoke_bundle.sh
# -- web UI harness ------------------------------------------------------ # -- web UI harness ------------------------------------------------------
@@ -182,6 +182,10 @@ build-linux: gen-build-info ## flutter build linux (desktop bundle).
build-macos: gen-build-info ## flutter build macos (desktop bundle). build-macos: gen-build-info ## flutter build macos (desktop bundle).
flutter build macos flutter build macos
.PHONY: build-windows
build-windows: gen-build-info ## flutter build windows (desktop bundle).
flutter build windows
# -- install / uninstall ----------------------------------------------------- # -- install / uninstall -----------------------------------------------------
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a # Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
@@ -196,6 +200,9 @@ ifeq ($(FLUTTER_OS),linux)
else ifeq ($(FLUTTER_OS),macos) else ifeq ($(FLUTTER_OS),macos)
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli
else ifeq ($(FLUTTER_OS),windows)
BUNDLE_DIR := build/windows/x64/runner/Release
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/clide-cli.exe
endif endif
ICON_SIZES := 16 32 48 128 192 256 512 ICON_SIZES := 16 32 48 128 192 256 512
@@ -291,7 +298,11 @@ dugite-clean: ## Remove the dugite-native directory.
# target picks up whatever `cc` is on PATH. # target picks up whatever `cc` is on PATH.
CLIDE_CLI_SRC := native/clide-cli/clide.c CLIDE_CLI_SRC := native/clide-cli/clide.c
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide ifeq ($(FLUTTER_OS),windows)
CLIDE_CLI_BIN := native/windows-x64/clide.exe
else
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
endif
CC ?= cc CC ?= cc
.PHONY: clide-cli .PHONY: clide-cli
@@ -299,7 +310,11 @@ clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC) $(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
@mkdir -p $(dir $(CLIDE_CLI_BIN)) @mkdir -p $(dir $(CLIDE_CLI_BIN))
ifeq ($(FLUTTER_OS),windows)
ci/build_cli_windows.sh
else
$(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC) $(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC)
endif
@echo "==> built $(CLIDE_CLI_BIN)" @echo "==> built $(CLIDE_CLI_BIN)"
.PHONY: clide-cli-clean .PHONY: clide-cli-clean
+95
View File
@@ -50,6 +50,11 @@ bindings:
- intent: dismiss - intent: dismiss
keys: escape keys: escape
when: quickOpen.open when: quickOpen.open
# Ex command-line overlay (T-407): Esc dismisses it back to normal mode (the
# overlay's own EditableText handles Enter via onSubmitted).
- intent: dismiss
keys: escape
when: exline.open
- intent: findInFiles.open - intent: findInFiles.open
keys: [ctrl+shift+f, meta+shift+f] keys: [ctrl+shift+f, meta+shift+f]
- intent: focus.nextPanel - intent: focus.nextPanel
@@ -63,6 +68,96 @@ bindings:
- intent: text.scaleReset - intent: text.scaleReset
keys: [ctrl+0, meta+0] keys: [ctrl+0, meta+0]
# ---- Pane navigation (non-editor panes) ------------------------------
# When a non-editor pane holds focus (file tree, conversation, lists), the
# same motion keys mean NAVIGATION, not buffer edits (T-406). The
# `!editor.focused` guard keeps these out of the editor's way; the editor
# publishes `editor.focused` while it has focus. These MUST precede the
# editor motions below — the resolver takes the first matching binding in
# file order, so with a pane focused (editor.focused false) nav wins, and
# with the editor focused the `!editor.focused` clause fails and the buffer
# motion below wins. Each pane runs its own SequenceMatcher (PaneKeyNav).
- intent: nav.down
keys: j
when: "vim.normal && !editor.focused"
- intent: nav.up
keys: k
when: "vim.normal && !editor.focused"
- intent: nav.pageDown
keys: ctrl+d
when: "vim.normal && !editor.focused"
- intent: nav.pageUp
keys: ctrl+u
when: "vim.normal && !editor.focused"
- intent: nav.top
keys: "g g" # gg
when: "vim.normal && !editor.focused"
- intent: nav.bottom
keys: shift+g # G
when: "vim.normal && !editor.focused"
- intent: nav.expandOrRight
keys: l
when: "vim.normal && !editor.focused"
- intent: nav.collapseOrLeft
keys: h
when: "vim.normal && !editor.focused"
- intent: nav.activate
keys: [o, enter]
when: "vim.normal && !editor.focused"
# ---- ctrl+w window-command family (T-404) ----------------------------
# Multi-chord sequences resolved by the GLOBAL matcher (root_shell), so they
# work from any focus. Bare ctrl+w still closes the editor after the ambiguity
# timeout (the editor.close binding below / contributions layer). The 3-column
# clide layout approximates vim's window grid: h/l focus left/right panels,
# j toggles the dock, o is "only" (focus mode), q/c close the editor.
- intent: command:panel.focus.left
keys: ctrl+w h
when: "vim.normal || vim.visual"
- intent: command:panel.focus.right
keys: ctrl+w l
when: "vim.normal || vim.visual"
- intent: command:dock.toggle
keys: ctrl+w j
when: "vim.normal || vim.visual"
- intent: focus.nextPanel
keys: [ctrl+w w, ctrl+w ctrl+w]
when: "vim.normal || vim.visual"
- intent: focus.previousPanel
keys: ctrl+w shift+w # ctrl+w W
when: "vim.normal || vim.visual"
- intent: command:panel.focusMode
keys: ctrl+w o
when: "vim.normal || vim.visual"
- intent: command:editor.close
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 ------------------------------------------------ # ---- Mode transitions ------------------------------------------------
- intent: command:vim.mode.visual - intent: command:vim.mode.visual
keys: v keys: v
+1 -1
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info` # Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump # (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead. # pubspec instead.
version: "2.4.0" version: "2.5.0"
homepage: https://github.com/postmeridiem/clide homepage: https://github.com/postmeridiem/clide
license: MIT license: MIT
license_file: assets/LICENSE license_file: assets/LICENSE
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# Build the C `clide` client with MSVC on Windows (Git Bash / MSYS).
# Wrapped by `make clide-cli` — don't run directly (see CLAUDE.md
# tooling discipline). Finds the VC++ toolset via vswhere, loads the
# x64 dev environment, compiles:
# native/clide-cli/clide.c -> native/windows-x64/clide.exe
# ws2_32.lib supplies winsock (AF_UNIX socket support).
set -e
VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
if [ ! -x "$VSWHERE" ]; then
echo "vswhere.exe not found — install Visual Studio (Build Tools) with the C++ workload" >&2
exit 1
fi
VSROOT=$("$VSWHERE" -products '*' -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | tr -d '\r')
if [ -z "$VSROOT" ]; then
echo "no Visual Studio C++ x64 toolset found (vswhere returned nothing)" >&2
exit 1
fi
mkdir -p native/windows-x64
# A generated .bat sidesteps the unwinnable sh->cmd quote escaping for
# the space-laden VS path. //c keeps MSYS from path-mangling cmd's /c
# switch; /Fo drops the .obj next to the .exe so the repo root stays
# clean.
BAT=$(mktemp --suffix=.bat)
trap 'rm -f "$BAT"' EXIT
cat > "$BAT" <<EOF
@call "$VSROOT\\Common7\\Tools\\VsDevCmd.bat" -arch=amd64 -no_logo
@cl /nologo /O2 /W4 /D_CRT_SECURE_NO_WARNINGS native\\clide-cli\\clide.c /Fonative\\windows-x64\\ /Fe:native\\windows-x64\\clide.exe ws2_32.lib
EOF
cmd.exe //c "$(cygpath -w "$BAT")"
rm -f native/windows-x64/clide.obj
+6 -1
View File
@@ -35,7 +35,12 @@ echo "==> dart test (pty — unreliable under the flutter test runner; serial)"
# --concurrency=1: these spawn real PTYs and compete for fds when run in # --concurrency=1: these spawn real PTYs and compete for fds when run in
# parallel, which flaked them (registry/session). Serialize — the proper fix # parallel, which flaked them (registry/session). Serialize — the proper fix
# for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193) # for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193)
dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart test/panes/registry_test.dart # windows_pty_test is the ConPTY sibling of session_test; each suite
# self-skips off-platform, so the union always contributes tests.
# --timeout 60s matches the flutter lines below: a wedged PTY test (e.g. a
# ConPTY reader blocked forever in ReadFile) fails fast instead of hanging the
# whole serial run.
dart test -r "$REPORTER" --concurrency=1 --timeout 60s --tags pty test/pty/session_test.dart test/panes/registry_test.dart test/pty/windows_pty_test.dart
# The parallel pool excludes both pty (runs under dart test, above) and # The parallel pool excludes both pty (runs under dart test, above) and
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1 # serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
+7 -1
View File
@@ -3,10 +3,16 @@
# start" regression gate. Flutter integration tests prefer one file at # start" regression gate. Flutter integration tests prefer one file at
# a time on desktop; we iterate to avoid the "Unable to start the app" # a time on desktop; we iterate to avoid the "Unable to start the app"
# error that hits when they run as a batch. # error that hits when they run as a batch.
#
# -d linux pins the desktop device explicitly: the GitHub ubuntu-latest
# runner exposes BOTH a linux desktop AND a chrome web device, so a bare
# `flutter test integration_test/...` aborts with "More than one device
# connected" before it ever compiles (the dev box / old Gitea runner only
# had the one device, so this was latent until CI moved to GitHub).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
for f in integration_test/*_test.dart; do for f in integration_test/*_test.dart; do
echo "==> integration_test: $f" echo "==> integration_test: $f"
flutter test "$f" flutter test -d linux "$f"
done done
+17
View File
@@ -0,0 +1,17 @@
# dartdoc configuration.
#
# clide is an application, not a published library — its lib/ API docs are not
# a consumed surface. The dart-doc CI gate (.github/workflows/test.yml) exists
# to catch real doc-comment defects: unresolved [symbol] references. That check
# stays strict (unresolved-doc-reference is NOT ignored).
#
# The "broken-link" category, by contrast, is pure rendering noise here:
# - The README is dartdoc's landing page; its repo-relative links
# ([CLAUDE.md], [docs/…], [LICENSE]) are correct on GitHub but don't resolve
# once copied into the generated API site.
# - The generated Phosphor icon font (lib/widgets/src/icons/phosphor_glyphs.g.dart,
# 1512 glyphs) produces broken cross-links to its own library page.
# Neither is a fixable doc-quality problem, so the category is ignored.
dartdoc:
ignore:
- broken-link
@@ -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

+4 -2
View File
@@ -73,7 +73,7 @@ You might also want, project-permitting:
- [D-29: Pre-push gate — fast layer only](decisions/testing.md#d-29-pre-push-gate--fast-layer-only) — _testing_ - [D-29: Pre-push gate — fast layer only](decisions/testing.md#d-29-pre-push-gate--fast-layer-only) — _testing_
- [D-30: Tests are client-side only](decisions/testing.md#d-30-tests-are-client-side-only) — _testing_ - [D-30: Tests are client-side only](decisions/testing.md#d-30-tests-are-client-side-only) — _testing_
- [D-31: Prefer-zero-deps, exact-pin](decisions/tooling.md#d-31-prefer-zero-deps-exact-pin) — _tooling_ - [D-31: Prefer-zero-deps, exact-pin](decisions/tooling.md#d-31-prefer-zero-deps-exact-pin) — _tooling_
- [D-32: CI — Gitea primary, Linux-only runners, not yet activated](decisions/tooling.md#d-32-ci--gitea-primary-linux-only-runners-not-yet-activated) — _tooling_ - [D-32: CI — GitHub Actions, Linux + Windows runners, active](decisions/tooling.md#d-32-ci--github-actions-linux--windows-runners-active) — _tooling_
- [D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts](decisions/tooling.md#d-33-golden-output-ignore-pattern--coverage-excludes-output-not-scripts) — _tooling_ - [D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts](decisions/tooling.md#d-33-golden-output-ignore-pattern--coverage-excludes-output-not-scripts) — _tooling_
- [D-34: Q&D record system](decisions/process.md#d-34-qd-record-system) — _process_ - [D-34: Q&D record system](decisions/process.md#d-34-qd-record-system) — _process_
- [D-35: Kanban / waterfall, not Scrum](decisions/process.md#d-35-kanban--waterfall-not-scrum) — _process_ - [D-35: Kanban / waterfall, not Scrum](decisions/process.md#d-35-kanban--waterfall-not-scrum) — _process_
@@ -141,6 +141,7 @@ You might also want, project-permitting:
- [D-97: ssh:// workspace URI + system-ssh auth](decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth) — _architecture_ - [D-97: ssh:// workspace URI + system-ssh auth](decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth) — _architecture_
- [D-98: Remote-tool contract + connect preflight](decisions/architecture.md#d-98-remote-tool-contract--connect-preflight) — _architecture_ - [D-98: Remote-tool contract + connect preflight](decisions/architecture.md#d-98-remote-tool-contract--connect-preflight) — _architecture_
- [D-99: Remote session identity keyed on (host, workspace)](decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace) — _architecture_ - [D-99: Remote session identity keyed on (host, workspace)](decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace) — _architecture_
- [D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling](decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling) — _tooling_
## Open questions ## Open questions
@@ -182,7 +183,7 @@ You might also want, project-permitting:
- [Q-47: Live mixed documents — implement?](questions/design.md#q-47-live-mixed-documents--implement) — _design_ - [Q-47: Live mixed documents — implement?](questions/design.md#q-47-live-mixed-documents--implement) — _design_
- [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_ - [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_
- [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_ - [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_ - [Q-51: Unify workspace lifecycle on a single fenced open primitive](questions/architecture.md#q-51-unify-workspace-lifecycle-on-a-single-fenced-open-primitive) — _architecture_
## Resolved questions ## Resolved questions
@@ -196,6 +197,7 @@ You might also want, project-permitting:
- [Q-28: Terminal strip scope — shell only or logs/errors/tests](questions/architecture.md#q-28-terminal-strip-scope--shell-only-or-logserrorstests) — _architecture_ - [Q-28: Terminal strip scope — shell only or logs/errors/tests](questions/architecture.md#q-28-terminal-strip-scope--shell-only-or-logserrorstests) — _architecture_
- [Q-32: MCP tool surface — minimum slash-ide or extended clide tools?](questions/architecture.md#q-32-mcp-tool-surface--minimum-slash-ide-or-extended-clide-tools) — _architecture_ - [Q-32: MCP tool surface — minimum slash-ide or extended clide tools?](questions/architecture.md#q-32-mcp-tool-surface--minimum-slash-ide-or-extended-clide-tools) — _architecture_
- [Q-33: MCP transport — SSE, WebSocket, stdio, or all?](questions/architecture.md#q-33-mcp-transport--sse-websocket-stdio-or-all) — _architecture_ - [Q-33: MCP transport — SSE, WebSocket, stdio, or all?](questions/architecture.md#q-33-mcp-transport--sse-websocket-stdio-or-all) — _architecture_
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
## Rejected ## Rejected
+14 -5
View File
@@ -11,11 +11,12 @@ Toolchain, supply chain, CI, ignore strategy.
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted. - **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory. - **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
### D-32: CI — Gitea primary, Linux-only runners, not yet activated ### D-32: CI — GitHub Actions, Linux + Windows runners, active
- **Date:** 2026-04-21 - **Date:** 2026-04-21 (amended 2026-06-15)
- **Decision:** CI config lives at `.gitea/workflows/test.yml` (Gitea Actions consumes GitHub-Actions syntax). Runners are Linux only; macOS is tested locally. The workflow is ready but Gitea Actions is not yet activated on the instance — the file is a staged pipeline for review. If the repo moves to GitHub, the file copies to `.github/workflows/test.yml` verbatim. - **Decision:** CI runs on **GitHub Actions** under `.github/workflows/`: `test.yml` (Linux — analyze + format + unit/widget/golden + coverage gate, with `pql` installed and `pql.db` rebuilt from the changelog), `windows.yml` + `windows-soak.yml` (ConPTY tests + the orphan-leak soak), and `release.yml` (version-tagged builds, the CHANGELOG section as release notes). macOS is tested locally (no macOS runner). The web-WASM Playwright e2e job is withheld pending the `dart:ffi` fence (D-100 / Q-50). Every job goes through `make` targets.
- **Rationale:** We want the CI story defined before we turn CI on — lower blast radius on early red builds. GitHub portability is free because the syntax is shared. - **Rationale:** The repo moved to GitHub (origin `postmeridiem/clide`); GitHub Actions consumes the same workflow syntax the staged Gitea pipeline used, so the move was near-verbatim. Defining the CI story before flipping it on kept early red builds low-blast-radius.
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation. - **Cost:** macOS coverage is local-only; the browser/e2e surface stays dark until D-100's fence lands.
- **Amended (2026-06-15):** Superseded the original "Gitea primary, not yet activated" posture — the Gitea staging pipeline was never activated and is gone (only `legacy/.gitea/`, the frozen Python tree, remains); CI is live on GitHub Actions (commits `8e0f33b` Linux, `45aa2d9` Windows/release). Reconciled while closing out T-384.
- **Raised by:** 2026-04-21 planning. - **Raised by:** 2026-04-21 planning.
### D-42: Dependencies documented in `licenses.yaml` ### D-42: Dependencies documented in `licenses.yaml`
@@ -101,3 +102,11 @@ Toolchain, supply chain, CI, ignore strategy.
- **Raised by:** 2026-06-11 — user: "pql updates live outside this repo and people have to first update the pql binaries and install them." - **Raised by:** 2026-06-11 — user: "pql updates live outside this repo and people have to first update the pql binaries and install them."
--- ---
### D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling
- **Date:** 2026-06-15
- **Decision:** Resolve [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) by **fencing** (option a): every module that imports `dart:ffi` — the native PTY (`lib/src/pty/`), tree-sitter (`lib/kernel/src/syntax/`), the Lua host (`lib/lua/`), the Windows watchdog, and the FFI `libc` shim — moves behind a conditional-import facade: `import 'x_native.dart' if (dart.library.ffi) 'x_native.dart' ... else 'x_stub.dart'` (io/ffi → native impl; web → a stub). Web stubs degrade gracefully (no PTY/terminal, no native git, no tree-sitter highlighting — the web target is a UI/e2e surface, not a functional desktop replacement) and never throw at import time. A `flutter build web --wasm` compile step is added to CI so the fence can't silently rot.
- **Rationale:** Keep the web/WASM build a live "happy accident" — a hopeful future target the maintainer wants to reach eventually — rather than letting the dart:ffi pivot quietly amputate it (option c, drop) or leaving it dark and rotting (option b, park). Crucially this does **not** compromise desktop fidelity (the CLAUDE.md guardrail): stubs exist only on the web target; the desktop build keeps the real FFI impls unchanged. Fencing is reversible and additive; dropping the target is effectively one-way.
- **Cost:** An ongoing tax — every new native binding needs a web stub + conditional import, and the wasm compile gate must stay green. Accepted deliberately: the maintainer values keeping the door open over avoiding that tax. Functional web parity is explicitly **not** promised — only that the tree compiles to wasm and the Playwright/e2e harness ([D-26](process.md)) can run again.
- **Cross-reference:** [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop), [D-32](#d-32-ci--github-actions-linux--windows-runners-active) (the withheld web-WASM e2e job lands once this fence is implemented), the tree-sitter FFI pivot.
- **Raised by:** 2026-06-15 — user, reconciling T-384: "a happy accident for the web-based UI lives a bit more hopeful for me than it does in CLAUDE.md … let's fence dart:ffi with web stubs."
+8 -1
View File
@@ -160,9 +160,16 @@ ticket persistence.
- **Source:** 2026-06-09 — split out of T-132 / T-158 (was "blocked on upstream"); project memory `claude-usage-budget-not-exposed`, GitHub anthropics/claude-code#44328. - **Source:** 2026-06-09 — split out of T-132 / T-158 (was "blocked on upstream"); project memory `claude-usage-budget-not-exposed`, GitHub anthropics/claude-code#44328.
### Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop? ### Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?
- **Status:** Open - **Status:** Resolved → [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling)
- **Resolved (2026-06-15):** **Fence** (option a). Every `dart:ffi` importer moves behind a conditional-import facade with a web stub, and a `flutter build web --wasm` compile gate is added so it can't rot. The maintainer wants the web/WASM build kept alive as a hopeful future target; desktop fidelity is untouched (stubs exist only on the web target). Functional web parity is explicitly not promised — only that the tree compiles to wasm and the e2e/Playwright harness can run again. Implementation tracked in T-438. See [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling).
- **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish. - **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish.
- **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked. - **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked.
- **Source:** T-384 / 2026-06-11 Fable review (epic T-359). - **Source:** T-384 / 2026-06-11 Fable review (epic T-359).
### Q-51: Unify workspace lifecycle on a single fenced open primitive
- **Status:** Open
- **Question:** There is no single "open workspace X" primitive — only two half-primitives in different layers. `project.open(root)` (`lib/kernel/src/project.dart`) is the only repo-targeting path and is intrinsically *in-place*: it rebuilds services in the same process, reusing the shared `daemonBus`. `newWindow()` (`lib/builtin/menubar/src/file_actions.dart`) spawns a blank detached `Process.start` with no repo argument and no env scrubbing. To open a repo in a *new* window you must spawn a blank window and then run the in-place switch inside it. Should both fold behind one `WorkspaceService.open(root, {target: thisWindow | newWindow})` that is the *sole* deriver of IPC identity from a root — and, more fundamentally, should in-place switching survive at all, or should `workspace ⇒ window ⇒ process ⇒ socket ⇒ bus ⇒ session-id` be strictly one-to-one so the leak/bleed class becomes structurally impossible?
- **Context:** Surfaced 2026-06-14 from [T-421](../../) (status-bar branch bleeds across parallel windows). The same root cause — scattered, per-entry-point workspace lifecycle with no single fencing owner — already produced T-367 (in-place switch leaked the entire previous service set) and T-269 (kept the previous repo's Claude session). If in-place switching is abolished, the teardown burden those tickets patch disappears entirely. Relevant decisions: [D-70](../decisions/architecture.md) (per-workspace socket path), [D-56](../decisions/architecture.md) (one server per workspace), [D-72](../decisions/architecture.md) (multi-connection serial dispatch).
- **Source:** T-421 / 2026-06-14 user review.
--- ---
+4 -1
View File
@@ -7,7 +7,7 @@
/// This file is pure (no Flutter): it turns a flat [ConversationItem] list /// This file is pure (no Flutter): it turns a flat [ConversationItem] list
/// into a list of [RenderGroup]s — each either a first-class [StickyItem] or /// into a list of [RenderGroup]s — each either a first-class [StickyItem] or
/// a foldable [FoldedCluster]. The widget layer renders sticky items as /// a foldable [FoldedCluster]. The widget layer renders sticky items as
/// before and clusters as one [activity card]. Kept separate + unit-tested /// before and clusters as one `activity card`. Kept separate + unit-tested
/// because the fold rules are the load-bearing part. /// because the fold rules are the load-bearing part.
library; library;
@@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
// breaks the cluster at every level, including L3, so parallel agents // breaks the cluster at every level, including L3, so parallel agents
// never merge into one Activity card. // never merge into one Activity card.
if (isAgentTool(name)) return false; if (isAgentTool(name)) return false;
// A Workflow run is a first-class orchestration card too (T-416): it owns
// the live agent fan-out, so it never folds into a generic Activity card.
if (name == 'Workflow') return false;
// The Edit/Write call stays first-class with its diff at L1/L2. // The Edit/Write call stays first-class with its diff at L1/L2.
if (level == FoldLevel.everything) return true; if (level == FoldLevel.everything) return true;
return !isDiffTool(name); return !isDiffTool(name);
+10 -23
View File
@@ -21,11 +21,15 @@
/// IO wrapper the orchestrator calls. Flutter-free by design. /// IO wrapper the orchestrator calls. Flutter-free by design.
library; library;
import 'dart:ffi' show Abi;
import 'dart:io'; import 'dart:io';
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath; import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
// Web fence (T-438, D-100): `Abi.current()` (dart:ffi) is desktop-only; the web
// build gets a default dir name with no FFI introspection.
import 'native_abi_stub.dart' if (dart.library.ffi) 'native_abi_io.dart';
/// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a /// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a
/// hosted session (T-217), so the agent isn't prompted on every IDE call. /// hosted session (T-217), so the agent isn't prompted on every IDE call.
/// Claude Code's settings/flag syntax for a command-scoped Bash rule is /// Claude Code's settings/flag syntax for a command-scoped Bash rule is
@@ -90,26 +94,6 @@ String? resolveClideCliDir({required String? currentPath, required List<String>
return null; return null;
} }
/// The `native/<os>-<arch>/` directory name the Makefile builds the C client
/// into (e.g. `linux-x64`, `macos-arm64`) — used to find the dev-tree binary
/// when clide runs un-installed (dogfooding clide-on-clide).
String nativeClideDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macos today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
/// The result of [agentBootstrap]: the env delta to overlay and the extra /// The result of [agentBootstrap]: the env delta to overlay and the extra
/// spawn args (context note + allow rule) to prepend to a session's argv. /// spawn args (context note + allow rule) to prepend to a session's argv.
class AgentBootstrap { class AgentBootstrap {
@@ -125,10 +109,13 @@ class AgentBootstrap {
/// orchestrator merges both into one `--append-system-prompt`. /// orchestrator merges both into one `--append-system-prompt`.
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) { AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
final home = Platform.environment['HOME']; final home = Platform.environment['HOME'];
final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH']; // The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
// shells out to — find user-installed components on a desktop launch, not just
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
final currentPath = resolvedToolPath();
final candidates = <String>[ final candidates = <String>[
if (home != null && home.isNotEmpty) '$home/.local/bin', if (home != null && home.isNotEmpty) '$home/.local/bin',
'$workspaceRoot/native/${nativeClideDirName()}', '$workspaceRoot/native/${currentNativeDirName()}',
File(Platform.resolvedExecutable).parent.path, File(Platform.resolvedExecutable).parent.path,
]; ];
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile); final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
+11 -23
View File
@@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
/// Returns a change stream for [dir] (fires on any file event under it). /// Returns a change stream for [dir] (fires on any file event under it).
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir); typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
/// Modest version-agnostic fallback used when the probe is unavailable, so /// Fallback used when the probe is unavailable. Mirrors the builtins a real
/// the typeahead still offers the common built-ins. /// CLI advertises in its stream-json `initialize` handshake (probed against
const List<String> kFallbackSlashCommands = [ /// 2.1.175) — i.e. the ones that genuinely work headless. It deliberately
'add-dir', /// does NOT list TUI-only commands (config, permissions, status, doctor, …):
'agents', /// this list doubles as the router's "advertised" set (T-411), and a TUI-only
'clear', /// token here would be forwarded to the CLI and error. The composer unions
'compact', /// [kClideOwnedCommands] on top for the typeahead (T-162).
'config', const List<String> kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage'];
'context',
'cost',
'doctor',
'exit',
'help',
'init',
'mcp',
'memory',
'model',
'permissions',
'resume',
'review',
'status',
'usage',
];
class ClaudeConfig extends ChangeNotifier { class ClaudeConfig extends ChangeNotifier {
ClaudeConfig({ ClaudeConfig({
@@ -272,6 +257,7 @@ class ClaudeConfig extends ChangeNotifier {
if (probe == null) return; // stay on the static fallback if (probe == null) return; // stay on the static fallback
_probe = probe; _probe = probe;
await _writeProbeCache(probe); await _writeProbeCache(probe);
if (_disposed) return; // a slow probe racing a teardown mustn't notify a disposed notifier
notifyListeners(); notifyListeners();
} finally { } finally {
_probing = false; _probing = false;
@@ -283,6 +269,7 @@ class ClaudeConfig extends ChangeNotifier {
/// not re-resolved (the binary doesn't change under us at runtime). /// not re-resolved (the binary doesn't change under us at runtime).
Future<void> refresh() async { Future<void> refresh() async {
await _loadDiskConfig(); await _loadDiskConfig();
if (_disposed) return; // a watcher-driven refresh racing a teardown mustn't notify a disposed notifier
notifyListeners(); notifyListeners();
} }
@@ -293,6 +280,7 @@ class ClaudeConfig extends ChangeNotifier {
_stopWatching(); _stopWatching();
_projectDir = dir; _projectDir = dir;
await _loadDiskConfig(); await _loadDiskConfig();
if (_disposed) return; // a project switch racing a teardown mustn't notify a disposed notifier
_startWatchers(); _startWatchers();
notifyListeners(); notifyListeners();
} }
@@ -36,8 +36,10 @@ import 'package:clide/builtin/claude/src/meta_sidebar/tab_strip.dart';
import 'package:clide/builtin/claude/src/meta_sidebar/team_tab.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/team_tab.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask; import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, parseUsageText;
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation; import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; import 'package:clide/builtin/claude/src/transcript_reader.dart' show AssistantTextMessage, ConversationItem, SessionStatus;
import 'package:clide/builtin/claude/src/workflow_run.dart' show WorkflowRun;
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -83,7 +85,12 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
StreamSubscription<TeamMemberJoined>? _joinSub; StreamSubscription<TeamMemberJoined>? _joinSub;
StreamSubscription<TeamMemberLeft>? _leftSub; StreamSubscription<TeamMemberLeft>? _leftSub;
StreamSubscription<Message>? _statusSub; StreamSubscription<Message>? _statusSub;
StreamSubscription<Message>? _tabSub;
StreamSubscription<SessionStatus>? _primarySub; StreamSubscription<SessionStatus>? _primarySub;
StreamSubscription<ConversationItem>? _primaryItemsSub;
StreamSubscription<Map<String, WorkflowRun>>? _primaryWorkflowsSub;
ClaudeUsage? _usage;
Map<String, WorkflowRun> _workflows = const {};
StreamSubscription<void>? _brokerChangeSub; StreamSubscription<void>? _brokerChangeSub;
Timer? _timer; Timer? _timer;
late final Future<ClaudeStats> Function() _load; late final Future<ClaudeStats> Function() _load;
@@ -163,6 +170,13 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_memberStatus.remove(m.agentId); _memberStatus.remove(m.agentId);
}); });
}); });
// Slash-command navigation (T-413): /status, /config, /mcp, … publish a
// meta.tab message; switch the sub-tab to match.
_tabSub = kernel.messages.subscribe(publisher: 'builtin.claude', channel: 'meta.tab').listen((msg) {
final name = msg.data['tab'] as String?;
final tab = SidebarTab.values.where((t) => t.name == name).firstOrNull;
if (tab != null && mounted) setState(() => _tab = tab);
});
// Live per-member status forwarded by the observer (T-157). // Live per-member status forwarded by the observer (T-157).
_statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) { _statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) {
final agentId = msg.data['agentId'] as String?; final agentId = msg.data['agentId'] as String?;
@@ -191,15 +205,42 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final session = _orchestrator?.byId('primary')?.session; final session = _orchestrator?.byId('primary')?.session;
_primarySub?.cancel(); _primarySub?.cancel();
_primarySub = null; _primarySub = null;
_primaryItemsSub?.cancel();
_primaryItemsSub = null;
_primaryWorkflowsSub?.cancel();
_primaryWorkflowsSub = null;
if (session == null) { if (session == null) {
if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null); if (mounted && (_primaryStatus != null || _workflows.isNotEmpty)) {
setState(() {
_primaryStatus = null;
_workflows = const {};
});
}
return; return;
} }
final seed = session.status; final seed = session.status;
if (mounted) setState(() => _primaryStatus = seed); if (mounted) {
setState(() {
_primaryStatus = seed;
_workflows = session.workflows;
});
}
_primarySub = session.statusStream.listen((s) { _primarySub = session.statusStream.listen((s) {
if (mounted) setState(() => _primaryStatus = s); if (mounted) setState(() => _primaryStatus = s);
}); });
// The Activity tab's WORKFLOWS section tracks the primary session's live
// workflow runs (T-416).
_primaryWorkflowsSub = session.workflowsStream.listen((w) {
if (mounted) setState(() => _workflows = w);
});
// Watch for /usage responses: CLI-local output arrives as synthetic
// assistant text; when it parses as usage, the Activity block updates
// (T-415). Driven by the refresh control publishing '/usage'.
_primaryItemsSub = session.items.listen((item) {
if (item is! AssistantTextMessage || !item.synthetic) return;
final parsed = parseUsageText(item.text);
if (parsed != null && mounted) setState(() => _usage = parsed);
});
} }
void _onConfigChange() { void _onConfigChange() {
@@ -247,7 +288,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_joinSub?.cancel(); _joinSub?.cancel();
_leftSub?.cancel(); _leftSub?.cancel();
_statusSub?.cancel(); _statusSub?.cancel();
_tabSub?.cancel();
_primarySub?.cancel(); _primarySub?.cancel();
_primaryItemsSub?.cancel();
_primaryWorkflowsSub?.cancel();
_brokerChangeSub?.cancel(); _brokerChangeSub?.cancel();
_injectCtl.dispose(); _injectCtl.dispose();
_config?.removeListener(_onConfigChange); _config?.removeListener(_onConfigChange);
@@ -263,7 +307,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)), SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)),
Expanded( Expanded(
child: switch (_tab) { child: switch (_tab) {
SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config), SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage, workflows: _workflows),
SidebarTab.team => TeamTabView( SidebarTab.team => TeamTabView(
members: _members, members: _members,
memberStatus: _memberStatus, memberStatus: _memberStatus,
@@ -303,6 +347,8 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
), ),
SidebarTab.config => ConfigTabView( SidebarTab.config => ConfigTabView(
config: _config, config: _config,
status: _primaryStatus,
models: _orchestrator?.byId('primary')?.session.availableModels,
expanded: _expanded, expanded: _expanded,
onToggleSection: (section) => setState(() { onToggleSection: (section) => setState(() {
if (_expanded.contains(section)) { if (_expanded.contains(section)) {
+247 -4
View File
@@ -14,6 +14,7 @@ import 'clipboard_paste.dart';
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey; import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
import 'conversation_controller.dart'; import 'conversation_controller.dart';
import 'conversation_view.dart'; import 'conversation_view.dart';
import 'model_picker_card.dart';
import 'permission_mode_control.dart'; import 'permission_mode_control.dart';
import 'prompt_card.dart'; import 'prompt_card.dart';
import 'session_index.dart'; import 'session_index.dart';
@@ -24,6 +25,7 @@ import 'slash_commands.dart';
import 'stream_json_session.dart'; import 'stream_json_session.dart';
import 'task_list.dart'; import 'task_list.dart';
import 'transcript_reader.dart'; import 'transcript_reader.dart';
import 'workflow_run.dart';
/// The Claude conversation pane. Drives `claude` over the stream-json control /// The Claude conversation pane. Drives `claude` over the stream-json control
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events /// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
@@ -73,6 +75,9 @@ class _ClaudePaneState extends State<ClaudePane> {
StreamSubscription<SessionStatus>? _statusSub; StreamSubscription<SessionStatus>? _statusSub;
StreamSubscription<SessionEnd>? _endSub; StreamSubscription<SessionEnd>? _endSub;
StreamSubscription<ProjectOpened>? _projectSub; StreamSubscription<ProjectOpened>? _projectSub;
StreamSubscription<Message>? _commandSub;
StreamSubscription<String>? _modelErrorSub;
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
ConversationController? _conversation; ConversationController? _conversation;
StreamJsonSession? _session; StreamJsonSession? _session;
SessionStatus _status = const SessionStatus(); SessionStatus _status = const SessionStatus();
@@ -85,6 +90,17 @@ class _ClaudePaneState extends State<ClaudePane> {
/// /resume, and respawns operate on this pane's own session (T-375). /// /resume, and respawns operate on this pane's own session (T-375).
late String? _forkSource = widget.forkSourceId; late String? _forkSource = widget.forkSourceId;
/// Whether a bare `/model` opened the picker in the interaction zone
/// (T-408). An open prompt takes precedence; the picker shows once it
/// resolves.
bool _modelPickerOpen = false;
bool _effortPickerOpen = false;
bool _permissionPickerOpen = false;
/// Effort level this pane's session runs at (`--effort`, T-412). Null =
/// the CLI default. Set by /effort; carried by every respawn.
String? _effort;
bool _spawned = false; bool _spawned = false;
/// Per-session composer draft (text + caret), held here so an unsent /// Per-session composer draft (text + caret), held here so an unsent
@@ -164,6 +180,18 @@ class _ClaudePaneState extends State<ClaudePane> {
// GlobalKey and spawns once, so without this it would keep the previous // GlobalKey and spawns once, so without this it would keep the previous
// repo's session after a switch (T-269). // repo's session after a switch (T-269).
_projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged); _projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
// Sidebar controls (and any future surface) drive this pane's session by
// publishing slash-command text on builtin.claude/command (T-414) —
// executed through the exact _send routing the composer uses, so the
// control and the typed command are one code path (D-6). Only the
// primary pane listens: the controls target the primary session, and a
// second listener would double-execute.
if (widget.isPrimary) {
_commandSub = ClideKernel.of(context).messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen((msg) {
final text = msg.data['text'] as String?;
if (text != null && text.isNotEmpty) _send(text);
});
}
// Re-fold the conversation when the activity fold-level setting changes // Re-fold the conversation when the activity fold-level setting changes
// (claude.activity.fold-level command, T-235). // (claude.activity.fold-level command, T-235).
ClideKernel.of(context).settings.addListener(_onSettingsChanged); ClideKernel.of(context).settings.addListener(_onSettingsChanged);
@@ -179,11 +207,16 @@ class _ClaudePaneState extends State<ClaudePane> {
activeClaudeConfig?.removeListener(_onConfigChanged); activeClaudeConfig?.removeListener(_onConfigChanged);
_kernel?.settings.removeListener(_onSettingsChanged); _kernel?.settings.removeListener(_onSettingsChanged);
_projectSub?.cancel(); _projectSub?.cancel();
_commandSub?.cancel();
_projectSub = null; _projectSub = null;
_statusSub?.cancel(); _statusSub?.cancel();
_statusSub = null; _statusSub = null;
_endSub?.cancel(); _endSub?.cancel();
_endSub = null; _endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
// The orchestrator owns the session, so disposing this pane does NOT kill // The orchestrator owns the session, so disposing this pane does NOT kill
// it — that's what lets a hidden/kept-alive pane keep its session (T-169). // it — that's what lets a hidden/kept-alive pane keep its session (T-169).
// A secondary tab being *closed* is a real teardown, so close its session; // A secondary tab being *closed* is a real teardown, so close its session;
@@ -237,6 +270,13 @@ class _ClaudePaneState extends State<ClaudePane> {
_statusSub = null; _statusSub = null;
_endSub?.cancel(); _endSub?.cancel();
_endSub = null; _endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
_conversation = null; _conversation = null;
_session = null; _session = null;
@@ -284,7 +324,14 @@ class _ClaudePaneState extends State<ClaudePane> {
_sessionId ??= freshSessionId(); _sessionId ??= freshSessionId();
try { try {
managed = await orch.spawn( managed = await orch.spawn(
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource), SpawnSpec(
id: _orchId,
role: 'fork ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
forkSourceSessionId: forkSource,
effort: _effort,
),
); );
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = 'Could not start fork: $e'); if (mounted) setState(() => _error = 'Could not start fork: $e');
@@ -315,6 +362,7 @@ class _ClaudePaneState extends State<ClaudePane> {
cwd: repoRoot, cwd: repoRoot,
resume: resume, resume: resume,
transcriptPath: resume ? transcriptFile : null, transcriptPath: resume ? transcriptFile : null,
effort: _effort,
), ),
); );
} catch (e) { } catch (e) {
@@ -327,6 +375,9 @@ class _ClaudePaneState extends State<ClaudePane> {
_session = managed.session; _session = managed.session;
_conversation = managed.conversation; _conversation = managed.conversation;
// The wire never reports effort — record what this session was spawned
// with so the status line / sidebar can show it (T-412).
if (_effort != null) managed.session.noteEffort(_effort!);
// Diagnostic (T-274 follow-up): record how this pane bound its session — // Diagnostic (T-274 follow-up): record how this pane bound its session —
// a fresh spawn vs connecting to existing on-disk history (the seed read // a fresh spawn vs connecting to existing on-disk history (the seed read
// from the transcript/sidecar). Surfaces the resume path in `make run`. // from the transcript/sidecar). Surfaces the resume path in `make run`.
@@ -340,6 +391,18 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return; if (!mounted) return;
setState(() => _status = s); setState(() => _status = s);
}); });
// Workflow runs arrive on out-of-band system events that add no
// conversation item, so the view won't rebuild on its own — drive a
// rebuild as the run map changes so the workflow card updates live (T-416).
_workflowsSub = managed.session.workflowsStream.listen((_) {
if (!mounted) return;
setState(() {});
});
// A rejected /model change (unknown name) rolls back silently in the
// status — say why out loud (T-408).
_modelErrorSub = managed.session.modelErrors.listen((msg) {
_kernel?.notify.warn(msg, title: 'model');
});
// Surface a dead process instead of letting it look thoughtful (T-361): // Surface a dead process instead of letting it look thoughtful (T-361):
// late binders read the replayed end; live sessions stream it. // late binders read the replayed end; live sessions stream it.
final alreadyEnded = managed.session.end; final alreadyEnded = managed.session.end;
@@ -357,7 +420,10 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return; if (!mounted) return;
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}'; final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail'); _kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart'); // Surface the CLI's own reason (e.g. "Session ID … is already in use")
// instead of an opaque "code 1" (T-437).
final why = end.reason.isEmpty ? '' : '${end.reason}';
setState(() => _statusLine = 'claude exited (code ${end.exitCode})$why · /clear to restart');
} }
// Send composed text to Claude over the stream-json channel. Commands clide // Send composed text to Claude over the stream-json channel. Commands clide
@@ -377,10 +443,152 @@ class _ClaudePaneState extends State<ClaudePane> {
case 'fork': case 'fork':
_forkSession(); _forkSession();
return; return;
case 'model':
_modelCommand(slashCommandArg(text) ?? '');
return;
case 'effort':
_effortCommand(slashCommandArg(text) ?? '');
return;
case 'permissions':
_permissionsCommand(slashCommandArg(text) ?? '');
return;
case 'status':
_openMetaTab('activity');
return;
case 'config':
case 'mcp':
case 'agents':
case 'hooks':
_openMetaTab('config');
return;
case 'memory':
_openMemory();
return;
case 'help':
_helpCommand();
return;
}
// Route the rest (T-411): a known TUI-only builtin never reaches the
// session — forwarded it would error (or, un-advertised, bracket-paste to
// the model as literal text, burning a turn). It becomes a local notice
// card pointing at the clide-native way instead.
final advertised = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands;
if (routeSlashCommand(text, advertised: advertised) == SlashRoute.unavailable) {
_session?.addLocalNotice(tuiOnlyNotice(slashCommandToken(text)!));
return;
} }
_session?.send(text); _session?.send(text);
} }
/// clide-owned `/model` (T-408): with an argument, set the model directly;
/// bare, open the picker in the interaction zone (D-78).
void _modelCommand(String arg) {
if (_session == null) return;
if (arg.isNotEmpty) {
_session!.setModel(arg);
return;
}
setState(() => _modelPickerOpen = true);
}
void _pickModel(String value) {
_session?.setModel(value);
_closeModelPicker();
}
void _closeModelPicker() {
setState(() => _modelPickerOpen = false);
_composerFocus.requestFocus();
}
/// clide-owned `/effort` (T-412): with a level, respawn-with-resume carrying
/// `--effort`; bare, open the picker. No set_effort control subtype exists
/// (probed 2.1.175), so the respawn IS the mechanism — resume keeps the
/// conversation, only the process restarts.
void _effortCommand(String arg) {
if (_session == null) return;
if (arg.isEmpty) {
setState(() => _effortPickerOpen = true);
return;
}
if (!kEffortLevels.any((l) => l.value == arg)) {
_session!.addLocalNotice('unknown effort "$arg" — levels: ${kEffortLevels.map((l) => l.value).join(', ')}');
return;
}
_setEffort(arg);
}
void _pickEffort(String value) {
_closeEffortPicker();
_setEffort(value);
}
void _closeEffortPicker() {
setState(() => _effortPickerOpen = false);
_composerFocus.requestFocus();
}
void _setEffort(String level) {
final sid = _sessionId;
if (sid == null) return;
_effort = level;
_kernel?.notify.info('effort $level — restarting the session to apply', title: 'effort');
unawaited(_respawnWithSession(sid));
}
/// clide-owned `/permissions` (T-413): with a mode, set it directly over
/// set_permission_mode; bare, open a picker — the same interaction-zone
/// pattern as /model and /effort.
void _permissionsCommand(String arg) {
final s = _session;
if (s == null) return;
if (arg.isEmpty) {
setState(() => _permissionPickerOpen = true);
return;
}
if (!kPermissionModes.any((m) => m.value == arg)) {
s.addLocalNotice('unknown permission mode "$arg" — modes: ${kPermissionModes.map((m) => m.value).join(', ')}');
return;
}
s.setPermissionMode(arg);
}
void _pickPermissionMode(String value) {
_closePermissionPicker();
_session?.setPermissionMode(value);
}
void _closePermissionPicker() {
setState(() => _permissionPickerOpen = false);
_composerFocus.requestFocus();
}
/// Navigate to the Claude sidebar and select a sub-tab (T-413): the
/// /status//config//mcp//agents//hooks commands land here.
void _openMetaTab(String tab) {
final k = _kernel;
if (k == null) return;
k.panels.activateTab(Slots.sidebar, 'claude.meta');
k.messages.publish('builtin.claude', 'meta.tab', {'tab': tab});
}
/// clide-owned `/memory` (T-413): open the workspace CLAUDE.md in the editor.
void _openMemory() {
final root = _repoRoot;
if (root == null) return;
unawaited(_ipc()?.request('editor.open', args: {'path': '$root/CLAUDE.md'}));
}
/// clide-owned `/help` (T-413): a local summary card — never the CLI's TUI
/// help, which doesn't exist headless.
void _helpCommand() {
final advertised = (activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands).where((c) => !kClideOwnedCommands.contains(c)).toList()..sort();
_session?.addLocalNotice(
'clide commands: ${(kClideOwnedCommands.toList()..sort()).map((c) => '/$c').join(' ')}\n'
'claude commands & skills: ${advertised.map((c) => '/$c').join(' ')}',
);
}
/// Record a submitted prompt in the active session's history (T-163), /// Record a submitted prompt in the active session's history (T-163),
/// de-duping immediate repeats. Empty/whitespace prompts are skipped. /// de-duping immediate repeats. Empty/whitespace prompts are skipped.
void _appendHistory(String text) { void _appendHistory(String text) {
@@ -405,7 +613,7 @@ class _ClaudePaneState extends State<ClaudePane> {
/// background tap must never pull focus from (or resurrect) the composer /// background tap must never pull focus from (or resurrect) the composer
/// over an open prompt. /// over an open prompt.
void _focusComposerOnTap() { void _focusComposerOnTap() {
if (_session?.pendingPrompt != null) return; if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen || _permissionPickerOpen) return;
_composerFocus.requestFocus(); _composerFocus.requestFocus();
} }
@@ -477,6 +685,13 @@ class _ClaudePaneState extends State<ClaudePane> {
_statusSub = null; _statusSub = null;
_endSub?.cancel(); _endSub?.cancel();
_endSub = null; _endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old session await activeSessionOrchestrator?.close(_orchId); // kills the old session
// Erase only after the process is dead, so claude isn't mid-write. // Erase only after the process is dead, so claude isn't mid-write.
final root = _repoRoot; final root = _repoRoot;
@@ -532,6 +747,7 @@ class _ClaudePaneState extends State<ClaudePane> {
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{}, hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{}, toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{}, quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
emptyState: ClaudeBanner( emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot, workspace: _repoRoot,
@@ -548,9 +764,36 @@ class _ClaudePaneState extends State<ClaudePane> {
), ),
// An open prompt takes the composer's space and hides the text // An open prompt takes the composer's space and hides the text
// input until it's answered, so interaction stays out of the // input until it's answered, so interaction stays out of the
// conversation stream (D-78). // conversation stream (D-78). The /model picker uses the same
// slot; a prompt outranks it (T-408).
if (prompt != null && _session != null) if (prompt != null && _session != null)
ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt) ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt)
else if (_modelPickerOpen && _session != null)
ModelPickerCard(
models: _session!.availableModels.isEmpty ? kFallbackModels : _session!.availableModels,
currentModel: _status.model,
onPick: _pickModel,
onCancel: _closeModelPicker,
)
else if (_effortPickerOpen && _session != null)
ModelPickerCard(
title: 'effort',
models: kEffortLevels,
currentModel: _status.effort,
// Exact match — containment would mark `high` inside `xhigh`.
isCurrent: (o, c) => c != null && o.value == c,
onPick: _pickEffort,
onCancel: _closeEffortPicker,
)
else if (_permissionPickerOpen && _session != null)
ModelPickerCard(
title: 'permissions',
models: kPermissionModes,
currentModel: _status.permissionMode,
isCurrent: (o, c) => c != null && o.value == c,
onPick: _pickPermissionMode,
onCancel: _closePermissionPicker,
)
else else
StreamBuilder<bool>( StreamBuilder<bool>(
stream: _session?.busyStream, stream: _session?.busyStream,
+40 -2
View File
@@ -53,8 +53,8 @@ String nextSafePermissionMode(String current) {
} }
/// Status-line segments split around the permission-mode badge so the UI can /// Status-line segments split around the permission-mode badge so the UI can
/// render the mode as an interactive control between them (T-226). [leading] /// render the mode as an interactive control between them (T-226). `leading`
/// is the model; [trailing] joins context / cost / rate-limit. Either may be /// is the model; `trailing` joins context / cost / rate-limit. Either may be
/// null when there's nothing to show. /// null when there's nothing to show.
({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) { ({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) {
final trailing = [ final trailing = [
@@ -92,3 +92,41 @@ String formatTokenCount(int n) {
if (n >= 1000) return '${(n / 1000).round()}k'; if (n >= 1000) return '${(n / 1000).round()}k';
return '$n'; return '$n';
} }
/// Parsed `/usage` output (T-415). The CLI answers a forwarded `/usage`
/// headless and free (probed 2.1.175, num_turns 0) with plain text:
///
/// Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam)
/// Current week (all models): 53% used · resets Jun 15, 6:59pm (…)
/// Current week (Sonnet only): 0% used
class ClaudeUsage {
const ClaudeUsage({this.session, this.week, this.weekSonnet});
/// The value text per line (e.g. `15% used · resets Jun 12, 3:39pm`),
/// timezone parenthetical stripped. Null when the line wasn't present.
final String? session;
final String? week;
final String? weekSonnet;
bool get isEmpty => session == null && week == null && weekSonnet == null;
}
/// Parse `/usage` response text into a [ClaudeUsage], or null when [text]
/// isn't usage output. Tolerant of label drift: any `Current …: …% used`
/// line is matched by its key phrase.
ClaudeUsage? parseUsageText(String text) {
if (!text.contains('% used')) return null;
String? valueOf(String keyPhrase) {
for (final line in text.split('\n')) {
if (!line.contains(keyPhrase)) continue;
final colon = line.indexOf(':');
if (colon < 0) continue;
// Strip the trailing timezone parenthetical — noise at sidebar width.
return line.substring(colon + 1).replaceAll(RegExp(r'\s*\([^)]*\)\s*$'), '').trim();
}
return null;
}
final usage = ClaudeUsage(session: valueOf('Current session'), week: valueOf('(all models)'), weekSonnet: valueOf('(Sonnet only)'));
return usage.isEmpty ? null : usage;
}
@@ -28,8 +28,8 @@ class ConversationController extends ChangeNotifier {
} }
/// Build a controller fed from the kernel [MessageBus] — it consumes /// Build a controller fed from the kernel [MessageBus] — it consumes
/// the [ConversationItem]s a [TranscriptPublisher] writes onto /// the [ConversationItem]s a `TranscriptPublisher` writes onto
/// [publisher]/[channel]. Decouples the view from the reader so several /// `publisher`/[channel]. Decouples the view from the reader so several
/// panels can render the same conversation (team work, T-139/T-140). /// panels can render the same conversation (team work, T-139/T-140).
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) { factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
final stream = messages final stream = messages
+170 -1
View File
@@ -15,13 +15,17 @@ import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/bash_tail_source.dart'; import 'package:clide/builtin/claude/src/bash_tail_source.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabel;
import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/file_tail_follower.dart'; import 'package:clide/builtin/claude/src/file_tail_follower.dart';
import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/src/facade.dart'; import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/keymap/intents.dart';
import 'package:clide/kernel/src/keymap/pane_key_nav.dart';
import 'package:clide/kernel/src/syntax/language_map.dart'; import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart'; import 'package:clide/kernel/src/theme/tokens.dart';
@@ -38,11 +42,18 @@ class ConversationView extends StatefulWidget {
this.hiddenToolUseIds = const <String>{}, this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{}, this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{}, this.quietErrorToolUseIds = const <String>{},
this.workflows = const <String, WorkflowRun>{},
this.foldLevel = FoldLevel.tools, this.foldLevel = FoldLevel.tools,
}); });
final ConversationController controller; final ConversationController controller;
/// Live Workflow runs keyed by their launching `Workflow` tool-use id
/// (T-416). A `Workflow` tool-use card with a matching run renders the
/// dedicated run card (phases, agent rows, status) instead of the generic
/// tool card; absent (pre-progress, or on reload) it falls back to generic.
final Map<String, WorkflowRun> workflows;
/// How aggressively consecutive meta items (tool calls/results, thinking) /// How aggressively consecutive meta items (tool calls/results, thinking)
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]). /// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
final FoldLevel foldLevel; final FoldLevel foldLevel;
@@ -332,6 +343,7 @@ class _ConversationViewState extends State<ConversationView> {
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId, promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId, runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
), ),
FoldedCluster(:final items) => _ActivityCard( FoldedCluster(:final items) => _ActivityCard(
key: ValueKey('cluster.${items.first.uuid}'), key: ValueKey('cluster.${items.first.uuid}'),
@@ -343,6 +355,7 @@ class _ConversationViewState extends State<ConversationView> {
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId, promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId, runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
), ),
EditRun(:final edits) => _EditRunCard( EditRun(:final edits) => _EditRunCard(
key: ValueKey('edits.${edits.first.uuid}'), key: ValueKey('edits.${edits.first.uuid}'),
@@ -376,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
return list; return list;
}, },
); );
return ColoredBox( final body = ColoredBox(
color: tokens.panelBackground, color: tokens.panelBackground,
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized, child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized,
); );
// Vim nav scrolls the conversation while this region holds focus under the
// vim preset (T-406): j/k by a line, ctrl+d/u by half a viewport, gg/G to
// the ends — G also re-arms follow-tail so new output keeps it pinned.
return PaneKeyNav(onNav: _onNav, child: body);
}
/// One "line" of scroll for j/k — a few text rows' worth.
static const double _lineScroll = 48;
void _onNav(NavIntent intent, int count) {
if (!_scroll.hasClients) return;
final p = _scroll.position;
final half = p.viewportDimension / 2;
switch (intent) {
case NavDownIntent():
_scrollBy(_lineScroll * count);
case NavUpIntent():
_scrollBy(-_lineScroll * count);
case NavPageDownIntent():
_scrollBy(half);
case NavPageUpIntent():
_scrollBy(-half);
case NavTopIntent():
_scroll.jumpTo(0);
_atBottom = false;
case NavBottomIntent():
_scroll.jumpTo(p.maxScrollExtent);
_atBottom = true; // re-arm follow-tail (T-297)
case NavExpandOrRightIntent() || NavCollapseOrLeftIntent() || NavActivateIntent():
break; // a reader pane has no expand/activate semantics
}
}
void _scrollBy(double delta) {
final p = _scroll.position;
final target = (p.pixels + delta).clamp(0.0, p.maxScrollExtent);
_scroll.jumpTo(target);
_atBottom = (p.maxScrollExtent - target) <= _bottomEpsilon;
} }
} }
@@ -503,6 +554,7 @@ class _ConversationTurn extends StatelessWidget {
this.resultByToolUseId = const <String, ToolResultMessage>{}, this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{}, this.promptsByToolUseId = const <String, List<UserMessage>>{},
this.runByToolUseId = const <String, List<ConversationItem>>{}, this.runByToolUseId = const <String, List<ConversationItem>>{},
this.workflows = const <String, WorkflowRun>{},
}); });
final ConversationItem item; final ConversationItem item;
@@ -538,6 +590,9 @@ class _ConversationTurn extends StatelessWidget {
/// thinking, tool cards) nested under the Agent card in a holder (T-264). /// thinking, tool cards) nested under the Agent card in a holder (T-264).
final Map<String, List<ConversationItem>> runByToolUseId; final Map<String, List<ConversationItem>> runByToolUseId;
/// Live Workflow runs keyed by launching tool-use id (T-416).
final Map<String, WorkflowRun> workflows;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i = item; final i = item;
@@ -577,6 +632,17 @@ class _ConversationTurn extends StatelessWidget {
onOpenFile: (path, line) => _openFile(context, path, line), onOpenFile: (path, line) => _openFile(context, path, line),
), ),
), ),
// CLI-local output (model "<synthetic>": a forwarded local command's
// response or a clide-injected notice, T-411) is not Claude speaking —
// framed + muted like the context card (T-306), attributed to clide.
AssistantTextMessage() when i.synthetic => ConversationCard(
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: 'clide',
copyText: i.text,
margin: _childMargin,
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the // Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
// agent with a muted accent, never the coral "claude" brand (T-265). The // agent with a muted accent, never the coral "claude" brand (T-265). The
// coral claudeAccent is reserved for the real main-thread Claude. // coral claudeAccent is reserved for the real main-thread Claude.
@@ -686,6 +752,13 @@ class _ConversationTurn extends StatelessWidget {
/// and its own per-item mark. An Agent/Task call also nests its visible /// and its own per-item mark. An Agent/Task call also nests its visible
/// sub-agent run in a second collapser below (T-264). /// sub-agent run in a second collapser below (T-264).
Widget _toolUseCollapser(AssistantToolUse t) { Widget _toolUseCollapser(AssistantToolUse t) {
// A Workflow tool-use with a live run (T-416) renders the dedicated run
// card — phases, agent rows, status — instead of the generic tool card. No
// run yet (pre-progress, or on reload where the system events are gone)
// falls through to the generic collapser below.
if (t.name == 'Workflow' && workflows[t.toolUseId] != null) {
return _workflowCard(t, workflows[t.toolUseId]!);
}
final outcome = toolUseOutcomes[t.toolUseId]; final outcome = toolUseOutcomes[t.toolUseId];
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError); final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
final collapser = ClideCollapserCard( final collapser = ClideCollapserCard(
@@ -729,6 +802,99 @@ class _ConversationTurn extends StatelessWidget {
); );
} }
/// A dedicated card for a Workflow run (T-416): the harness's multi-agent
/// orchestration. The collapser header carries the run's live status (spinner
/// while running, check when done) and a `done/total agents` counter; the body
/// lists each fanned-out agent — grouped under phase headers when the workflow
/// declared phases — plus the run's usage and the orchestration script.
Widget _workflowCard(AssistantToolUse t, WorkflowRun run) {
final title = run.name ?? 'workflow';
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
final detail = run.done ? (run.summary ?? run.description) : run.description;
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
return ClideCollapserCard(
label: 'workflow',
color: color,
collapsedSummary: collapsedSummary,
counter: counter,
status: run.done ? ClideRunStatus.success : ClideRunStatus.running,
children: [_workflowBody(t, run)],
);
}
Widget _workflowBody(AssistantToolUse t, WorkflowRun run) {
final agents = run.orderedAgents;
final phases = run.orderedPhases;
final rows = <Widget>[];
if (phases.isEmpty) {
rows.addAll(agents.map(_workflowAgentRow));
} else {
for (final p in phases) {
rows.add(
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600),
),
);
rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow));
}
// Agents the deltas never tagged with a phase still render, after the
// phased groups, so nothing fanned out is silently dropped.
rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow));
}
if (rows.isEmpty) {
rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta));
}
final script = t.input['script'];
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: run.done ? tokens.statusSuccess : tokens.globalFocus,
label: run.name ?? 'workflow',
copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input),
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows),
extraSegments: [
if (run.totalTokens != null && run.totalTokens! > 0)
CardSegment(
label: 'usage',
child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta),
),
if (script is String)
CardSegment(
label: 'script',
child: ClideCodeBlock(source: script, language: 'javascript'),
),
],
margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH),
);
}
/// One agent row in a workflow card: a state glyph (spinner while running, a
/// muted check once done), the agent's label, and its model (T-416).
Widget _workflowAgentRow(WorkflowAgent a) {
final done = a.state == WorkflowAgentState.done;
final Widget glyph = done
? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess)
: ClideSpinner(size: 12, color: tokens.globalTextMuted);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(width: 16, child: Center(child: glyph)),
const SizedBox(width: 6),
Expanded(
child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis),
),
if (a.model != null && a.model!.isNotEmpty) ...[
const SizedBox(width: 8),
ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1),
],
],
),
);
}
/// The inner content card for a tool use (T-305): the call body + folded /// The inner content card for a tool use (T-305): the call body + folded
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own /// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
/// collapse caret — the enclosing collapser owns collapse. Used both as a /// collapse caret — the enclosing collapser owns collapse. Used both as a
@@ -896,6 +1062,7 @@ class _ActivityCard extends StatelessWidget {
required this.resultByToolUseId, required this.resultByToolUseId,
required this.promptsByToolUseId, required this.promptsByToolUseId,
required this.runByToolUseId, required this.runByToolUseId,
this.workflows = const <String, WorkflowRun>{},
}); });
final List<ConversationItem> items; final List<ConversationItem> items;
@@ -906,6 +1073,7 @@ class _ActivityCard extends StatelessWidget {
final Map<String, ToolResultMessage> resultByToolUseId; final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId; final Map<String, List<UserMessage>> promptsByToolUseId;
final Map<String, List<ConversationItem>> runByToolUseId; final Map<String, List<ConversationItem>> runByToolUseId;
final Map<String, WorkflowRun> workflows;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -927,6 +1095,7 @@ class _ActivityCard extends StatelessWidget {
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId, promptsByToolUseId: promptsByToolUseId,
runByToolUseId: runByToolUseId, runByToolUseId: runByToolUseId,
workflows: workflows,
), ),
], ],
); );
+4
View File
@@ -5,6 +5,7 @@ import 'package:clide/clide.dart';
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel; import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode; import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
import 'package:clide/builtin/claude/src/conversation_view.dart' show claudeAccent;
import 'package:clide/builtin/claude/src/claude_session_host.dart'; import 'package:clide/builtin/claude/src/claude_session_host.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/pane_context_status.dart'; import 'package:clide/builtin/claude/src/pane_context_status.dart';
@@ -308,6 +309,9 @@ class ClaudeExtension extends ClideExtension {
slot: Slots.sidebar, slot: Slots.sidebar,
title: 'Activity', title: 'Activity',
icon: PhosphorIcons.byName('robot'), icon: PhosphorIcons.byName('robot'),
// Claude's accent marks Claude's own panel in the rail (T-418) —
// nominative use per the licenses.yaml trademark note.
iconColor: claudeAccent,
priority: 60, priority: 60,
build: (_) => const ClaudeMetaSidebar(), build: (_) => const ClaudeMetaSidebar(),
), ),
@@ -4,7 +4,7 @@
/// process), so to "watch the same output" we open our OWN read-only follower /// process), so to "watch the same output" we open our OWN read-only follower
/// on the file the command tails. This never spawns a process and never /// on the file the command tails. This never spawns a process and never
/// touches Claude's command — it just reads the file as it grows, like /// touches Claude's command — it just reads the file as it grows, like
/// `tail -f`, and hands new bytes to [onData]. /// `tail -f`, and hands new bytes to `onData`.
/// ///
/// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather /// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather
/// than using a watcher so it works uniformly across platforms and survives /// than using a watcher so it works uniformly across platforms and survives
@@ -1,28 +1,61 @@
/// The Activity tab: usage stats (stats-cache.json) + the primary /// The Activity tab: session controls, usage, stats (stats-cache.json), and
/// session's live runtime row. Split out of claude_meta_sidebar.dart /// the primary session's live runtime row. Split out of
/// (T-395). /// claude_meta_sidebar.dart (T-395); session controls + the usage block are
/// the power-panel additions (T-415).
library; library;
import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_stats.dart'; import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel; import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class ActivityTabView extends StatelessWidget { class ActivityTabView extends StatelessWidget {
const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config}); const ActivityTabView({
super.key,
required this.stats,
required this.primaryStatus,
required this.config,
this.usage,
this.workflows = const <String, WorkflowRun>{},
});
final ClaudeStats stats; final ClaudeStats stats;
final SessionStatus? primaryStatus; final SessionStatus? primaryStatus;
final ClaudeConfig? config; final ClaudeConfig? config;
/// Parsed `/usage` output for the usage block, refreshed via the refresh
/// control (T-415). Null until the first refresh.
final ClaudeUsage? usage;
/// Live Workflow runs in the primary session, keyed by launching tool-use id
/// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with
/// its done/total agent count and running/done state.
final Map<String, WorkflowRun> workflows;
/// Publish a slash command for the primary pane to execute — the session
/// controls are the same code path as typing the command (D-6).
void _command(BuildContext context, String text) {
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': text});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final latest = stats.latest; final latest = stats.latest;
final u = usage;
final sections = <MetaSection>[ final sections = <MetaSection>[
..._workflowSection(tokens),
if (u != null)
MetaSection('USAGE', [
if (u.session != null) MetaRow('session', u.session!),
if (u.week != null) MetaRow('week (all)', u.week!),
if (u.weekSonnet != null) MetaRow('week (sonnet)', u.weekSonnet!),
]),
if (latest != null) if (latest != null)
MetaSection('TODAY', [ MetaSection('TODAY', [
MetaRow('messages', '${latest.messageCount}'), MetaRow('messages', '${latest.messageCount}'),
@@ -32,10 +65,65 @@ class ActivityTabView extends StatelessWidget {
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]), if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
..._runtimeSection(tokens), ..._runtimeSection(tokens),
]; ];
if (sections.isEmpty) {
return metaPlaceholder('No activity recorded yet.'); return ListView(
} padding: const EdgeInsets.all(12),
return buildMetaTable(tokens, sections); children: [
// SESSION control strip (T-415): drives the primary session through
// the builtin.claude/command bus — identical to typing the command.
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClideText('SESSION', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
),
Row(
children: [
_control(context, tokens, 'clear', 'trash', '/clear'),
_control(context, tokens, 'compact', 'arrows-in-simple', '/compact'),
_control(context, tokens, 'fork', 'git-branch', '/fork'),
_control(context, tokens, 'resume', 'clock-counter-clockwise', '/resume'),
const Spacer(),
_control(context, tokens, 'refresh usage', 'arrow-clockwise', '/usage'),
],
),
const SizedBox(height: 6),
if (sections.isEmpty) metaPlaceholder('No activity recorded yet.') else ...metaTableChildren(tokens, sections),
],
);
}
Widget _control(BuildContext context, SurfaceTokens tokens, String label, String glyph, String command) {
return Semantics(
button: true,
label: '$label session',
excludeSemantics: true,
onTap: () => _command(context, command),
child: ClideTappable(
tooltip: '$label · $command',
onTap: () => _command(context, command),
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: ClideIcon(PhosphorIcons.byName(glyph), size: 15, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
);
}
/// An aggregate WORKFLOWS section while one or more workflow runs exist this
/// session (T-416): a row per run — its name and `done/total agents`, tinted
/// focus while running and success once complete.
List<MetaSection> _workflowSection(SurfaceTokens tokens) {
final runs = workflows.values.toList();
if (runs.isEmpty) return const [];
return [
MetaSection('WORKFLOWS', [
for (final r in runs)
MetaRow(
r.name ?? r.taskId ?? 'workflow',
r.agentCount == 0 ? (r.done ? 'done' : 'starting') : '${r.doneCount}/${r.agentCount} agents${r.done ? '' : ''}',
valueColor: r.done ? tokens.statusSuccess : tokens.globalFocus,
),
]),
];
} }
List<MetaSection> _runtimeSection(SurfaceTokens tokens) { List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
@@ -43,6 +131,7 @@ class ActivityTabView extends StatelessWidget {
final skills = config?.skills.length; final skills = config?.skills.length;
final rows = <MetaRow>[ final rows = <MetaRow>[
if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus), if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
if (st?.effort != null) MetaRow('effort', st!.effort!),
if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'), if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)), if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
if (skills != null) MetaRow('skills', '$skills'), if (skills != null) MetaRow('skills', '$skills'),
@@ -1,22 +1,40 @@
/// The Config tab (T-183): the pinned settings table over [ClaudeConfig] /// The Config tab (T-183): the settings table over [ClaudeConfig] plus the
/// plus the skills/agents/commands/hooks/permissions/MCP accordion. /// skills/agents/commands/hooks/permissions/MCP accordion. Split out of
/// Split out of claude_meta_sidebar.dart (T-395). The accordion's /// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in
/// expansion state lives in the parent (it survives tab switches) and /// the parent (it survives tab switches) and arrives as a prop + toggle
/// arrives as a prop + toggle callback. /// callback.
///
/// T-414 makes the settings table a control panel: model / effort /
/// permission-mode rows are live popover controls. Picking an option
/// publishes the explicit slash command (`/model sonnet`) on the
/// `builtin.claude`/`command` channel; the primary Claude pane executes it
/// through the same `_send` routing the composer uses — one implementation,
/// two surfaces (D-6).
library; library;
import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel; import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart' show ModelOption, kEffortLevels, kFallbackModels, kPermissionModes;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class ConfigTabView extends StatelessWidget { class ConfigTabView extends StatelessWidget {
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection}); const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection, this.status, this.models});
final ClaudeConfig? config; final ClaudeConfig? config;
/// The primary session's live status — drives the control rows' current
/// values. Null before the session reports (controls fall back to the
/// probe/settings values).
final SessionStatus? status;
/// Models selectable for the primary session (from its `initialize`
/// response); falls back to [kFallbackModels].
final List<ModelOption>? models;
/// Sections currently expanded — owned by the parent state. /// Sections currently expanded — owned by the parent state.
final Set<ConfigSection> expanded; final Set<ConfigSection> expanded;
final void Function(ConfigSection section) onToggleSection; final void Function(ConfigSection section) onToggleSection;
@@ -29,19 +47,34 @@ class ConfigTabView extends StatelessWidget {
return metaPlaceholder('Claude environment not loaded.'); return metaPlaceholder('Claude environment not loaded.');
} }
final settings = cfg.settings; final settings = cfg.settings;
final model = cfg.probe?.model ?? settings['model']?.toString() ?? ''; final model = status?.model ?? cfg.probe?.model ?? settings['model']?.toString() ?? 'default';
final outputStyle = settings['outputStyle']?.toString() ?? 'default'; final outputStyle = settings['outputStyle']?.toString() ?? 'default';
final mode = cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default'; final mode = status?.permissionMode ?? cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
final children = <Widget>[ final children = <Widget>[
// Pinned SETTINGS table — not collapsible. // Pinned SETTINGS control panel — not collapsible.
Padding( Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 8),
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted), child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
),
SettingControlRow(
label: 'model',
value: model,
valueColor: tokens.globalFocus,
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
command: 'model',
),
SettingControlRow(label: 'effort', value: effort, options: kEffortLevels, isActive: (o) => o.value == effort, command: 'effort'),
SettingControlRow(
label: 'permission mode',
value: permissionModeLabel(mode),
options: kPermissionModes,
isActive: (o) => o.value == mode,
command: 'permissions',
), ),
_configRow(tokens, 'model', model, valueColor: tokens.globalFocus),
_configRow(tokens, 'output style', outputStyle), _configRow(tokens, 'output style', outputStyle),
_configRow(tokens, 'permission mode', permissionModeLabel(mode)),
_configRow(tokens, 'source', '~/.claude + .claude'), _configRow(tokens, 'source', '~/.claude + .claude'),
// ---- Accordion sections ---- // ---- Accordion sections ----
@@ -57,7 +90,7 @@ class ConfigTabView extends StatelessWidget {
return ListView(padding: const EdgeInsets.all(12), children: children); return ListView(padding: const EdgeInsets.all(12), children: children);
} }
/// One key→value row in the pinned SETTINGS table. /// One read-only key→value row in the pinned SETTINGS table.
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) { Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
@@ -66,10 +99,10 @@ class ConfigTabView extends StatelessWidget {
children: [ children: [
SizedBox( SizedBox(
width: kMetaLabelColumnWidth, width: kMetaLabelColumnWidth,
child: ClideText(label, muted: true, fontSize: clideFontSmall), child: ClideText(label, muted: true, fontSize: kMetaFont),
), ),
Expanded( Expanded(
child: ClideText(value, fontSize: clideFontSmall, color: valueColor ?? tokens.globalForeground), child: ClideText(value, fontSize: kMetaFont, color: valueColor ?? tokens.globalForeground),
), ),
], ],
), ),
@@ -223,3 +256,105 @@ class ConfigTabView extends StatelessWidget {
return rows; return rows;
} }
} }
/// One live setting row (T-414): label + current value as a popover control on
/// the owned anchored-menu primitive. Picking an option publishes the explicit
/// slash command on `builtin.claude`/`command`; the primary Claude pane
/// executes it through its normal `_send` routing — so the sidebar control and
/// the typed command are literally the same code path (D-6).
class SettingControlRow extends StatefulWidget {
const SettingControlRow({
super.key,
required this.label,
required this.value,
required this.options,
required this.isActive,
required this.command,
this.valueColor,
});
final String label;
/// Current value, displayed on the trigger.
final String value;
final Color? valueColor;
final List<ModelOption> options;
final bool Function(ModelOption option) isActive;
/// The slash-command token this control drives (`model`, `effort`,
/// `permissions`); a pick publishes `/<command> <option.value>`.
final String command;
@override
State<SettingControlRow> createState() => _SettingControlRowState();
}
class _SettingControlRowState extends State<SettingControlRow> {
final ClideOverlayController _overlay = ClideOverlayController();
void _pick(String value) {
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': '/${widget.command} $value'});
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: kMetaLabelColumnWidth,
child: ClideText(widget.label, muted: true, fontSize: kMetaFont),
),
Expanded(
child: ClideAnchoredOverlay(
controller: _overlay,
align: ClideAnchorAlign.start,
overlayBuilder: (ctx, c) => ClideMenu(
onClose: c.close,
entries: [
for (final o in widget.options)
ClideMenuItem(
label: o.description.isEmpty ? o.displayName : '${o.displayName}${o.description}',
active: widget.isActive(o),
semanticLabel: '${widget.label}: ${o.displayName}',
onSelect: () => _pick(o.value),
),
],
),
anchor: Semantics(
button: true,
label: '${widget.label}: ${widget.value}. Click to change.',
excludeSemantics: true,
onTap: _overlay.toggle,
child: ClideTappable(
tooltip: 'change ${widget.label}',
onTap: _overlay.toggle,
builder: (ctx, hovered, _) => DecoratedBox(
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ClideText(widget.value, fontSize: kMetaFont, color: widget.valueColor ?? tokens.globalForeground, maxLines: 1),
),
const SizedBox(width: 4),
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
],
),
),
),
),
),
),
),
],
),
);
}
}
@@ -9,7 +9,11 @@ import 'package:flutter/widgets.dart';
/// The shared label-column width + row pitch the Activity and Config tables /// The shared label-column width + row pitch the Activity and Config tables
/// both use, so toggling between tabs keeps every value at the same x and y. /// both use, so toggling between tabs keeps every value at the same x and y.
const double kMetaLabelColumnWidth = 110; const double kMetaLabelColumnWidth = 110;
const double kMetaRowPitch = 4; const double kMetaRowPitch = 6;
/// Type scale for the sidebar tables (T-414 styling pass): labels/values read
/// at meta size (13) — the old 12px-everything read as bland and cramped.
const double kMetaFont = clideFontMeta;
/// The sidebar's sub-tabs. /// The sidebar's sub-tabs.
enum SidebarTab { activity, team, config } enum SidebarTab { activity, team, config }
@@ -36,18 +40,23 @@ class MetaRow {
/// The muted empty-state body shared by every tab. /// The muted empty-state body shared by every tab.
Widget metaPlaceholder(String text) => Padding( Widget metaPlaceholder(String text) => Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: ClideText(text, muted: true, fontSize: clideFontSmall), child: ClideText(text, muted: true, fontSize: kMetaFont),
); );
/// Key→value sections on the shared table geometry (Activity + Config). /// Key→value sections on the shared table geometry (Activity + Config).
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) { Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) =>
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections));
/// The table rows without the enclosing ListView, for tabs that compose extra
/// widgets around the sections (the Activity tab's control strip, T-415).
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
final children = <Widget>[]; final children = <Widget>[];
for (var i = 0; i < sections.length; i++) { for (var i = 0; i < sections.length; i++) {
final s = sections[i]; final s = sections[i];
children.add( children.add(
Padding( Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6), padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted), child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
), ),
); );
for (final r in s.rows) { for (final r in s.rows) {
@@ -59,10 +68,10 @@ Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
children: [ children: [
SizedBox( SizedBox(
width: kMetaLabelColumnWidth, width: kMetaLabelColumnWidth,
child: ClideText(r.label, muted: true, fontSize: clideFontSmall), child: ClideText(r.label, muted: true, fontSize: kMetaFont),
), ),
Expanded( Expanded(
child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground), child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
), ),
], ],
), ),
@@ -70,5 +79,5 @@ Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
); );
} }
} }
return ListView(padding: const EdgeInsets.all(12), children: children); return children;
} }
@@ -51,7 +51,7 @@ class AgentRosterRow extends StatefulWidget {
final void Function(String memberName, String text) onInjectSubmit; final void Function(String memberName, String text) onInjectSubmit;
final void Function(String memberName) onClose; final void Function(String memberName) onClose;
/// Called when the badge cycles to a new [mode] string for this member. /// Called when the badge cycles to a new `mode` string for this member.
/// Handles both safe-trio clicks and confirmed bypass. The parent sends /// Handles both safe-trio clicks and confirmed bypass. The parent sends
/// the mode to the session via `StreamJsonSession.setPermissionMode`. /// the mode to the session via `StreamJsonSession.setPermissionMode`.
final void Function(String memberName, String mode) onSetPermissionMode; final void Function(String memberName, String mode) onSetPermissionMode;
@@ -0,0 +1,183 @@
/// The `/model` picker for the interaction zone (T-408, D-78): a bare
/// `/model` swaps this card in for the composer; picking an entry sends
/// `set_model` over the control channel and the composer returns. Esc
/// cancels. Like [ToolPromptCard], it lives in the composer zone — never
/// inline in the conversation.
///
/// Keyboard: number keys pick directly (CLI muscle memory, T-240), Up/Down
/// move the highlight, Enter picks the highlighted entry, Esc cancels.
library;
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Whether [option] is the session's current model. Options carry aliases
/// (`sonnet`) or full ids while the status holds the full id
/// (`claude-sonnet-4-6`), so match on equality or alias containment.
bool modelOptionIsCurrent(ModelOption option, String? currentModel) {
if (currentModel == null || option.value == 'default') return false;
if (option.value == currentModel) return true;
return currentModel.toLowerCase().contains(option.value.toLowerCase());
}
class ModelPickerCard extends StatefulWidget {
const ModelPickerCard({
super.key,
required this.models,
this.currentModel,
required this.onPick,
required this.onCancel,
this.title = 'model',
this.isCurrent = modelOptionIsCurrent,
});
/// Selectable entries, in display order. Callers pass [kFallbackModels]
/// when the session hasn't reported its list yet.
final List<ModelOption> models;
/// The session's current model (full id), to mark the active entry.
final String? currentModel;
/// Called once with the picked [ModelOption.value].
final void Function(String value) onPick;
/// Called when the user dismisses the picker without choosing.
final VoidCallback onCancel;
/// Header label. The /effort picker reuses this card with its own title
/// and an exact-match [isCurrent] (T-412).
final String title;
/// Marks the active entry. The model default ([modelOptionIsCurrent]) also
/// alias-matches (`sonnet` ⊂ `claude-sonnet-4-6`); effort needs exact match
/// (`high` would falsely match inside `xhigh`).
final bool Function(ModelOption option, String? current) isCurrent;
@override
State<ModelPickerCard> createState() => _ModelPickerCardState();
}
class _ModelPickerCardState extends State<ModelPickerCard> {
late int _highlight = _initialHighlight();
int _initialHighlight() {
for (var i = 0; i < widget.models.length; i++) {
if (widget.isCurrent(widget.models[i], widget.currentModel)) return i;
}
return 0;
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent || !node.hasPrimaryFocus) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored;
final key = e.logicalKey;
if (key == LogicalKeyboardKey.escape) {
widget.onCancel();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
setState(() => _highlight = (_highlight + 1) % widget.models.length);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowUp) {
setState(() => _highlight = (_highlight - 1 + widget.models.length) % widget.models.length);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
widget.onPick(widget.models[_highlight].value);
return KeyEventResult.handled;
}
final digit = _digitOf(key);
if (digit != null && digit >= 1 && digit <= widget.models.length) {
widget.onPick(widget.models[digit - 1].value);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
static int? _digitOf(LogicalKeyboardKey key) {
const digits = [
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
];
const numpad = [
LogicalKeyboardKey.numpad1,
LogicalKeyboardKey.numpad2,
LogicalKeyboardKey.numpad3,
LogicalKeyboardKey.numpad4,
LogicalKeyboardKey.numpad5,
LogicalKeyboardKey.numpad6,
LogicalKeyboardKey.numpad7,
LogicalKeyboardKey.numpad8,
LogicalKeyboardKey.numpad9,
];
var i = digits.indexOf(key);
if (i < 0) i = numpad.indexOf(key);
return i < 0 ? null : i + 1;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border(top: BorderSide(color: tokens.statusInfo, width: 2)),
),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusInfo),
const Spacer(),
ClideText('↑↓ · 1-${widget.models.length} · Enter · Esc', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
],
),
const SizedBox(height: 8),
for (var i = 0; i < widget.models.length; i++) _row(tokens, i),
const SizedBox(height: 6),
Row(
children: [
const Spacer(),
ClideButton(label: 'cancel', variant: ClideButtonVariant.subtle, onPressed: widget.onCancel),
],
),
],
),
),
);
}
Widget _row(SurfaceTokens tokens, int i) {
final m = widget.models[i];
final current = widget.isCurrent(m, widget.currentModel);
final highlighted = i == _highlight;
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ClideButton(
label: '${i + 1}. ${current ? '' : ''} ${m.displayName}${m.description.isEmpty ? '' : '${m.description}'}',
variant: highlighted ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
onPressed: () => widget.onPick(m.value),
),
);
}
}
+28
View File
@@ -0,0 +1,28 @@
/// Resolve the `native/<os>-<arch>/` directory name via FFI ABI introspection
/// (T-438 web fence, D-100). Desktop-only; the web build uses
/// [native_abi_stub.dart], so `dart:ffi` (here, only `Abi`) stays out of the
/// wasm graph.
library;
import 'dart:ffi' show Abi;
import 'dart:io' show Platform;
/// The `<os>-<arch>` dir name for the current process (e.g. `linux-x64`,
/// `macos-arm64`) — used to find a dev-tree `clide` binary when running
/// un-installed. [abi] is an injection seam for tests; production passes none.
String currentNativeDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macOS today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
@@ -0,0 +1,6 @@
/// Web stub (T-438 web fence, D-100): no FFI ABI introspection on web. The
/// native dir name only matters for locating a dev-tree `clide` binary, which
/// doesn't exist on the web target — so a harmless default suffices.
library;
String currentNativeDirName() => 'linux-x64';
+1 -1
View File
@@ -5,7 +5,7 @@
/// Rendered in the composer zone (not inline in the conversation) so /// Rendered in the composer zone (not inline in the conversation) so
/// interaction and conversation widgets don't mix — the pane swaps it in for /// interaction and conversation widgets don't mix — the pane swaps it in for
/// the text input while a prompt is open. The decision is returned via /// the text input while a prompt is open. The decision is returned via
/// [onResolve]; the pane then removes the card. /// `onResolve`; the pane then removes the card.
/// ///
/// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no /// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no
/// hover-revealed chrome that would fight the buttons. /// hover-revealed chrome that would fight the buttons.
+18 -6
View File
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent /// Erase [sessionId]'s transcript under [projectDir] so a subsequent
/// `claude --session-id <sessionId>` re-creates it empty — the in-place /// `claude --session-id <sessionId>` re-creates it empty — the in-place
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl` /// `/clear` path for the primary pane (T-268). Removes the `<id>.jsonl`, plus
/// and the sidecar `<id>/` directory claude keeps beside it. Best-effort: /// a per-session `<id>/` sidecar dir if one exists (best-effort; missing
/// missing entries are not an error. The caller MUST have killed the session's /// entries are not an error). Note the shared per-project `memory/` dir that
/// process first, so claude is not mid-write. /// claude 2.1.x keeps beside transcripts is deliberately left alone — it is
/// not per-session.
///
/// The caller MUST have AWAITED the session's process death first (T-437): a
/// still-live claude re-flushes its transcript and keeps the id registered, so
/// the respawn's `--session-id` is rejected as "already in use" (exit 1).
/// [ClaudeSessionOrchestrator.close] now awaits that death before this runs.
Future<void> clearSessionTranscript(String projectDir, String sessionId) async { Future<void> clearSessionTranscript(String projectDir, String sessionId) async {
final file = File('$projectDir/$sessionId.jsonl'); final file = File('$projectDir/$sessionId.jsonl');
if (await file.exists()) await file.delete(); if (await file.exists()) await file.delete();
@@ -124,12 +130,18 @@ String freshSessionId() {
/// same id). Expands an FNV-1a stream into 16 bytes. /// same id). Expands an FNV-1a stream into 16 bytes.
String _deterministicUuid(String seed) { String _deterministicUuid(String seed) {
final bytes = <int>[]; final bytes = <int>[];
var h = 0xcbf29ce484222325; // FNV-1a 64-bit offset basis, split into two 32-bit halves so the dart2js
// web fallback accepts it (a full 64-bit literal "can't be represented
// exactly in JavaScript" — T-438). Correct on the VM/wasm; web never derives
// a session id (no claude process there).
var h = (0xcbf29ce4 << 32) | 0x84222325;
const prime = 0x100000001b3; const prime = 0x100000001b3;
for (var i = 0; i < 16; i++) { for (var i = 0; i < 16; i++) {
for (final c in utf8.encode('$seed:$i')) { for (final c in utf8.encode('$seed:$i')) {
h ^= c; h ^= c;
h = (h * prime) & 0xFFFFFFFFFFFFFFFF; // 64-bit modular wrap is implicit on the VM/wasm; the explicit
// `& 0xFFFFFFFFFFFFFFFF` was a no-op and a dart2js-incompatible literal.
h = h * prime;
} }
bytes.add(h & 0xff); bytes.add(h & 0xff);
} }
@@ -2,8 +2,8 @@
/// ///
/// A session is a `claude` stream-json process clide spawns and renders; a /// A session is a `claude` stream-json process clide spawns and renders; a
/// pane is just a *view* on one. The orchestrator decouples a session's /// pane is just a *view* on one. The orchestrator decouples a session's
/// lifecycle from any pane: [spawn] starts and registers it, [show]/[hide] /// lifecycle from any pane: `spawn` starts and registers it, `show`/`hide`
/// toggle visibility WITHOUT tearing the process down, and [close] kills it. /// toggle visibility WITHOUT tearing the process down, and `close` kills it.
/// This is the one primitive behind teammate / secondary tab / forked branch /// This is the one primitive behind teammate / secondary tab / forked branch
/// (Phase 2): they are all just managed sessions shown as panes. /// (Phase 2): they are all just managed sessions shown as panes.
/// ///
@@ -49,6 +49,7 @@ class SpawnSpec {
this.team = false, this.team = false,
this.memberName, this.memberName,
this.forkSourceSessionId, this.forkSourceSessionId,
this.effort,
}); });
final String id; final String id;
@@ -81,6 +82,12 @@ class SpawnSpec {
/// Takes precedence over [resume]/[sessionId] for arg selection. /// Takes precedence over [resume]/[sessionId] for arg selection.
final String? forkSourceSessionId; final String? forkSourceSessionId;
/// Effort level passed to `claude --effort` (low/medium/high/xhigh/max,
/// T-412). Null spawns without the flag — the CLI uses its configured
/// default (settings.json `effortLevel`). No set_effort control subtype
/// exists, so changing effort means respawn-with-resume carrying this.
final String? effort;
/// Whether this spec spawns a forked session. /// Whether this spec spawns a forked session.
bool get isFork => forkSourceSessionId != null; bool get isFork => forkSourceSessionId != null;
} }
@@ -238,7 +245,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
preambles.add(_teamSystemPrompt(name, spec.role)); preambles.add(_teamSystemPrompt(name, spec.role));
} }
final bootstrap = agentBootstrap(spec.cwd, base: spec.env); final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs]; sessionArgs = [
'--append-system-prompt',
preambles.join('\n\n'),
...bootstrap.extraArgs,
if (spec.effort != null) ...['--effort', spec.effort!],
...sessionArgs,
];
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta); final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start(); final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
@@ -300,12 +313,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
} }
/// Kill and forget a session (the real teardown). The conversation's /// Kill and forget a session (the real teardown). The conversation's
/// onDispose kills the process + closes its streams. /// onDispose kills the process + closes its streams; we then AWAIT the
/// session's teardown so the `claude` process is genuinely dead before we
/// return (T-437). Callers respawn the primary on the same deterministic
/// `--session-id` right after /clear — if the old process were still alive,
/// claude 2.1.177 would reject the id as "already in use" and the respawn
/// would exit 1.
Future<void> close(String id) async { Future<void> close(String id) async {
final m = _sessions.remove(id); final m = _sessions.remove(id);
if (m == null) return; if (m == null) return;
broker.removeMember(id); broker.removeMember(id);
m.conversation.dispose(); m.conversation.dispose(); // cancels the item subscription; kicks off session teardown
await m.session.dispose(); // idempotent — awaits the real process exit
notifyListeners(); notifyListeners();
} }
+110 -2
View File
@@ -30,8 +30,29 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
/// Slash commands clide handles itself instead of forwarding to Claude: /// Slash commands clide handles itself instead of forwarding to Claude:
/// Claude Code's own handling forks the session to a new id that clide's /// Claude Code's own handling forks the session to a new id that clide's
/// transcript reader can't follow, so clide owns the semantics (T-156). /// transcript reader can't follow, so clide owns the semantics (T-156).
/// `/fork` branches the current session into a new pane (T-172). /// `/fork` branches the current session into a new pane (T-172). `/model`
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'}; /// is interactive in the CLI's TUI only — forwarded it does nothing — so
/// clide owns it as a set_model control request / picker (T-408). `/effort`
/// has no control subtype, so clide owns it as a respawn-with-resume
/// carrying `--effort` (T-412). `/permissions` is a picker over
/// set_permission_mode; the rest navigate to clide surfaces (T-413):
/// /status//config//mcp//agents//hooks → the Claude sidebar tabs,
/// /memory → CLAUDE.md in the editor, /help → a local command summary.
const Set<String> kClideOwnedCommands = {
'clear',
'resume',
'fork',
'model',
'effort',
'permissions',
'status',
'config',
'mcp',
'agents',
'hooks',
'memory',
'help',
};
/// The clide-owned command in [text] (a single-line leading-slash token in /// The clide-owned command in [text] (a single-line leading-slash token in
/// [kClideOwnedCommands]), or null. /// [kClideOwnedCommands]), or null.
@@ -40,6 +61,93 @@ String? clideOwnedCommand(String text) {
return token != null && kClideOwnedCommands.contains(token) ? token : null; return token != null && kClideOwnedCommands.contains(token) ? token : null;
} }
/// Where slash input goes (T-411). One source of truth so a TUI-only command
/// neither errors raw from the CLI nor bracket-pastes to the model as text
/// (burning a real turn — observed with /effort on claude 2.1.175).
enum SlashRoute {
/// clide implements it natively ([kClideOwnedCommands]).
owned,
/// The CLI handles it headless — advertised in the `initialize` handshake's
/// `slash_commands` (skills + the headless builtins: compact, context, …).
forward,
/// A known TUI-only builtin: never forwarded; clide shows a local notice
/// with the clide-native way ([kTuiOnlyCommands]).
unavailable,
}
/// Claude Code TUI-only builtins (probed against 2.1.175: not advertised in
/// stream-json, and forwarding would either error "isn't available in this
/// environment" or — worse, for un-advertised tokens — bracket-paste to the
/// model as literal text). Value = the clide-native pointer shown in the
/// notice card. Commands clide later implements move to [kClideOwnedCommands].
const Map<String, String> kTuiOnlyCommands = {
'effort': '', // owned (T-412) — only routes here if ever removed from owned
'status': '', // owned (T-413)
'cost': 'cost and context usage live in the Claude sidebar (Activity tab)',
'context': '', // advertised on current CLIs — only routes here on older ones
'help': '', // owned (T-413)
'config': '', // owned (T-413)
'permissions': '', // owned (T-413)
'memory': '', // owned (T-413)
'mcp': '', // owned (T-413)
'agents': '', // owned (T-413)
'hooks': '', // owned (T-413)
'todos': "Claude's task list docks above the composer",
'model': '', // owned (T-408) — only routes here if ever removed from owned
'doctor': 'run `claude doctor` in a terminal',
'login': 'run `claude` in a terminal and use /login there',
'logout': 'run `claude` in a terminal and use /logout there',
'exit': 'close the pane or switch sessions instead',
'vim': 'clide ships its own editor vim mode',
'add-dir': '',
'bashes': '',
'bug': '',
'export': '',
'fast': '',
'ide': "you're already in one",
'install-github-app': '',
'migrate-installer': '',
'output-style': '',
'pr-comments': '',
'privacy-settings': '',
'release-notes': '',
'rewind': '',
'statusline': '',
'terminal-setup': '',
'upgrade': '',
};
/// Route [text] (composer input). Null when it isn't slash-command input —
/// send it as a normal message. Precedence: owned > advertised > TUI-only
/// catalog > forward (unknown tokens stay literal text via bracketed paste).
SlashRoute? routeSlashCommand(String text, {required Iterable<String> advertised}) {
final token = slashCommandToken(text);
if (token == null) return null;
if (kClideOwnedCommands.contains(token)) return SlashRoute.owned;
if (advertised.contains(token)) return SlashRoute.forward;
if (kTuiOnlyCommands.containsKey(token)) return SlashRoute.unavailable;
return SlashRoute.forward;
}
/// The notice text for a TUI-only [token] — the CLI's own phrasing plus the
/// clide-native pointer when the catalog has one.
String tuiOnlyNotice(String token) {
final hint = kTuiOnlyCommands[token] ?? '';
final base = "/$token is a Claude Code TUI command — it isn't available in clide's conversation pane.";
return hint.isEmpty ? base : '$base\n$hint';
}
/// The argument text after the command token — `"/model sonnet"` → `"sonnet"`
/// — trimmed; empty when there is none (`"/model"`). Null when [text] isn't
/// single-line leading-slash input.
String? slashCommandArg(String text) {
if (slashCommandToken(text) == null) return null;
final ws = text.indexOf(RegExp(r'\s'));
return ws < 0 ? '' : text.substring(ws + 1).trim();
}
bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n'; bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n';
/// An in-progress slash query at the cursor — the `/` position and the word /// An in-progress slash query at the cursor — the `/` position and the word
+226 -18
View File
@@ -19,6 +19,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/src/util/value_stream.dart'; import 'package:clide/src/util/value_stream.dart';
/// The claude subprocess, abstracted so tests drive it without spawning. /// The claude subprocess, abstracted so tests drive it without spawning.
@@ -106,7 +107,20 @@ class ClaudeStreamJsonProcess extends StreamJsonProcess {
@override @override
Future<void> kill() async { Future<void> kill() async {
// Await the process's ACTUAL death, not just the signal (T-437). clide
// respawns the primary on the SAME deterministic --session-id right after
// /clear; if the old process is still alive (or still flushing its
// transcript) when the new one starts, claude 2.1.177 rejects the id with
// "Session ID … is already in use" and the respawn exits 1. SIGTERM first
// (claude cleans its session registry on it), escalate to SIGKILL if it
// doesn't go, and only return once exitCode has resolved.
_proc.kill(); _proc.kill();
try {
await _proc.exitCode.timeout(const Duration(seconds: 2));
} on TimeoutException {
_proc.kill(ProcessSignal.sigkill);
await _proc.exitCode;
}
} }
@override @override
@@ -147,6 +161,53 @@ abstract class McpServer {
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments); Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments);
} }
/// A model selectable for a session, from the `initialize` control_response's
/// `models[]` (T-408). Pure data, Flutter-free.
class ModelOption {
const ModelOption({required this.value, required this.displayName, this.description = ''});
/// The id/alias sent in `set_model` — e.g. `default`, `sonnet`, `opus`.
final String value;
/// Human label, e.g. `Sonnet`.
final String displayName;
/// One-line blurb shown muted next to the label.
final String description;
}
/// Effort levels `claude --effort` accepts (probed against 2.1.175). There is
/// NO set_effort control subtype (probed: rejected), so changing effort
/// respawns the session with the flag — resume keeps the conversation (T-412).
/// Expressed as [ModelOption]s so the /effort picker reuses the /model card.
const List<ModelOption> kEffortLevels = [
ModelOption(value: 'low', displayName: 'low', description: 'fastest, minimal thinking'),
ModelOption(value: 'medium', displayName: 'medium', description: 'balanced'),
ModelOption(value: 'high', displayName: 'high', description: 'thorough'),
ModelOption(value: 'xhigh', displayName: 'xhigh', description: 'deeper reasoning'),
ModelOption(value: 'max', displayName: 'max', description: 'maximum thinking budget'),
];
/// Permission modes for the /permissions picker (T-413), set over the
/// set_permission_mode control request. Bypass is last and explicit — the
/// footgun stays visible but never the default reach (T-181).
const List<ModelOption> kPermissionModes = [
ModelOption(value: 'default', displayName: 'default', description: 'ask before sensitive tools'),
ModelOption(value: 'acceptEdits', displayName: 'acceptEdits', description: 'auto-approve file edits'),
ModelOption(value: 'plan', displayName: 'plan', description: 'read-only planning mode'),
ModelOption(value: 'bypassPermissions', displayName: 'bypassPermissions', description: 'no prompts at all — careful'),
];
/// Fallback picker entries for when the `initialize` response hasn't arrived
/// (or carried no models): the stable aliases every claude build accepts
/// (T-408). `default` resets to the CLI's configured model.
const List<ModelOption> kFallbackModels = [
ModelOption(value: 'default', displayName: 'Default', description: 'recommended — the CLI\'s configured model'),
ModelOption(value: 'sonnet', displayName: 'Sonnet', description: 'fast, great for everyday tasks'),
ModelOption(value: 'opus', displayName: 'Opus', description: 'most capable'),
ModelOption(value: 'haiku', displayName: 'Haiku', description: 'fastest, lightweight'),
];
/// An interactive prompt Claude is blocked on, from the stream-json control /// An interactive prompt Claude is blocked on, from the stream-json control
/// channel (a `can_use_tool` control_request) — a tool needing permission, or /// channel (a `can_use_tool` control_request) — a tool needing permission, or
/// an `AskUserQuestion`. Pure data; the decision goes back via /// an `AskUserQuestion`. Pure data; the decision goes back via
@@ -240,6 +301,20 @@ class SessionEnd {
final int exitCode; final int exitCode;
final List<String> stderrTail; final List<String> stderrTail;
/// The most recent non-empty stderr line — the CLI's own error message when
/// it dies (e.g. "Session ID … is already in use") — for surfacing in the
/// pane so a non-zero exit is never an opaque "code 1" (T-437). Empty when
/// stderr was silent; capped so a stray long line can't blow out the status
/// line.
String get reason {
for (final line in stderrTail.reversed) {
final t = line.trim();
if (t.isEmpty) continue;
return t.length > 200 ? '${t.substring(0, 200)}' : t;
}
return '';
}
} }
class StreamJsonSession { class StreamJsonSession {
@@ -261,6 +336,27 @@ class StreamJsonSession {
String? _claudeSessionId; String? _claudeSessionId;
int _localSeq = 0; int _localSeq = 0;
/// The `initialize` handshake's request id — its control_response carries
/// the selectable `models[]` (T-408).
String? _initRequestId;
/// In-flight `set_model` request ids → the model the status held before the
/// optimistic merge, so an error response can roll it back (T-408).
final _pendingSetModel = <String, String?>{};
List<ModelOption> _availableModels = const [];
/// Models selectable for this session, from the `initialize` response.
/// Empty until that response arrives (callers fall back to
/// [kFallbackModels]).
List<ModelOption> get availableModels => _availableModels;
final _modelErrorCtl = StreamController<String>.broadcast();
/// Errors from rejected `set_model` requests (e.g. an unknown model name),
/// for the pane to surface (T-408).
Stream<String> get modelErrors => _modelErrorCtl.stream;
/// Token-by-token streaming state (T-168, wire shape verified by T-184). /// Token-by-token streaming state (T-168, wire shape verified by T-184).
/// ///
/// With `--include-partial-messages`, claude emits the in-progress reply as /// With `--include-partial-messages`, claude emits the in-progress reply as
@@ -309,6 +405,20 @@ class StreamJsonSession {
Map<String, bool> get toolUseOutcomes => _toolUseOutcome; Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
Set<String> get quietErrorToolUseIds => _quietErrorToolUses; Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
/// Live Workflow runs, keyed by their launching `Workflow` tool-use id
/// (T-416). Accumulated from the out-of-band `system` task_* events the
/// harness emits while a workflow runs in the background; the conversation
/// card and the sidebar indicator both read this snapshot. Ephemeral — the
/// events aren't in the resumed transcript, so this is empty on reload.
final _workflows = <String, WorkflowRun>{};
final _workflowsCtl = ValueStream<Map<String, WorkflowRun>>.seeded(const {});
/// The current workflow runs, keyed by launching tool-use id.
Map<String, WorkflowRun> get workflows => Map.unmodifiable(_workflows);
/// Emits the workflow-run map whenever a `system` task event updates it.
Stream<Map<String, WorkflowRun>> get workflowsStream => _workflowsCtl.stream;
/// Whether a turn is in flight (between a send and claude's `result`). Drives /// Whether a turn is in flight (between a send and claude's `result`). Drives
/// the composer's Stop affordance. /// the composer's Stop affordance.
bool _busy = false; bool _busy = false;
@@ -368,22 +478,22 @@ class StreamJsonSession {
// code is not (T-361). // code is not (T-361).
final exit = _proc.exitCode; final exit = _proc.exitCode;
if (exit != null) unawaited(exit.then(_onExit)); if (exit != null) unawaited(exit.then(_onExit));
// Declaring our in-process MCP servers in the `initialize` handshake is what // The `initialize` handshake is side-effect-free (verified in the protocol
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent // spike) and does double duty: declaring our in-process MCP servers is what
// when we actually host a server, so a plain session is unchanged. // makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
if (_mcpServers.isNotEmpty) { // response's `models[]` feeds the /model picker (T-408).
_proc.writeLine( _initRequestId = 'init-${_localSeq++}';
jsonEncode({ _proc.writeLine(
'type': 'control_request', jsonEncode({
'request_id': 'init-${_localSeq++}', 'type': 'control_request',
'request': { 'request_id': _initRequestId,
'subtype': 'initialize', 'request': {
'hooks': <String, dynamic>{}, 'subtype': 'initialize',
'sdkMcpServers': [for (final s in _mcpServers) s.name], 'hooks': <String, dynamic>{},
}, 'sdkMcpServers': [for (final s in _mcpServers) s.name],
}), },
); }),
} );
} }
void _onLine(String line) { void _onLine(String line) {
@@ -411,6 +521,12 @@ class StreamJsonSession {
_onControlRequest(ev); _onControlRequest(ev);
return; return;
} }
// Responses to OUR control requests: the initialize result (models) and
// set_model acks/errors (T-408).
if (ev['type'] == 'control_response') {
_onControlResponse(ev);
return;
}
// A `result` ends the turn — clear the busy/interruptible state and reset // A `result` ends the turn — clear the busy/interruptible state and reset
// streaming state so the next turn is fresh. // streaming state so the next turn is fresh.
if (ev['type'] == 'result') { if (ev['type'] == 'result') {
@@ -427,6 +543,15 @@ class StreamJsonSession {
return; return;
} }
// Workflow run progress (T-416): the harness reports a backgrounded Workflow
// tool's fan-out on out-of-band `system` task_* events keyed by the
// launching tool-use id. Fold them into the run snapshot and notify; they
// carry no conversation item, so don't fall through to the parser.
if (isWorkflowSystemEvent(ev)) {
_onWorkflowEvent(ev);
return;
}
// Finalise a streamed reply: when the real text `assistant` event for a // Finalise a streamed reply: when the real text `assistant` event for a
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid // message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
// so the controller replaces the placeholder in place rather than appending // so the controller replaces the placeholder in place rather than appending
@@ -500,6 +625,15 @@ class StreamJsonSession {
} }
} }
/// Fold one workflow `system` task event into its run snapshot, keyed by the
/// launching tool-use id, and publish the updated map (T-416).
void _onWorkflowEvent(Map<String, dynamic> ev) {
final id = ev['tool_use_id'] as String;
final prior = _workflows[id] ?? WorkflowRun(toolUseId: id);
_workflows[id] = prior.foldEvent(ev);
_workflowsCtl.add(Map.unmodifiable(_workflows));
}
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt] /// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
/// item the UI resolves; every other subtype is answered with an error so /// item the UI resolves; every other subtype is answered with an error so
/// the turn never hangs waiting on us (D-78). /// the turn never hangs waiting on us (D-78).
@@ -742,6 +876,18 @@ class StreamJsonSession {
_setBusy(true); _setBusy(true);
} }
/// Inject a clide-local notice card into the conversation — nothing is sent
/// to claude. Used by the slash-command router for TUI-only commands
/// (T-411); renders as the muted synthetic "clide" card.
void addLocalNotice(String text) {
_items.add(AssistantTextMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text, synthetic: true));
}
/// Record the effort level this session was spawned with (`--effort`,
/// T-412). The wire never reports effort, so the spawner tells the status
/// what it set; the status line / sidebar read it from [SessionStatus].
void noteEffort(String level) => _mergeStatus(SessionStatus(effort: level));
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends /// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
/// the `interrupt` control_request; claude cancels the current turn and ends /// the `interrupt` control_request; claude cancels the current turn and ends
/// it with a `result`, which clears [busy]. Safe to call when idle. /// it with a `result`, which clears [busy]. Safe to call when idle.
@@ -778,6 +924,59 @@ class StreamJsonSession {
_mergeStatus(SessionStatus(permissionMode: mode)); _mergeStatus(SessionStatus(permissionMode: mode));
} }
/// Set the model for subsequent turns (T-408). Sends a `set_model`
/// control_request; [model] is an alias (`sonnet`, `opus`) or full id, and
/// `default` resets to the CLI's configured model. The status merges
/// optimistically (mirroring [setPermissionMode]); an error response rolls
/// it back and surfaces on [modelErrors].
void setModel(String model) {
final rid = 'set-model-${_localSeq++}';
_pendingSetModel[rid] = _status.model;
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': rid,
'request': {'subtype': 'set_model', 'model': model},
}),
);
// `default` resolves to a model only the CLI knows — leave the status to
// the next assistant event in that case.
if (model != 'default') _mergeStatus(SessionStatus(model: model));
}
/// A `control_response` to one of our requests: capture the initialize
/// result's `models[]`, and roll back + surface a rejected set_model (T-408).
void _onControlResponse(Map<String, dynamic> ev) {
final resp = ev['response'];
if (resp is! Map) return;
final rid = resp['request_id'] as String?;
if (rid == null) return;
final isError = resp['subtype'] == 'error';
if (rid == _initRequestId && !isError) {
final result = resp['response'];
final models = result is Map ? result['models'] : null;
if (models is List) {
_availableModels = List.unmodifiable([
for (final m in models)
if (m is Map && m['value'] is String)
ModelOption(
value: m['value'] as String,
displayName: m['displayName'] as String? ?? m['value'] as String,
description: m['description'] as String? ?? '',
),
]);
}
return;
}
if (_pendingSetModel.containsKey(rid)) {
final previous = _pendingSetModel.remove(rid);
if (isError) {
if (previous != null) _mergeStatus(SessionStatus(model: previous));
_modelErrorCtl.add(resp['error'] as String? ?? 'model change rejected');
}
}
}
/// The process exited under a live session. Flip every "in flight" /// The process exited under a live session. Flip every "in flight"
/// surface off so the pane reflects reality instead of spinning forever. /// surface off so the pane reflects reality instead of spinning forever.
void _onExit(int code) { void _onExit(int code) {
@@ -793,15 +992,24 @@ class StreamJsonSession {
_endCtl.add(_end!); _endCtl.add(_end!);
} }
Future<void> dispose() async { /// Idempotent: the conversation controller's [dispose] fires this
/// unawaited while a caller (the orchestrator's [ClaudeSessionOrchestrator.close])
/// awaits it to know the process is truly dead (T-437). Caching the future
/// makes both paths share one teardown rather than killing/closing twice.
Future<void> dispose() => _disposeFuture ??= _dispose();
Future<void>? _disposeFuture;
Future<void> _dispose() async {
_disposed = true; // deliberate teardown — suppress the exit-watch path _disposed = true; // deliberate teardown — suppress the exit-watch path
await _sub?.cancel(); await _sub?.cancel();
await _proc.kill(); await _proc.kill(); // awaits the process's real exit (T-437)
await _items.close(); await _items.close();
await _statusCtl.close(); await _statusCtl.close();
await _workflowsCtl.close();
await _sessionIdCtl.close(); await _sessionIdCtl.close();
await _pendingCtl.close(); await _pendingCtl.close();
await _busyCtl.close(); await _busyCtl.close();
await _endCtl.close(); await _endCtl.close();
await _modelErrorCtl.close();
} }
} }
+2 -2
View File
@@ -5,8 +5,8 @@
/// and the full workspace pane read from this one model — they share state, /// and the full workspace pane read from this one model — they share state,
/// they do NOT each hold their own copy. /// they do NOT each hold their own copy.
/// ///
/// [postAsUser] is the user's write path: it routes by @tag (one agent or /// `postAsUser` is the user's write path: it routes by @tag (one agent or
/// broadcast) and, when the interrupt flag is set, calls [interrupt()] on the /// broadcast) and, when the interrupt flag is set, calls `interrupt()` on the
/// target session THEN delivers the message. /// target session THEN delivers the message.
/// ///
/// Flutter-free on purpose: this module (like [TeamBroker]) runs under /// Flutter-free on purpose: this module (like [TeamBroker]) runs under
@@ -2,7 +2,7 @@
/// ///
/// Displays the live broker chat timeline as colour-coded rows and provides a /// Displays the live broker chat timeline as colour-coded rows and provides a
/// quick @-post composer. Tapping the pop-out icon opens the full chat pane /// quick @-post composer. Tapping the pop-out icon opens the full chat pane
/// ([claude.team-chat] workspace tab). /// (`claude.team-chat` workspace tab).
/// ///
/// Both this widget and [TeamChatPane] read from the same [TeamChatModel] — /// Both this widget and [TeamChatPane] read from the same [TeamChatModel] —
/// there is one model, two surfaces. /// there is one model, two surfaces.
+31 -9
View File
@@ -24,7 +24,7 @@
/// ///
/// # Version drift-guard /// # Version drift-guard
/// If the envelope `version` field has an unfamiliar major version the reader /// If the envelope `version` field has an unfamiliar major version the reader
/// warns via [onWarn] (or stderr if omitted) and degrades gracefully — it /// warns via `onWarn` (or stderr if omitted) and degrades gracefully — it
/// parses whatever it can and skips the rest rather than crashing. /// parses whatever it can and skips the rest rather than crashing.
library; library;
@@ -114,12 +114,19 @@ final class AssistantTextMessage extends ConversationItem {
super.parentUuid, super.parentUuid,
super.parentToolUseId, super.parentToolUseId,
required this.text, required this.text,
this.synthetic = false,
}); });
final String text; final String text;
/// CLI-local output, not the model: the wire marks it `model: "<synthetic>"`
/// (a forwarded local command's response — /usage output, "/x isn't
/// available in this environment", …). clide-injected notices use it too.
/// Rendered as a muted "clide" card, never coral Claude prose (T-411).
final bool synthetic;
@override @override
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars)'; String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars${synthetic ? ', synthetic' : ''})';
} }
/// Extended thinking block from an assistant turn. /// Extended thinking block from an assistant turn.
@@ -220,7 +227,7 @@ class TranscriptReader {
/// [pollInterval] controls how often the reader polls for new data and /// [pollInterval] controls how often the reader polls for new data and
/// session switches (default 500 ms). /// session switches (default 500 ms).
/// ///
/// [onWarn] receives warning messages from the version drift-guard. /// `onWarn` receives warning messages from the version drift-guard.
/// If omitted, warnings are written to stderr. /// If omitted, warnings are written to stderr.
TranscriptReader( TranscriptReader(
this.workspacePath, { this.workspacePath, {
@@ -412,7 +419,7 @@ class TranscriptReader {
} }
/// Parse a single JSONL line into its items (forwarding any version /// Parse a single JSONL line into its items (forwarding any version
/// warnings to [onWarn]). Public so tests exercise the real parser. /// warnings to `onWarn`). Public so tests exercise the real parser.
List<ConversationItem> parseLine(String line) { List<ConversationItem> parseLine(String line) {
final parsed = parseTranscriptChunk(line); final parsed = parseTranscriptChunk(line);
for (final w in parsed.warnings) { for (final w in parsed.warnings) {
@@ -426,7 +433,7 @@ class TranscriptReader {
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw, /// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
/// and the reader [merge]s deltas into a running status. /// and the reader [merge]s deltas into a running status.
class SessionStatus { class SessionStatus {
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo}); const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo, this.effort});
/// Assistant `message.model`, e.g. `claude-opus-4-7`. /// Assistant `message.model`, e.g. `claude-opus-4-7`.
final String? model; final String? model;
@@ -451,7 +458,13 @@ class SessionStatus {
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited. /// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
final String? rateLimitInfo; final String? rateLimitInfo;
bool get isEmpty => model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null; /// The session's effort level (`--effort`, T-412). The wire never reports
/// it — clide records what it spawned with via [StreamJsonSession.noteEffort];
/// null means the CLI default (settings.json `effortLevel`).
final String? effort;
bool get isEmpty =>
model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null && effort == null;
/// Overlay [other]'s non-null fields onto this one. /// Overlay [other]'s non-null fields onto this one.
SessionStatus merge(SessionStatus other) => SessionStatus( SessionStatus merge(SessionStatus other) => SessionStatus(
@@ -461,6 +474,7 @@ class SessionStatus {
cost: other.cost ?? cost, cost: other.cost ?? cost,
contextWindow: other.contextWindow ?? contextWindow, contextWindow: other.contextWindow ?? contextWindow,
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo, rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
effort: other.effort ?? effort,
); );
@override @override
@@ -471,10 +485,11 @@ class SessionStatus {
other.contextTokens == contextTokens && other.contextTokens == contextTokens &&
other.cost == cost && other.cost == cost &&
other.contextWindow == contextWindow && other.contextWindow == contextWindow &&
other.rateLimitInfo == rateLimitInfo; other.rateLimitInfo == rateLimitInfo &&
other.effort == effort;
@override @override
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo); int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo, effort);
} }
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and /// Result of [parseTranscriptChunk]: items, version-drift warnings, and
@@ -582,7 +597,9 @@ void _extractAssistantStatus(Map<String, dynamic> envelope, _StatusAcc status) {
final message = envelope['message'] as Map?; final message = envelope['message'] as Map?;
if (message == null) return; if (message == null) return;
final model = message['model'] as String?; final model = message['model'] as String?;
if (model != null && model.isNotEmpty) status.model = model; // "<synthetic>" marks CLI-local output (a forwarded local command's
// response) — not a model switch; it must not clobber the tracked model.
if (model != null && model.isNotEmpty && model != kSyntheticModel) status.model = model;
final usage = message['usage'] as Map?; final usage = message['usage'] as Map?;
if (usage != null) { if (usage != null) {
int n(String k) => (usage[k] as num?)?.toInt() ?? 0; int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
@@ -656,6 +673,9 @@ void _parseUserInto(
} }
} }
/// The model marker on CLI-local output (forwarded local-command responses).
const String kSyntheticModel = '<synthetic>';
void _parseAssistantInto( void _parseAssistantInto(
Map<String, dynamic> envelope, Map<String, dynamic> envelope,
String uuid, String uuid,
@@ -669,6 +689,7 @@ void _parseAssistantInto(
if (message == null) return; if (message == null) return;
final content = message['content']; final content = message['content'];
if (content is! List) return; if (content is! List) return;
final synthetic = (message['model'] as String?) == kSyntheticModel;
for (final item in content) { for (final item in content) {
if (item is! Map) continue; if (item is! Map) continue;
@@ -684,6 +705,7 @@ void _parseAssistantInto(
parentUuid: parentUuid, parentUuid: parentUuid,
parentToolUseId: parentToolUseId, parentToolUseId: parentToolUseId,
text: text, text: text,
synthetic: synthetic,
), ),
); );
} }
+234
View File
@@ -0,0 +1,234 @@
/// Live state of a Claude Code Workflow run (T-416).
///
/// A Workflow is the harness's multi-agent orchestration tool. The model calls
/// it as an ordinary `tool_use` (`name: "Workflow"`, `input: {script}`); the
/// tool returns immediately ("launched in background") and the run's real
/// progress arrives out-of-band on stream-json `type: "system"` events keyed by
/// the launching tool-use id. This file is the pure, Flutter-free model that
/// folds those events into a snapshot the conversation/sidebar surfaces render.
///
/// Wire shape (captured by the T-416 spike, claude 2.1.175):
/// - `task_started` — task_id, tool_use_id, description, workflow_name,
/// prompt (script source)
/// - `task_progress` — usage{total_tokens,tool_uses,duration_ms}, summary,
/// and `workflow_progress[]`, a DELTA list mixing
/// `{type:"workflow_phase", index, title}` and
/// `{type:"workflow_agent", index, label, phaseIndex?,
/// phaseTitle?, model, state(start|progress|done),
/// agentId?}` — partial, merged by index.
/// - `task_updated` — patch{status, end_time}
/// - `task_notification` — terminal status:"completed", summary, usage
///
/// Limit: these events are ephemeral (not persisted to the resumed transcript
/// JSONL), so live progress shows during the session; on reload only the tool
/// card + its "launched in background" result survive.
library;
/// Lifecycle of a single workflow agent, from its `state` field.
enum WorkflowAgentState { start, progress, done, unknown }
WorkflowAgentState parseWorkflowAgentState(Object? raw) => switch (raw) {
'start' || 'queued' || 'running' => WorkflowAgentState.start,
'progress' => WorkflowAgentState.progress,
'done' || 'complete' || 'completed' => WorkflowAgentState.done,
_ => WorkflowAgentState.unknown,
};
/// One phase declared by `meta.phases` / a `phase()` call.
class WorkflowPhase {
const WorkflowPhase({required this.index, required this.title});
final int index;
final String title;
}
/// One agent fanned out by the workflow. Fields accrete across `task_progress`
/// deltas — a later delta fills in `agentId` / upgrades `model` / advances
/// `state`, so [mergeDelta] overlays non-null fields onto the prior snapshot.
class WorkflowAgent {
const WorkflowAgent({
required this.index,
required this.label,
this.model,
this.state = WorkflowAgentState.start,
this.agentId,
this.phaseIndex,
this.phaseTitle,
});
final int index;
final String label;
final String? model;
final WorkflowAgentState state;
final String? agentId;
final int? phaseIndex;
final String? phaseTitle;
/// Fold a raw `workflow_agent` delta entry onto this snapshot, keeping prior
/// values where the delta omits a field.
WorkflowAgent mergeDelta(Map<String, dynamic> e) => WorkflowAgent(
index: index,
label: (e['label'] as String?)?.isNotEmpty == true ? e['label'] as String : label,
model: (e['model'] as String?) ?? model,
state: e.containsKey('state') ? parseWorkflowAgentState(e['state']) : state,
agentId: (e['agentId'] as String?) ?? agentId,
phaseIndex: (e['phaseIndex'] as num?)?.toInt() ?? phaseIndex,
phaseTitle: (e['phaseTitle'] as String?) ?? phaseTitle,
);
static WorkflowAgent fromDelta(Map<String, dynamic> e) => WorkflowAgent(
index: (e['index'] as num).toInt(),
label: (e['label'] as String?) ?? '',
model: e['model'] as String?,
state: parseWorkflowAgentState(e['state']),
agentId: e['agentId'] as String?,
phaseIndex: (e['phaseIndex'] as num?)?.toInt(),
phaseTitle: e['phaseTitle'] as String?,
);
}
/// An immutable snapshot of one workflow run. [foldEvent] returns a new snapshot
/// with a single `system` task event applied (the session keeps one per
/// launching tool-use id and replaces it as events arrive).
class WorkflowRun {
const WorkflowRun({
required this.toolUseId,
this.taskId,
this.name,
this.description,
this.summary,
this.done = false,
this.totalTokens,
this.toolUses,
this.durationMs,
this.phases = const {},
this.agents = const {},
});
/// The launching `Workflow` tool-use id — the join key to the conversation
/// card and across all of this run's system events.
final String toolUseId;
/// The harness task id (e.g. `wy01fihjt`), assigned at `task_started`.
final String? taskId;
/// `workflow_name` from `meta.name`.
final String? name;
final String? description;
final String? summary;
/// True once a `task_updated{status:completed}` or `task_notification`
/// terminal event lands.
final bool done;
final int? totalTokens;
final int? toolUses;
final int? durationMs;
/// Phase index → phase. Empty for a phase-less workflow.
final Map<int, WorkflowPhase> phases;
/// Agent index → agent snapshot.
final Map<int, WorkflowAgent> agents;
bool get running => !done;
int get agentCount => agents.length;
int get doneCount => agents.values.where((a) => a.state == WorkflowAgentState.done).length;
/// Agents in index order — the order the script fanned them out.
List<WorkflowAgent> get orderedAgents {
final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index));
return list;
}
/// Phases in index order.
List<WorkflowPhase> get orderedPhases {
final list = phases.values.toList()..sort((a, b) => a.index.compareTo(b.index));
return list;
}
WorkflowRun _copyWith({
String? taskId,
String? name,
String? description,
String? summary,
bool? done,
int? totalTokens,
int? toolUses,
int? durationMs,
Map<int, WorkflowPhase>? phases,
Map<int, WorkflowAgent>? agents,
}) => WorkflowRun(
toolUseId: toolUseId,
taskId: taskId ?? this.taskId,
name: name ?? this.name,
description: description ?? this.description,
summary: summary ?? this.summary,
done: done ?? this.done,
totalTokens: totalTokens ?? this.totalTokens,
toolUses: toolUses ?? this.toolUses,
durationMs: durationMs ?? this.durationMs,
phases: phases ?? this.phases,
agents: agents ?? this.agents,
);
/// Apply one `system` task event ([ev]) and return the updated snapshot.
/// [ev] must already be the decoded envelope; unknown subtypes return `this`.
WorkflowRun foldEvent(Map<String, dynamic> ev) {
switch (ev['subtype']) {
case 'task_started':
return _copyWith(taskId: ev['task_id'] as String?, name: ev['workflow_name'] as String?, description: ev['description'] as String?);
case 'task_progress':
return _foldProgress(ev);
case 'task_updated':
final patch = ev['patch'];
final status = patch is Map ? patch['status'] as String? : null;
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null);
case 'task_notification':
final status = ev['status'] as String?;
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null, summary: ev['summary'] as String?)._foldUsage(ev['usage']);
default:
return this;
}
}
WorkflowRun _foldProgress(Map<String, dynamic> ev) {
final phases = Map<int, WorkflowPhase>.from(this.phases);
final agents = Map<int, WorkflowAgent>.from(this.agents);
final progress = ev['workflow_progress'];
if (progress is List) {
for (final raw in progress) {
if (raw is! Map) continue;
final e = raw.cast<String, dynamic>();
final idx = (e['index'] as num?)?.toInt();
if (idx == null) continue;
switch (e['type']) {
case 'workflow_phase':
phases[idx] = WorkflowPhase(index: idx, title: (e['title'] as String?) ?? 'phase $idx');
case 'workflow_agent':
final prior = agents[idx];
agents[idx] = prior != null ? prior.mergeDelta(e) : WorkflowAgent.fromDelta(e);
}
}
}
return _copyWith(summary: ev['summary'] as String?, phases: phases, agents: agents)._foldUsage(ev['usage']);
}
WorkflowRun _foldUsage(Object? usage) {
if (usage is! Map) return this;
return _copyWith(
totalTokens: (usage['total_tokens'] as num?)?.toInt(),
toolUses: (usage['tool_uses'] as num?)?.toInt(),
durationMs: (usage['duration_ms'] as num?)?.toInt(),
);
}
}
/// The `system` subtypes that carry workflow run progress (T-416). Other system
/// subtypes (`init`, `hook_*`, `thinking_tokens`) are unrelated and left alone.
const Set<String> kWorkflowSystemSubtypes = {'task_started', 'task_progress', 'task_updated', 'task_notification'};
/// True when [ev] is a `system` event carrying workflow run progress that names
/// a launching tool-use id we can key on.
bool isWorkflowSystemEvent(Map<String, dynamic> ev) =>
ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true;
@@ -53,6 +53,23 @@ class DefaultLayoutExtension extends ClideExtension {
// Editor split (D-049, D-054) // Editor split (D-049, D-054)
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor), CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor), CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
// Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every
// preset; the vim preset additionally binds gt/gT to these (T-405 part 2,
// once a global multi-chord matcher lands — see T-404).
CommandContribution(
id: 'workspace.tab.next',
command: 'workspace.tab.next',
title: 'Next Workspace Tab',
defaultBinding: 'ctrl+pagedown',
run: _nextWorkspaceTab,
),
CommandContribution(
id: 'workspace.tab.previous',
command: 'workspace.tab.previous',
title: 'Previous Workspace Tab',
defaultBinding: 'ctrl+pageup',
run: _prevWorkspaceTab,
),
// Sidebar section switching (D-054): alt+1 through alt+5 // Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
CommandContribution( CommandContribution(
@@ -195,6 +212,27 @@ class DefaultLayoutExtension extends ClideExtension {
return IpcResponse.ok(id: '', data: {'focused': 'workspace'}); return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
} }
Future<IpcResponse> _nextWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: true);
Future<IpcResponse> _prevWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: false);
/// Cycle the workspace tab strip with wraparound (T-405). A no-op when there
/// are fewer than two tabs. Activating a tab also focuses the workspace slot
/// so the newly-shown pane takes keyboard focus.
Future<IpcResponse> _cycleWorkspaceTab({required bool forward}) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
final tabs = ctx.panels.tabsFor(Slots.workspace);
if (tabs.length < 2) return IpcResponse.ok(id: '', data: const {'cycled': false});
final active = ctx.panels.activeTabIn(Slots.workspace);
final cur = tabs.indexWhere((t) => t.id == active);
final start = cur < 0 ? 0 : cur;
final next = (start + (forward ? 1 : -1) + tabs.length) % tabs.length;
final nextId = tabs[next].id;
ctx.panels.activateTab(Slots.workspace, nextId);
ctx.focus.setActive(slot: Slots.workspace, contributionId: nextId);
return IpcResponse.ok(id: '', data: {'active': nextId});
}
Future<IpcResponse> _focusRight(List<String> args) async { Future<IpcResponse> _focusRight(List<String> args) async {
final ctx = _ctx; final ctx = _ctx;
if (ctx == null) return _notActivated(); if (ctx == null) return _notActivated();
@@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier {
_dirty = false; _dirty = false;
notifyListeners(); notifyListeners();
} }
case 'editor.selection-changed':
// An external setSelection moved the caret server-side (find-in-files
// line jump, ex-line `:N` goto — T-407). Mirror it onto the active
// buffer so the view's caret follows. Skipped while our own local edits
// are in flight — their echo already carries the authoritative caret.
final id = e.data['id'] as String?;
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
final sel = e.data['selection'];
if (sel is Map) {
_selection = Selection.fromJson(sel.cast<String, Object?>());
notifyListeners();
}
}
case 'editor.settings-changed': case 'editor.settings-changed':
// A source (e.g. a saved .editorconfig) re-resolved the buffer's // A source (e.g. a saved .editorconfig) re-resolved the buffer's
// settings. Refresh the active buffer's copy so indent/ruler update. // settings. Refresh the active buffer's copy so indent/ruler update.
+25 -3
View File
@@ -13,7 +13,7 @@ import 'vim_edit_ops.dart';
/// Tier-2 editor pane. Shows one tab per open buffer via the shared /// Tier-2 editor pane. Shows one tab per open buffer via the shared
/// [MultitabPane] (the same strip the Claude pane uses); the body /// [MultitabPane] (the same strip the Claude pane uses); the body
/// reflects the daemon's active buffer. The daemon ([EditorRegistry]) /// reflects the daemon's active buffer. The daemon (`EditorRegistry`)
/// is the source of truth for which buffers are open and which is /// is the source of truth for which buffers are open and which is
/// active — the local [MultitabController] is reconciled from it, and /// active — the local [MultitabController] is reconciled from it, and
/// tab gestures (select / close) are routed back as `editor.activate` /// tab gestures (select / close) are routed back as `editor.activate`
@@ -54,10 +54,16 @@ class _EditorViewState extends State<EditorView> {
super.initState(); super.initState();
_text = SyntaxTextController(syntax: _syntax); _text = SyntaxTextController(syntax: _syntax);
_focus = FocusNode(); _focus = FocusNode();
_focus.addListener(_onFocusChanged);
_text.addListener(_onTextChanged); _text.addListener(_onTextChanged);
_tabs.addListener(_onTabsChanged); _tabs.addListener(_onTabsChanged);
} }
/// Publish `editor.focused` so non-editor panes can guard their vim nav
/// bindings (`!editor.focused`) — when the editor holds focus, j/k/h/l/gg/G
/// stay buffer motions; when a pane holds focus they become nav (T-406).
void _onFocusChanged() => _keymap?.setScopeFlag('editor.focused', _focus.hasFocus);
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -75,12 +81,14 @@ class _EditorViewState extends State<EditorView> {
void dispose() { void dispose() {
_text.removeListener(_onTextChanged); _text.removeListener(_onTextChanged);
_text.dispose(); _text.dispose();
_focus.removeListener(_onFocusChanged);
_focus.dispose(); _focus.dispose();
_tabs.removeListener(_onTabsChanged); _tabs.removeListener(_onTabsChanged);
_tabs.dispose(); _tabs.dispose();
_controller?.removeListener(_onControllerChanged); _controller?.removeListener(_onControllerChanged);
_controller?.dispose(); _controller?.dispose();
_keymap?.removeListener(_onModeChanged); _keymap?.removeListener(_onModeChanged);
_keymap?.clearScopeFlag('editor.focused');
super.dispose(); super.dispose();
} }
@@ -100,12 +108,20 @@ class _EditorViewState extends State<EditorView> {
final c = _controller!; final c = _controller!;
_syncTabs(c); _syncTabs(c);
_text.updatePath(c.activePath); _text.updatePath(c.activePath);
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
if (c.content != _lastRemoteContent) { if (c.content != _lastRemoteContent) {
_lastRemoteContent = c.content; _lastRemoteContent = c.content;
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
_text.removeListener(_onTextChanged); _text.removeListener(_onTextChanged);
_text.value = TextEditingValue(text: c.content, selection: sel); _text.value = TextEditingValue(text: c.content, selection: sel);
_text.addListener(_onTextChanged); _text.addListener(_onTextChanged);
} else if (sel != _text.value.selection) {
// Selection-only change from an external setSelection (ex-line `:N` goto,
// find-in-files line jump on the already-active buffer) — content is
// unchanged, so move just the caret. The focused field scrolls it into
// view (T-407).
_text.removeListener(_onTextChanged);
_text.value = _text.value.copyWith(selection: sel);
_text.addListener(_onTextChanged);
} }
setState(() {}); // tab/title refresh setState(() {}); // tab/title refresh
} }
@@ -266,7 +282,13 @@ class _EditorViewState extends State<EditorView> {
} }
void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) { void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) {
if (intent is! InvokeCommandIntent) return; if (intent is! InvokeCommandIntent) {
// A typed app intent the matcher fired (e.g. the ex-line `:` open or ZZ).
// The editor only owns editor.vim.* / mode commands; bubble anything else
// to the app-root Actions so it reaches its global handler (T-407).
Actions.maybeInvoke(context, intent);
return;
}
final id = intent.commandId; final id = intent.commandId;
if (!id.startsWith('editor.vim.')) { if (!id.startsWith('editor.vim.')) {
// Mode change (vim.mode.*) or any other command. // Mode change (vim.mode.*) or any other command.
@@ -9,11 +9,26 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// One row in the flattened, currently-visible tree (T-406). The visible set is
/// a pre-order walk of the root plus the children of every expanded directory —
/// the same order the tree renders — so a selection cursor can move over it with
/// j/k.
@immutable
class TreeNode {
const TreeNode({required this.path, required this.name, required this.isDirectory, required this.depth});
final String path;
final String name;
final bool isDirectory;
final int depth;
}
class FileTreeController extends ChangeNotifier { class FileTreeController extends ChangeNotifier {
FileTreeController({required this.ipc, required this.events}) { FileTreeController({required this.ipc, required this.events}) {
_eventSub = events.on<DaemonEvent>().listen(_onEvent); _eventSub = events.on<DaemonEvent>().listen(_onEvent);
@@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier {
final Map<String, List<FileEntry>> _entries = {}; final Map<String, List<FileEntry>> _entries = {};
List<FileEntry>? entriesFor(String path) => _entries[path]; List<FileEntry>? entriesFor(String path) => _entries[path];
/// Display name of the workspace root row ('' path).
String get rootName => _rootPath?.split(Platform.pathSeparator).last ?? '';
// -- Keyboard selection cursor (T-406) -------------------------------------
/// The path of the currently selected row, or null when nothing is selected.
/// '' is the workspace-root row.
String? _selectedPath;
String? get selectedPath => _selectedPath;
/// The flattened, currently-visible rows in render order: the root, then the
/// children of every expanded directory, depth-first.
List<TreeNode> visibleNodes() {
final out = <TreeNode>[];
if (_rootPath == null) return out;
out.add(TreeNode(path: '', name: rootName, isDirectory: true, depth: 0));
if (isExpanded('')) _appendChildren('', 1, out);
return out;
}
void _appendChildren(String path, int depth, List<TreeNode> out) {
final entries = _entries[path];
if (entries == null) return;
for (final e in entries) {
out.add(TreeNode(path: e.path, name: e.name, isDirectory: e.isDirectory, depth: depth));
if (e.isDirectory && _expanded.contains(e.path)) _appendChildren(e.path, depth + 1, out);
}
}
TreeNode? _selectedNode([List<TreeNode>? nodes]) {
final list = nodes ?? visibleNodes();
for (final n in list) {
if (n.path == _selectedPath) return n;
}
return null;
}
/// Move the selection cursor [delta] rows (negative = up), clamped to the
/// visible list. A first move with nothing selected lands on the first row
/// (down) or last row (up).
void moveSelection(int delta) {
final nodes = visibleNodes();
if (nodes.isEmpty) return;
final cur = nodes.indexWhere((n) => n.path == _selectedPath);
final next = cur < 0 ? (delta > 0 ? 0 : nodes.length - 1) : (cur + delta).clamp(0, nodes.length - 1);
if (nodes[next].path == _selectedPath) return;
_selectedPath = nodes[next].path;
notifyListeners();
}
/// Select the first ([top]) or last visible row — vim gg / G.
void selectEdge({required bool top}) {
final nodes = visibleNodes();
if (nodes.isEmpty) return;
final path = (top ? nodes.first : nodes.last).path;
if (path == _selectedPath) return;
_selectedPath = path;
notifyListeners();
}
/// Collapse the selected directory, or — if it's already collapsed (or a
/// file) — step the selection out to its parent row (vim `h`).
Future<void> collapseOrOut() async {
final node = _selectedNode();
if (node == null) return;
if (node.isDirectory && node.path != '' && _expanded.contains(node.path)) {
await toggle(node.path); // collapse in place; selection stays on the dir
return;
}
if (node.path == '') return; // already at root
_selectedPath = _parentOf(node.path);
notifyListeners();
}
/// Expand the selected directory, or — if it's already expanded — step the
/// selection into its first child (vim `l`). A file is a no-op.
Future<void> expandOrInto() async {
final node = _selectedNode();
if (node == null || !node.isDirectory) return;
if (!_expanded.contains(node.path)) {
await toggle(node.path); // expand
return;
}
final children = _entries[node.path];
if (children != null && children.isNotEmpty) {
_selectedPath = children.first.path;
notifyListeners();
}
}
/// Resolve the selected row to an action target for the view: a directory to
/// toggle, or a file path to open (vim `o` / `enter`). Returns null when
/// nothing is selected.
({bool isDirectory, String path})? activateTarget() {
final node = _selectedNode();
if (node == null) return null;
return (isDirectory: node.isDirectory, path: node.path);
}
List<FileEntry> allLoadedEntries() { List<FileEntry> allLoadedEntries() {
final out = <FileEntry>[]; final out = <FileEntry>[];
for (final list in _entries.values) { for (final list in _entries.values) {
+101 -23
View File
@@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget {
class _FileTreeViewState extends State<FileTreeView> { class _FileTreeViewState extends State<FileTreeView> {
FileTreeController? _controller; FileTreeController? _controller;
String _filter = ''; String _filter = '';
final ScrollController _scroll = ScrollController();
/// Key on the currently-selected row, so a keyboard move can scroll it into
/// view (T-406).
final GlobalKey _selectedKey = GlobalKey();
/// Half-page step for ctrl+d / ctrl+u over the flattened tree.
static const int _pageStep = 10;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -39,9 +47,52 @@ class _FileTreeViewState extends State<FileTreeView> {
@override @override
void dispose() { void dispose() {
_controller?.dispose(); _controller?.dispose();
_scroll.dispose();
super.dispose(); super.dispose();
} }
void _onNav(NavIntent intent, int count, FileTreeController c) {
switch (intent) {
case NavDownIntent():
c.moveSelection(count);
case NavUpIntent():
c.moveSelection(-count);
case NavPageDownIntent():
c.moveSelection(_pageStep);
case NavPageUpIntent():
c.moveSelection(-_pageStep);
case NavTopIntent():
c.selectEdge(top: true);
case NavBottomIntent():
c.selectEdge(top: false);
case NavExpandOrRightIntent():
unawaited(c.expandOrInto());
case NavCollapseOrLeftIntent():
unawaited(c.collapseOrOut());
case NavActivateIntent():
_activateSelected(c);
}
}
void _activateSelected(FileTreeController c) {
final t = c.activateTarget();
if (t == null) return;
if (t.isDirectory) {
unawaited(c.toggle(t.path));
} else {
openWorkspaceFile(ClideKernel.of(context), t.path);
}
}
/// Scroll the selected row into view after the frame it's laid out in.
void _ensureSelectedVisible() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _selectedKey.currentContext;
if (ctx == null) return;
Scrollable.ensureVisible(ctx, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: const Duration(milliseconds: 80));
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final c = _controller; final c = _controller;
@@ -57,6 +108,23 @@ class _FileTreeViewState extends State<FileTreeView> {
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)); return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
} }
final rootName = root.split(Platform.pathSeparator).last; final rootName = root.split(Platform.pathSeparator).last;
final selected = c.selectedPath;
if (_filter.isEmpty && selected != null) _ensureSelectedVisible();
final scroller = SingleChildScrollView(
controller: _scroll,
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_filter.isEmpty) ...[
_DirRow(name: rootName, path: '', controller: c, depth: 0, selectedPath: selected, selectedKey: _selectedKey),
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1, selectedPath: selected, selectedKey: _selectedKey),
] else
..._filteredEntries(c),
],
),
);
return Column( return Column(
children: [ children: [
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)), ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
@@ -65,20 +133,10 @@ class _FileTreeViewState extends State<FileTreeView> {
label: 'file tree — $rootName', label: 'file tree — $rootName',
container: true, container: true,
explicitChildNodes: true, explicitChildNodes: true,
child: SingleChildScrollView( // Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
padding: const EdgeInsets.symmetric(vertical: 4), // region holds focus under the vim preset (T-406). The filter
child: Column( // box sits outside it, so typing a filter is never intercepted.
crossAxisAlignment: CrossAxisAlignment.stretch, child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller,
mainAxisSize: MainAxisSize.min,
children: [
if (_filter.isEmpty) ...[
_DirRow(name: rootName, path: '', controller: c, depth: 0),
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
] else
..._filteredEntries(c),
],
),
),
), ),
), ),
], ],
@@ -97,11 +155,13 @@ class _FileTreeViewState extends State<FileTreeView> {
} }
class _Children extends StatelessWidget { class _Children extends StatelessWidget {
const _Children({required this.path, required this.controller, required this.depth}); const _Children({required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
final String path; final String path;
final FileTreeController controller; final FileTreeController controller;
final int depth; final int depth;
final String? selectedPath;
final Key? selectedKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -117,58 +177,67 @@ class _Children extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth), _DirRow(name: e.name, path: e.path, controller: controller, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1), if (controller.isExpanded(e.path))
_Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey),
], ],
) )
else else
_FileRow(name: e.name, path: e.path, depth: depth), _FileRow(name: e.name, path: e.path, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
], ],
); );
} }
} }
class _DirRow extends StatelessWidget { class _DirRow extends StatelessWidget {
const _DirRow({required this.name, required this.path, required this.controller, required this.depth}); const _DirRow({required this.name, required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
final String name; final String name;
final String path; final String path;
final FileTreeController controller; final FileTreeController controller;
final int depth; final int depth;
final String? selectedPath;
final Key? selectedKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final expanded = controller.isExpanded(path); final expanded = controller.isExpanded(path);
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final selected = path == selectedPath;
return Semantics( return Semantics(
button: true, button: true,
label: '${expanded ? 'Collapse' : 'Expand'} $name', label: '${expanded ? 'Collapse' : 'Expand'} $name',
onTap: () => controller.toggle(path), onTap: () => controller.toggle(path),
child: _Row( child: _Row(
key: selected ? selectedKey : null,
depth: depth, depth: depth,
onTap: () => controller.toggle(path), onTap: () => controller.toggle(path),
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground), leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
label: name, label: name,
rotateLeading: expanded, rotateLeading: expanded,
selected: selected,
), ),
); );
} }
} }
class _FileRow extends StatelessWidget { class _FileRow extends StatelessWidget {
const _FileRow({required this.name, required this.path, required this.depth}); const _FileRow({required this.name, required this.path, required this.depth, this.selectedPath, this.selectedKey});
final String name; final String name;
final String path; final String path;
final int depth; final int depth;
final String? selectedPath;
final Key? selectedKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selected = path == selectedPath;
return Semantics( return Semantics(
button: true, button: true,
label: 'Open $name', label: 'Open $name',
onTap: () => _openFile(context, path), onTap: () => _openFile(context, path),
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name), child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected),
); );
} }
@@ -180,7 +249,7 @@ class _FileRow extends StatelessWidget {
} }
class _Row extends StatelessWidget { class _Row extends StatelessWidget {
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false}); const _Row({super.key, required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false, this.selected = false});
final int depth; final int depth;
final VoidCallback onTap; final VoidCallback onTap;
@@ -188,6 +257,10 @@ class _Row extends StatelessWidget {
final Widget? leading; final Widget? leading;
final bool rotateLeading; final bool rotateLeading;
/// True when the keyboard selection cursor is on this row (T-406) — draws a
/// persistent highlight + accent ring, distinct from transient hover.
final bool selected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
@@ -195,7 +268,12 @@ class _Row extends StatelessWidget {
return ClideTappable( return ClideTappable(
onTap: onTap, onTap: onTap,
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
color: hovered ? tokens.sidebarItemHover : null, decoration: selected
? BoxDecoration(
color: tokens.sidebarItemHover,
border: Border.all(color: tokens.globalFocus, width: 1),
)
: (hovered ? BoxDecoration(color: tokens.sidebarItemHover) : null),
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3), padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
child: Row( child: Row(
children: [ children: [
+16 -1
View File
@@ -2,6 +2,8 @@
/// merged health/toggle status-bar widget, and the `dock.toggle` command. /// merged health/toggle status-bar widget, and the `dock.toggle` command.
library; library;
import 'dart:async';
import 'package:clide/builtin/output/src/dock_status_item.dart'; import 'package:clide/builtin/output/src/dock_status_item.dart';
import 'package:clide/builtin/output/src/output_view.dart'; import 'package:clide/builtin/output/src/output_view.dart';
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
@@ -35,7 +37,20 @@ class OutputExtension extends ClideExtension {
slot: Slots.dock, slot: Slots.dock,
title: 'Output', title: 'Output',
priority: -100, // sort before Problems in the dock tab bar priority: -100, // sort before Problems in the dock tab bar
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing), build: (ctx) {
// The Level chip is the live verbosity toggle (T-433): drive the kernel
// logger + persist app.log.level so the choice survives restart and
// matches the `clide log level` CLI (D-6 parity).
final k = ClideKernel.of(ctx);
return OutputView(
ring: k.logRing,
initialLevel: k.log.minLevel,
onMinLevelChanged: (level) {
k.log.minLevel = level;
unawaited(k.settings.set('app.log.level', level.name));
},
);
},
), ),
StatusItemContribution( StatusItemContribution(
id: 'output.dock-toggle', id: 'output.dock-toggle',
+12 -3
View File
@@ -10,15 +10,23 @@ import 'package:clide/kernel/src/log_ring.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class OutputController extends ChangeNotifier { class OutputController extends ChangeNotifier {
OutputController(this.ring) { OutputController(this.ring, {LogLevel? initialLevel, this.onMinLevelChanged}) : minLevel = initialLevel ?? LogLevel.debug {
_sub = ring.changes.listen((_) => notifyListeners()); _sub = ring.changes.listen((_) => notifyListeners());
} }
final LogRing ring; final LogRing ring;
late final StreamSubscription<void> _sub; late final StreamSubscription<void> _sub;
/// Minimum level shown. Defaults to debug (trace is firehose-noise). /// Invoked when the Level chip changes the level — the dock chip is the live
LogLevel minLevel = LogLevel.debug; /// dev/prod verbosity toggle (T-433), not just a view filter. The owner wires
/// this to set the kernel `Logger.minLevel` and persist `app.log.level`. Null
/// in tests / when no kernel is attached, leaving the chip a pure view filter.
final void Function(LogLevel)? onMinLevelChanged;
/// Minimum level shown — initialized from the kernel logger's level so the
/// chip reflects the real verbosity (which a `clide log level` CLI may have
/// already set), defaulting to debug (trace is firehose-noise).
LogLevel minLevel;
/// Source filter; null = all sources. /// Source filter; null = all sources.
String? source; String? source;
@@ -29,6 +37,7 @@ class OutputController extends ChangeNotifier {
void setMinLevel(LogLevel level) { void setMinLevel(LogLevel level) {
if (minLevel == level) return; if (minLevel == level) return;
minLevel = level; minLevel = level;
onMinLevelChanged?.call(level);
notifyListeners(); notifyListeners();
} }
+7 -2
View File
@@ -10,18 +10,23 @@ import 'package:flutter/widgets.dart';
import 'output_controller.dart'; import 'output_controller.dart';
class OutputView extends StatefulWidget { class OutputView extends StatefulWidget {
const OutputView({super.key, required this.ring}); const OutputView({super.key, required this.ring, this.initialLevel, this.onMinLevelChanged});
/// The retained log buffer to render. The view owns a controller over it /// The retained log buffer to render. The view owns a controller over it
/// but never the ring itself (the app owns that). /// but never the ring itself (the app owns that).
final LogRing ring; final LogRing ring;
/// Initial verbosity (the kernel logger's current level) + the sink that
/// applies a chip change to the kernel + persists it (T-433).
final LogLevel? initialLevel;
final void Function(LogLevel)? onMinLevelChanged;
@override @override
State<OutputView> createState() => _OutputViewState(); State<OutputView> createState() => _OutputViewState();
} }
class _OutputViewState extends State<OutputView> { class _OutputViewState extends State<OutputView> {
late final OutputController _c = OutputController(widget.ring); late final OutputController _c = OutputController(widget.ring, initialLevel: widget.initialLevel, onMinLevelChanged: widget.onMinLevelChanged);
final ScrollController _scroll = ScrollController(); final ScrollController _scroll = ScrollController();
/// Follow the tail until the user scrolls up; resumes when they return to /// Follow the tail until the user scrolls up; resumes when they return to
+6 -8
View File
@@ -77,20 +77,18 @@ class _TerminalPaneState extends State<TerminalPane> {
return; return;
} }
final shell = Platform.environment['SHELL'] ?? '/bin/bash'; // Windows has no $SHELL convention and no login-shell flag —
// PowerShell 7 first, classic PowerShell as the always-there
// fallback.
final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash');
final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo'];
// The open workspace, not Directory.current — a desktop launch starts // The open workspace, not Directory.current — a desktop launch starts
// in $HOME and a project switch doesn't move the process CWD (T-381). // in $HOME and a project switch doesn't move the process CWD (T-381).
final cwd = _kernel?.project.current?.path ?? Directory.current.path; final cwd = _kernel?.project.current?.path ?? Directory.current.path;
final response = await ipc.request( final response = await ipc.request(
'pane.spawn', 'pane.spawn',
args: { args: {'argv': argv, 'kind': PaneKind.terminal.wire, 'cwd': cwd, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight},
'argv': [shell, '-l'],
'kind': PaneKind.terminal.wire,
'cwd': cwd,
'cols': _terminal.viewWidth,
'rows': _terminal.viewHeight,
},
); );
if (!mounted) return; if (!mounted) return;
if (!response.ok) { if (!response.ok) {
+2 -2
View File
@@ -9,7 +9,7 @@
/// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal` /// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal`
/// etc. Nothing reaches into this object across the builtin boundary. /// etc. Nothing reaches into this object across the builtin boundary.
/// ///
/// The whole layer is gated by [enabled], which the Vim extension ties to /// The whole layer is gated by `enabled`, which the Vim extension ties to
/// the active preset: under a non-Vim preset the flags are cleared so they /// the active preset: under a non-Vim preset the flags are cleared so they
/// can never affect another preset's bindings. /// can never affect another preset's bindings.
library; library;
@@ -47,7 +47,7 @@ class VimModeService extends ChangeNotifier {
/// Whether the Vim layer is live. False under non-Vim presets. /// Whether the Vim layer is live. False under non-Vim presets.
bool get enabled => _enabled; bool get enabled => _enabled;
/// The active mode. Meaningful only while [enabled]; defaults to /// The active mode. Meaningful only while `enabled`; defaults to
/// [VimMode.normal] and resets to it whenever the layer is enabled. /// [VimMode.normal] and resets to it whenever the layer is enabled.
VimMode get mode => _mode; VimMode get mode => _mode;
+4
View File
@@ -27,6 +27,7 @@ class TabContribution extends ContributionPoint {
required this.title, required this.title,
required this.build, required this.build,
this.icon, this.icon,
this.iconColor,
this.priority = 0, this.priority = 0,
this.fileGlobs = const [], this.fileGlobs = const [],
this.listenable, this.listenable,
@@ -39,6 +40,9 @@ class TabContribution extends ContributionPoint {
final String title; final String title;
final WidgetBuilder build; final WidgetBuilder build;
final Object? icon; final Object? icon;
/// Optional identity tint for the icon-rail glyph (T-418).
final Color? iconColor;
final int priority; final int priority;
final List<String> fileGlobs; final List<String> fileGlobs;
final Listenable? listenable; final Listenable? listenable;
+29
View File
@@ -0,0 +1,29 @@
/// FFI fd-inheritance probe for the testmode harness (T-438 web fence, D-100).
///
/// Desktop-only — [test_app.dart] selects [fd_check_stub.dart] on web so the
/// `dart:ffi` / `package:ffi` / libc imports stay out of the wasm graph.
library;
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'dart:io';
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'src/pty/ffi/libc.dart' as libc;
/// Probe whether `Process.start` inherits socket fds (the macOS question the
/// testmode harness answers). Returns a human-readable result line.
Future<String> fdInheritanceCheck() async {
final sv = pkg_ffi.calloc<ffi.Int32>(2);
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
final parent = sv[0];
final child = sv[1];
pkg_ffi.calloc.free(sv);
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
final stderr = await proc.stderr.transform(utf8.decoder).join();
final exit = await proc.exitCode;
libc.close(parent);
libc.close(child);
return 'exit=$exit stderr=${stderr.trim()}';
}
+4
View File
@@ -0,0 +1,4 @@
/// Web stub (T-438 web fence, D-100): no FFI fd-inheritance probe on web.
library;
Future<String> fdInheritanceCheck() async => 'skipped (no FFI on web)';
+4
View File
@@ -17,6 +17,8 @@ export 'src/events/message_bus.dart';
export 'src/events/types.dart'; export 'src/events/types.dart';
export 'src/ipc/client.dart'; export 'src/ipc/client.dart';
export 'src/log.dart'; export 'src/log.dart';
export 'src/file_log_sink.dart';
export 'src/watchdog.dart';
export 'src/settings.dart'; export 'src/settings.dart';
export 'src/facade.dart'; export 'src/facade.dart';
export 'src/clipboard.dart'; export 'src/clipboard.dart';
@@ -28,9 +30,11 @@ export 'src/keymap/key_chord.dart';
export 'src/keymap/keymap.dart'; export 'src/keymap/keymap.dart';
export 'src/keymap/keymap_service.dart'; export 'src/keymap/keymap_service.dart';
export 'src/keymap/modifier_tap.dart'; export 'src/keymap/modifier_tap.dart';
export 'src/keymap/pane_key_nav.dart';
export 'src/keymap/sequence_matcher.dart'; export 'src/keymap/sequence_matcher.dart';
export 'src/keymap/when_clause.dart'; export 'src/keymap/when_clause.dart';
export 'src/dialog.dart'; export 'src/dialog.dart';
export 'src/ex_line.dart';
export 'src/extensions_manager.dart'; export 'src/extensions_manager.dart';
export 'src/file_open.dart'; export 'src/file_open.dart';
export 'src/files.dart'; export 'src/files.dart';
+32 -25
View File
@@ -14,6 +14,8 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:clide/src/env/shell_env.dart' show expandToolPath;
/// State of the `clide` shell command relative to the running GUI. /// State of the `clide` shell command relative to the running GUI.
enum CliInstallState { enum CliInstallState {
/// No `clide` resolves on PATH. /// No `clide` resolves on PATH.
@@ -127,7 +129,7 @@ class CliInstaller {
'`make build` so the C client ships inside the app bundle.', '`make build` so the C client ships inside the app bundle.',
); );
} }
final dest = '${_normalize(installDir)}/clide'; final dest = '${_normalize(installDir)}/clide${Platform.isWindows ? '.exe' : ''}';
try { try {
Directory(installDir).createSync(recursive: true); Directory(installDir).createSync(recursive: true);
// Delete any existing entry first so a stale symlink (e.g. one into // Delete any existing entry first so a stale symlink (e.g. one into
@@ -180,52 +182,57 @@ class CliInstaller {
bool _dirOnPath(String dir) { bool _dirOnPath(String dir) {
final norm = _normalize(dir); final norm = _normalize(dir);
return _expandedPath().split(':').any((d) => d.isNotEmpty && _normalize(d) == norm); return _expandedPath().split(_pathSep).any((d) => d.isNotEmpty && _normalize(d) == norm);
} }
String _normalize(String p) => p.length > 1 && p.endsWith('/') ? p.substring(0, p.length - 1) : p; /// Trim a trailing slash; on Windows also fold separators and case so
/// `C:\Users\x/.local/bin` and `c:\users\x\.local\bin` compare equal.
String _normalize(String p) {
var s = p;
if (Platform.isWindows) s = s.replaceAll('\\', '/').toLowerCase();
return s.length > 1 && s.endsWith('/') ? s.substring(0, s.length - 1) : s;
}
String? _findOnPath(String name) { String? _findOnPath(String name) {
for (final dir in _expandedPath().split(':')) { for (final dir in _expandedPath().split(_pathSep)) {
if (dir.isEmpty) continue; if (dir.isEmpty) continue;
final f = File('$dir/$name'); if (Platform.isWindows) {
if (f.existsSync()) return f.path; for (final ext in const ['.exe', '.bat', '.cmd', '']) {
final f = File('$dir\\$name$ext');
if (f.existsSync()) return f.path;
}
} else {
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
} }
return null; return null;
} }
String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? ''); static String get _pathSep => Platform.isWindows ? ';' : ':';
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin'; 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
/// pql installers use there.
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? env['USERPROFILE'] ?? ''}/.local/bin';
/// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev /// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev
/// override first, then `<exe-dir>/clide-cli` — where `make build` drops it /// override first, then `<exe-dir>/clide-cli` — where `make build` drops it
/// inside the bundle (next to the GUI runner on Linux, in /// inside the bundle (next to the GUI runner on Linux and Windows, in
/// `Contents/MacOS/` on macOS). /// `Contents/MacOS/` on macOS).
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) { static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
final exeDir = File(resolvedExecutable).parent.path; final exeDir = File(resolvedExecutable).parent.path;
return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, '$exeDir/clide-cli']; return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, if (Platform.isWindows) '$exeDir/clide-cli.exe' else '$exeDir/clide-cli'];
} }
} }
final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos)-(x64|arm64)/clide$'); final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos|windows)-(x64|arm64)/clide(\.exe)?$');
/// True when [path] is a dev-tree C-client build artifact — /// True when [path] is a dev-tree C-client build artifact —
/// `native/<platform>/clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide /// `native/<platform>/clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide
/// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on /// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on
/// PATH; it's a working client but not a packaged production install, so it's /// PATH; it's a working client but not a packaged production install, so it's
/// classified separately (T-256) rather than as a clean install. /// classified separately (T-256) rather than as a clean install.
bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path); bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path.replaceAll('\\', '/'));
/// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps
/// launch with a sparse PATH that omits the usual user/homebrew bins, so on
/// macOS we prepend those (de-duplicated) before scanning. A top-level,
/// platform-parameterized function so both branches are testable off-platform.
String expandedPath(String base, {required bool macOS, String home = ''}) {
if (!macOS) return base;
final extras = <String>[if (home.isNotEmpty) '$home/.local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base;
return [...missing, ...existing].join(':');
}
+183
View File
@@ -0,0 +1,183 @@
/// State + grammar + execution for the Vim ex command-line overlay (T-407).
///
/// `:` (under `vim.normal`) opens a transient one-line prompt that runs a
/// small, fixed table of ex commands (`:w` `:q` `:wq` `:x` `:e <path>` `:N`).
/// It is NOT a vim *mode* — it's an overlay with its own `exline.open` scope
/// flag, dismissed with Esc, exactly the deferral `vim_mode_service.dart`
/// always named. The controller mirrors [QuickOpenController]'s open/close
/// shape so the overlay can reuse the quick-open chrome.
///
/// The command grammar ([parseExCommand]) is a pure switch — no parser, no
/// vimscript. Execution ([exWriteActive] etc.) goes through the editor IPC
/// verbs (the daemon is the source of truth for buffer state), so every ex
/// command is editor-targeted and no-ops when no buffer is active (the
/// 2026-06-13 decision on T-407).
library;
import 'dart:async';
import 'package:clide/clide.dart' show IpcResponse;
import 'package:clide/kernel/src/ipc/client.dart';
import 'package:flutter/foundation.dart';
class ExLineController extends ChangeNotifier {
bool _open = false;
String _input = '';
// Bumped each time an unknown command is rejected so the overlay can flash
// without closing. A monotonic nonce (not a bool) keeps repeated rejections
// individually observable by a listener.
int _invalidNonce = 0;
bool get isOpen => _open;
String get input => _input;
/// Increments whenever a typed command is rejected ([flashInvalid]); the
/// overlay watches it to flash the input and stay open.
int get invalidNonce => _invalidNonce;
void open() {
if (_open) return;
_open = true;
_input = '';
notifyListeners();
}
void close() {
if (!_open) return;
_open = false;
_input = '';
notifyListeners();
}
void setInput(String value) {
if (_input == value) return;
_input = value;
notifyListeners();
}
/// Signal that the submitted command was unknown — the overlay flashes and
/// stays open rather than executing or dismissing.
void flashInvalid() {
_invalidNonce++;
notifyListeners();
}
}
// --- Grammar ---------------------------------------------------------------
/// One parsed ex command. v1 table; anything off it is [ExUnknown].
sealed class ExCommand {
const ExCommand();
}
/// Empty input (`:` then Enter) — dismiss with no action.
class ExNoop extends ExCommand {
const ExNoop();
}
/// `:w` — write (save) the active buffer.
class ExWrite extends ExCommand {
const ExWrite();
}
/// `:q` (and `:q!`) — close the active editor tab.
class ExQuit extends ExCommand {
const ExQuit();
}
/// `:wq` / `:x` (and bang variants) and `ZZ` — save then close the active tab.
class ExWriteQuit extends ExCommand {
const ExWriteQuit();
}
/// `:e <path>` — open quick-open seeded with `<path>` (empty seed allowed).
class ExEdit extends ExCommand {
const ExEdit(this.query);
final String query;
@override
bool operator ==(Object other) => other is ExEdit && other.query == query;
@override
int get hashCode => query.hashCode;
}
/// `:<n>` — jump the active buffer to 1-based line `<n>`.
class ExGoto extends ExCommand {
const ExGoto(this.line);
final int line;
@override
bool operator ==(Object other) => other is ExGoto && other.line == line;
@override
int get hashCode => line.hashCode;
}
/// Anything not on the v1 table — the overlay flashes and stays open.
class ExUnknown extends ExCommand {
const ExUnknown();
}
/// Parse the text typed after `:` into an [ExCommand]. A leading colon is
/// tolerated (in case the user types it). v1 grammar only.
ExCommand parseExCommand(String raw) {
var s = raw.trim();
if (s.startsWith(':')) s = s.substring(1).trim();
if (s.isEmpty) return const ExNoop();
// `:e` / `:e <path>` — everything after the first token seeds quick-open.
if (s == 'e') return const ExEdit('');
if (s.startsWith('e ')) return ExEdit(s.substring(2).trim());
switch (s) {
case 'w':
return const ExWrite();
case 'q' || 'q!':
// No dirty-guard in v1, so `q!` is just `q`.
return const ExQuit();
case 'wq' || 'wq!' || 'x' || 'x!':
return const ExWriteQuit();
}
final line = int.tryParse(s);
if (line != null && line >= 1) return ExGoto(line);
return const ExUnknown();
}
// --- Execution (editor-targeted; no-op when no active buffer) ---------------
/// The active editor buffer id, or null when no buffer is active.
Future<String?> activeEditorBufferId(DaemonClient ipc) async {
final IpcResponse r = await ipc.request('editor.active');
if (!r.ok) return null;
final active = r.data['active'];
return active is Map ? active['id'] as String? : null;
}
/// `:w` — save the active buffer. `editor.save` resolves the active buffer
/// server-side, so a missing buffer is a silent no-op.
Future<void> exWriteActive(DaemonClient ipc) async {
await ipc.request('editor.save');
}
/// `:q` — close the active editor tab. The registry promotes the next buffer
/// (or collapses the split on the last one) — no separate split-close needed.
Future<void> exQuitActive(DaemonClient ipc) async {
final id = await activeEditorBufferId(ipc);
if (id == null) return;
await ipc.request('editor.close', args: {'id': id});
}
/// `:wq` / `:x` / `ZZ` — save the active buffer then close its tab.
Future<void> exWriteQuitActive(DaemonClient ipc) async {
final id = await activeEditorBufferId(ipc);
if (id == null) return;
await ipc.request('editor.save', args: {'id': id});
await ipc.request('editor.close', args: {'id': id});
}
/// `:<n>` — jump the active buffer to 1-based [line]. `editor.goto-line`
/// resolves the active buffer server-side and clamps out-of-range lines.
Future<void> exGotoLineActive(DaemonClient ipc, int line) async {
await ipc.request('editor.goto-line', args: {'line': line});
}
+13 -1
View File
@@ -26,6 +26,7 @@ import 'package:clide/kernel/src/notify.dart';
import 'package:clide/kernel/src/os.dart'; import 'package:clide/kernel/src/os.dart';
import 'package:clide/kernel/src/panels/arrangement.dart'; import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/registry.dart'; import 'package:clide/kernel/src/panels/registry.dart';
import 'package:clide/kernel/src/ex_line.dart';
import 'package:clide/kernel/src/project.dart'; import 'package:clide/kernel/src/project.dart';
import 'package:clide/kernel/src/quick_open.dart'; import 'package:clide/kernel/src/quick_open.dart';
import 'package:clide/kernel/src/reader_nav.dart'; import 'package:clide/kernel/src/reader_nav.dart';
@@ -58,6 +59,7 @@ class KernelServices {
required this.commands, required this.commands,
required this.palette, required this.palette,
required this.quickOpen, required this.quickOpen,
required this.exLine,
required this.recentFiles, required this.recentFiles,
required this.readerNav, required this.readerNav,
required this.keybindings, required this.keybindings,
@@ -97,6 +99,9 @@ class KernelServices {
final CommandRegistry commands; final CommandRegistry commands;
final PaletteController palette; final PaletteController palette;
final QuickOpenController quickOpen; final QuickOpenController quickOpen;
/// Transient Vim ex command-line overlay state (T-407).
final ExLineController exLine;
final RecentFilesService recentFiles; final RecentFilesService recentFiles;
final ReaderNavRegistry readerNav; final ReaderNavRegistry readerNav;
final KeybindingResolver keybindings; final KeybindingResolver keybindings;
@@ -137,9 +142,13 @@ class KernelServices {
Future<void> Function(String path)? onProjectOpen, Future<void> Function(String path)? onProjectOpen,
Future<String?> Function(String path)? onValidateProject, Future<String?> Function(String path)? onValidateProject,
DaemonBus? sharedBus, DaemonBus? sharedBus,
List<LogSink> additionalSinks = const [],
LogLevel? minLogLevel,
}) async { }) async {
final logRing = LogRing(); final logRing = LogRing();
final log = Logger(sinks: [stderrSink, logRing.add]); // additionalSinks lead the chain so a crash-survivable sink (FileLogSink,
// T-425) records the tail before the volatile stderr/ring sinks run.
final log = Logger(minLevel: minLogLevel ?? LogLevel.info, sinks: [...additionalSinks, stderrSink, logRing.add]);
final events = sharedBus ?? DaemonBus(); final events = sharedBus ?? DaemonBus();
final messages = MessageBus(); final messages = MessageBus();
final filterStates = FilterStateCache(messages: messages); final filterStates = FilterStateCache(messages: messages);
@@ -163,6 +172,7 @@ class KernelServices {
final palette = PaletteController(commands); final palette = PaletteController(commands);
final recentFiles = RecentFilesService(); final recentFiles = RecentFilesService();
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths); final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
final exLine = ExLineController();
final readerNav = ReaderNavRegistry(messages); final readerNav = ReaderNavRegistry(messages);
final clipboard = ClideClipboard(); final clipboard = ClideClipboard();
final files = FileServices(events); final files = FileServices(events);
@@ -249,6 +259,7 @@ class KernelServices {
commands: commands, commands: commands,
palette: palette, palette: palette,
quickOpen: quickOpen, quickOpen: quickOpen,
exLine: exLine,
recentFiles: recentFiles, recentFiles: recentFiles,
readerNav: readerNav, readerNav: readerNav,
keybindings: keybindings, keybindings: keybindings,
@@ -282,6 +293,7 @@ class KernelServices {
commands.dispose(); commands.dispose();
palette.dispose(); palette.dispose();
quickOpen.dispose(); quickOpen.dispose();
exLine.dispose();
toast.dispose(); toast.dispose();
recentFiles.dispose(); recentFiles.dispose();
readerNav.dispose(); readerNav.dispose();
+165
View File
@@ -0,0 +1,165 @@
/// Crash-survivable [LogSink] (T-425).
///
/// Every other sink in clide is volatile: [stderrSink] dies with the console,
/// the [LogRing] dies with the process. The Windows freeze that motivated this
/// (a hard power-cycle, no dumps, no logs) left no evidence for exactly that
/// reason. [FileLogSink] is the durable tail: it appends each [LogRecord] as
/// one JSON line to a size-rotated file under a persistent per-platform log
/// dir, and — crucially — fsyncs the records most likely to immediately
/// precede a crash, so the last breadcrumb is on disk before the box dies.
///
/// Design choices that matter for a CRASH logger:
/// - Synchronous I/O only. No async buffering / no IOSink — a hard death
/// between an `await` and its flush would lose the tail, which is the one
/// thing this sink exists to keep.
/// - Tiered flush. `warn`/`error` and records from inherently-risky sources
/// (pty/ffi/conpty/watchdog) `flushSync` immediately. High-volume
/// `info`/`debug` write through to the OS (surviving a process crash) and
/// are fsynced on a low-frequency timer — enough to bound power-loss to a
/// couple of seconds without an fsync per line.
/// - Never throws. A disk-full / permission error must not take logging — or
/// the app — down; every operation swallows its own failure.
///
/// Flutter-free (only `dart:io`/`dart:async`/`dart:convert` + [LogRecord]) so
/// it unit-tests under `dart test` against a temp dir, and so the PTY/FFI
/// layer (also Flutter-free) can route breadcrumbs through it.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'log.dart';
class FileLogSink {
FileLogSink({
required Directory dir,
String baseName = 'clide',
int maxBytes = 5 * 1024 * 1024,
int maxFiles = 5,
Set<String> eagerSources = const {'pty', 'ffi', 'conpty', 'watchdog'},
LogLevel eagerLevel = LogLevel.warn,
Duration flushInterval = const Duration(seconds: 2),
bool startFlushTimer = true,
}) : _dir = dir,
_baseName = baseName,
_maxBytes = maxBytes,
_maxFiles = maxFiles < 1 ? 1 : maxFiles,
_eagerSources = eagerSources,
_eagerLevel = eagerLevel {
_open();
if (startFlushTimer && flushInterval > Duration.zero) {
_timer = Timer.periodic(flushInterval, (_) => _flush());
}
}
final Directory _dir;
final String _baseName;
final int _maxBytes;
final int _maxFiles;
final Set<String> _eagerSources;
final LogLevel _eagerLevel;
RandomAccessFile? _raf;
int _size = 0;
bool _dirty = false;
Timer? _timer;
String get _sep => Platform.pathSeparator;
File get _active => File('${_dir.path}$_sep$_baseName.log');
File _archive(int i) => File('${_dir.path}$_sep$_baseName.$i.log');
/// The active log file's path — handy for the caller to surface (e.g. an
/// "open log folder" affordance) or to add to a CI artifact upload.
String get activePath => _active.path;
void _open() {
try {
_dir.createSync(recursive: true);
final f = _active;
_size = f.existsSync() ? f.lengthSync() : 0;
_raf = f.openSync(mode: FileMode.append);
} catch (_) {
_raf = null; // a disk problem must never kill logging
}
}
/// The [LogSink] entry point: `logger.addSink(fileSink.call)`.
void call(LogRecord r) {
if (_raf == null) return;
try {
final bytes = utf8.encode('${jsonEncode(_encode(r))}\n');
// Rotate BEFORE writing when this line would push the file past the cap,
// so the newest entries always live in the active file (and a single
// oversized line still lands rather than spinning rotations on an empty
// file).
if (_size > 0 && _size + bytes.length > _maxBytes) _rotate();
final raf = _raf;
if (raf == null) return;
raf.writeFromSync(bytes);
_size += bytes.length;
_dirty = true;
if (r.level.index >= _eagerLevel.index || _eagerSources.contains(r.source)) {
raf.flushSync();
_dirty = false;
}
} catch (_) {
// swallow — a logging failure must never propagate to the app
}
}
Map<String, Object?> _encode(LogRecord r) => {
'ts': r.timestamp.toIso8601String(),
'lvl': r.level.name,
'src': r.source,
'msg': r.message,
if (r.error != null) 'err': r.error.toString(),
if (r.stackTrace != null) 'stack': r.stackTrace.toString(),
};
void _flush() {
if (!_dirty) return;
try {
_raf?.flushSync();
_dirty = false;
} catch (_) {}
}
/// Roll `<base>.log` → `<base>.1.log`, shifting older archives up and
/// dropping the oldest past [maxFiles]. With `maxFiles == 1` the active file
/// is simply truncated (no archives kept).
void _rotate() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
try {
if (_maxFiles <= 1) {
if (_active.existsSync()) _active.deleteSync();
} else {
final oldest = _archive(_maxFiles - 1);
if (oldest.existsSync()) oldest.deleteSync();
for (var i = _maxFiles - 2; i >= 1; i--) {
final src = _archive(i);
if (src.existsSync()) src.renameSync(_archive(i + 1).path);
}
if (_active.existsSync()) _active.renameSync(_archive(1).path);
}
} catch (_) {}
_size = 0;
_open();
}
/// Flush + close. Call on orderly shutdown; a crash is covered by the eager
/// fsync above, not by this.
Future<void> close() async {
_timer?.cancel();
_timer = null;
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
+85
View File
@@ -77,6 +77,22 @@ class QuickOpenAcceptIntent extends Intent {
const QuickOpenAcceptIntent(); const QuickOpenAcceptIntent();
} }
// -- Vim ex command-line (T-407) --------------------------------------------
/// Open the Vim ex command-line overlay (`:`). A typed intent (not a
/// `command:` bridge) so it survives the editor's command-mode matcher and a
/// focused pane's nav matcher, both of which bubble unhandled typed intents to
/// the app-root Actions where this resolves to `services.exLine.open()`.
class ExLineOpenIntent extends Intent {
const ExLineOpenIntent();
}
/// Save the active buffer and close its tab (`ZZ`), without opening the
/// overlay — shares the `:wq` execution path.
class ExLineWriteQuitIntent extends Intent {
const ExLineWriteQuitIntent();
}
// -- Find in files ---------------------------------------------------------- // -- Find in files ----------------------------------------------------------
/// Reveal the find-in-files search panel in the sidebar. /// Reveal the find-in-files search panel in the sidebar.
@@ -98,6 +114,63 @@ class TextScaleResetIntent extends Intent {
const TextScaleResetIntent(); const TextScaleResetIntent();
} }
// -- Pane navigation (vim normal-mode motions outside the editor) ------------
/// Base for the preset-neutral navigation intents (T-406). A focused non-editor
/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and
/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds
/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys
/// to the same ids. Marker base so a pane's key handler can tell a nav motion
/// apart from any other fired intent.
sealed class NavIntent extends Intent {
const NavIntent();
}
/// Move the selection / scroll down one step (vim `j`).
class NavDownIntent extends NavIntent {
const NavDownIntent();
}
/// Move the selection / scroll up one step (vim `k`).
class NavUpIntent extends NavIntent {
const NavUpIntent();
}
/// Scroll down half a viewport (vim `ctrl+d`).
class NavPageDownIntent extends NavIntent {
const NavPageDownIntent();
}
/// Scroll up half a viewport (vim `ctrl+u`).
class NavPageUpIntent extends NavIntent {
const NavPageUpIntent();
}
/// Jump to the first item / top (vim `gg`).
class NavTopIntent extends NavIntent {
const NavTopIntent();
}
/// Jump to the last item / bottom (vim `G`).
class NavBottomIntent extends NavIntent {
const NavBottomIntent();
}
/// Expand the focused node, or step into it / move right (vim `l`).
class NavExpandOrRightIntent extends NavIntent {
const NavExpandOrRightIntent();
}
/// Collapse the focused node, or step out of it / move left (vim `h`).
class NavCollapseOrLeftIntent extends NavIntent {
const NavCollapseOrLeftIntent();
}
/// Activate the focused item — open the file, run the row (vim `o` / `enter`).
class NavActivateIntent extends NavIntent {
const NavActivateIntent();
}
// -- Command bridge --------------------------------------------------------- // -- Command bridge ---------------------------------------------------------
/// Generic "invoke this CommandRegistry command id" intent. Used for /// Generic "invoke this CommandRegistry command id" intent. Used for
@@ -136,6 +209,18 @@ final Map<String, Intent Function()> builtinIntents = {
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(), 'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(), 'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(), 'findInFiles.open': () => const FindInFilesIntent(),
'exline.open': () => const ExLineOpenIntent(),
'exline.writeQuit': () => const ExLineWriteQuitIntent(),
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
'nav.down': () => const NavDownIntent(),
'nav.up': () => const NavUpIntent(),
'nav.pageDown': () => const NavPageDownIntent(),
'nav.pageUp': () => const NavPageUpIntent(),
'nav.top': () => const NavTopIntent(),
'nav.bottom': () => const NavBottomIntent(),
'nav.expandOrRight': () => const NavExpandOrRightIntent(),
'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(),
'nav.activate': () => const NavActivateIntent(),
'text.scaleIncrease': () => const TextScaleIncreaseIntent(), 'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
'text.scaleDecrease': () => const TextScaleDecreaseIntent(), 'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
'text.scaleReset': () => const TextScaleResetIntent(), 'text.scaleReset': () => const TextScaleResetIntent(),
+1 -1
View File
@@ -13,7 +13,7 @@
/// ///
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g. /// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
/// `palette.open`, `editor.focused`). Producing services call /// `palette.open`, `editor.focused`). Producing services call
/// [setScopeFlag] when their state changes; consumers reference the /// `setScopeFlag` when their state changes; consumers reference the
/// flag name in when-clauses. /// flag name in when-clauses.
library; library;
+46 -23
View File
@@ -1,10 +1,16 @@
/// Detects a double-tapped bare modifier (e.g. JetBrains "Search /// Detects a double-tapped bare modifier (e.g. JetBrains "Search
/// Everywhere" = double-Shift). (T-341) /// Everywhere" = double-Shift). (T-341)
/// ///
/// Headless and clock-injected: the caller (the global key handler) passes /// A "tap" is a clean press-and-release: no other key may go down while the
/// the event time so it neither reads a clock nor consumes events. Feed it /// modifier is held, otherwise the press was a chord (`Shift+;` typing a
/// every [KeyDownEvent]: a bare modifier press via [tap], any other key via /// colon) and must not count (T-409). The gesture therefore completes on the
/// [reset] (an intervening key breaks the gesture, e.g. `Shift a Shift`). /// second clean *release*, never on a key-down — at down time it's unknowable
/// whether the press will stay bare.
///
/// Headless and clock-injected: the caller (the root shell's raw-keyboard
/// handler) passes the event time so it neither reads a clock nor consumes
/// events. Feed every [KeyDownEvent] to `down` and every [KeyUpEvent] to
/// `up`, passing the event's [KeyModifier] (null for non-modifier keys).
library; library;
import 'key_chord.dart'; import 'key_chord.dart';
@@ -12,33 +18,50 @@ import 'key_chord.dart';
class ModifierTapTracker { class ModifierTapTracker {
ModifierTapTracker({this.window = const Duration(milliseconds: 350)}); ModifierTapTracker({this.window = const Duration(milliseconds: 350)});
/// Max gap between the two taps to count as a double-tap. /// Max gap between the two tap releases to count as a double-tap.
final Duration window; final Duration window;
KeyModifier? _last; /// Modifier currently held whose press is still bare (no chorded key yet).
DateTime? _lastAt; KeyModifier? _pressing;
/// Record a bare-modifier press at [now]. Returns the modifier when this /// Modifier of the last completed clean tap, arming the double-tap.
/// press completes a double-tap of the *same* modifier within [window]; KeyModifier? _armed;
/// otherwise records it as the first tap and returns null. DateTime? _armedAt;
KeyModifier? tap(KeyModifier m, DateTime now) {
final last = _last; /// Record a key press. A non-modifier key ([mod] == null) — or any key
final lastAt = _lastAt; /// landing while a modifier is already held — is a chord: it dirties the
if (last == m && lastAt != null) { /// held press and breaks the armed gesture.
final gap = now.difference(lastAt); void down(KeyModifier? mod) {
if (mod == null || _pressing != null) {
_pressing = null;
_disarm();
return;
}
_pressing = mod;
}
/// Record a key release at [now]. Returns the modifier when this release
/// completes a double-tap: the second clean tap of the *same* modifier
/// within [window] of the first tap's release.
KeyModifier? up(KeyModifier? mod, DateTime now) {
if (mod == null) return null;
final pressing = _pressing;
_pressing = null;
if (pressing != mod) return null; // press went dirty (chorded) or stale
if (_armed == mod && _armedAt != null) {
final gap = now.difference(_armedAt!);
if (gap >= Duration.zero && gap <= window) { if (gap >= Duration.zero && gap <= window) {
reset(); _disarm();
return m; return mod;
} }
} }
_last = m; _armed = mod;
_lastAt = now; _armedAt = now;
return null; return null;
} }
/// Break the gesture — any non-modifier key press resets the tracker. void _disarm() {
void reset() { _armed = null;
_last = null; _armedAt = null;
_lastAt = null;
} }
} }
+136
View File
@@ -0,0 +1,136 @@
/// A reusable vim normal-mode navigation key handler for non-editor panes
/// (T-406).
///
/// The passive global key path is single-chord only and can't run sequences or
/// consume events (D-82), so — exactly like the editor's command-mode handler —
/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a
/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file
/// tree, conversation, and lists share one implementation.
///
/// While a `vim.normal` scope flag is set and this region holds focus, bare and
/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed
/// the matcher against the live keymap; a fired [NavIntent] is handed to
/// `onNav` with its repeat count. Everything else under `vim.normal` is
/// swallowed (vim normal mode is inert for unbound keys), except other-modifier
/// chords (palette, quick-open, …) which bubble to the global handler. Under a
/// non-vim preset or in insert mode the region is transparent — keys pass
/// straight through.
///
/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key
/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav
/// 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';
import '../facade.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'keymap.dart';
import 'sequence_matcher.dart';
/// Signature for a fired navigation motion: the [intent] and its repeat
/// [count] (>= 1, from a leading digit prefix like `5j`).
typedef NavHandler = void Function(NavIntent intent, int count);
class PaneKeyNav extends StatefulWidget {
const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true});
final Widget child;
/// Called when a `nav.*` motion resolves while this region has focus.
final NavHandler onNav;
/// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that
/// want to move focus here programmatically (a row tap, F6) pass their own.
final FocusNode? focusNode;
final bool autofocus;
/// Whether the region can take focus at all. False makes it a pure pass-through
/// (used when a pane temporarily routes keys elsewhere, e.g. a filter box).
final bool canRequestFocus;
@override
State<PaneKeyNav> createState() => _PaneKeyNavState();
}
class _PaneKeyNavState extends State<PaneKeyNav> {
FocusNode? _ownNode;
SequenceMatcher? _matcher;
FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav'));
/// The half-page scroll chords are the only modified chords this handler
/// claims; every other modified chord bubbles to the global shortcut path.
static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD);
static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU);
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_matcher != null) return;
final kernel = ClideKernel.of(context);
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
}
@override
void dispose() {
_ownNode?.dispose();
super.dispose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
final kernel = ClideKernel.of(context);
// Only vim normal mode drives pane navigation. Insert/visual or a non-vim
// preset → transparent, keys pass through to whatever's below.
if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
final chord = KeyChord.fromKeyEvent(event, hw);
if (chord == null) return KeyEventResult.ignored;
// Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only
// modified chords we claim (half-page scroll). Any other modified chord is
// an app shortcut (palette, quick-open) — let it bubble to the global path.
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored;
final r = _matcher!.feed(chord);
switch (r.outcome) {
case SeqOutcome.fired:
// 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;
case SeqOutcome.unmatched:
// Vim normal mode beeps on unbound keys — swallow so a bare key never
// leaks to text input or the global handler.
return KeyEventResult.handled;
}
}
@override
Widget build(BuildContext context) {
return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
/// and := unary ('&&' unary)* /// and := unary ('&&' unary)*
/// unary := '!' unary | atom /// unary := '!' unary | atom
/// atom := IDENT | '(' expr ')' /// atom := IDENT | '(' expr ')'
/// IDENT := [a-zA-Z_][a-zA-Z0-9._-]* /// IDENT := `[a-zA-Z_][a-zA-Z0-9._-]*`
/// ///
/// Identifiers resolve against a `Map<String, bool>` context. A missing /// Identifiers resolve against a `Map<String, bool>` context. A missing
/// identifier evaluates to `false` — bindings can assume any required /// identifier evaluates to `false` — bindings can assume any required
+29
View File
@@ -62,3 +62,32 @@ void stderrSink(LogRecord r) {
stderr.writeln(r); stderr.writeln(r);
if (r.stackTrace != null) stderr.writeln(r.stackTrace); if (r.stackTrace != null) stderr.writeln(r.stackTrace);
} }
/// Parse a level name (case-insensitive, trimmed) to a [LogLevel], or null if
/// it is absent/blank/unknown — so an invalid source falls through to the next
/// one in [resolveLogLevel] rather than crashing the boot.
LogLevel? parseLogLevel(String? name) {
if (name == null) return null;
final n = name.trim().toLowerCase();
if (n.isEmpty) return null;
for (final l in LogLevel.values) {
if (l.name == n) return l;
}
return null;
}
/// Resolve the effective [Logger.minLevel] at boot — the dev/prod verbosity
/// toggle (T-425). Highest precedence first:
///
/// 1. `--dart-define=CLIDE_LOG=<level>` (baked into the build)
/// 2. the `CLIDE_LOG` environment variable
/// 3. the `app.log.level` setting
/// 4. a build-mode default: `warn` in release (a shipped app stays quiet),
/// `info` in debug.
///
/// Each named source is parsed leniently; an unknown name is skipped, not
/// fatal. The build-mode flag is passed in (rather than read here) to keep
/// this Flutter-free — `main.dart` supplies `kReleaseMode`.
LogLevel resolveLogLevel({required bool isRelease, String? dartDefine, String? envVar, String? settingValue}) {
return parseLogLevel(dartDefine) ?? parseLogLevel(envVar) ?? parseLogLevel(settingValue) ?? (isRelease ? LogLevel.warn : LogLevel.info);
}
+3 -3
View File
@@ -2,9 +2,9 @@
/// output dock (T-54 / D-87) reads on open. /// output dock (T-54 / D-87) reads on open.
/// ///
/// The [Logger] only live-broadcasts to its stream; a panel that opens late /// The [Logger] only live-broadcasts to its stream; a panel that opens late
/// would see nothing. [LogRing] is a sink that keeps the last [capacity] /// would see nothing. [LogRing] is a sink that keeps the last `capacity`
/// records (drop-oldest, same shape as the D-85 event ring) plus enough /// records (drop-oldest, same shape as the D-85 event ring) plus enough
/// bookkeeping to drive the panel's filter dropdown (distinct [sources]) and /// bookkeeping to drive the panel's filter dropdown (distinct `sources`) and
/// the status-bar health badge (level counts). /// the status-bar health badge (level counts).
/// ///
/// Flutter-free (only `dart:async`/`dart:collection` + the [LogRecord] type) /// Flutter-free (only `dart:async`/`dart:collection` + the [LogRecord] type)
@@ -42,7 +42,7 @@ class LogRing {
int get length => _records.length; int get length => _records.length;
bool get isEmpty => _records.isEmpty; bool get isEmpty => _records.isEmpty;
/// Append a record (the [Logger] sink). Drops the oldest past [capacity]. /// Append a record (the [Logger] sink). Drops the oldest past `capacity`.
void add(LogRecord r) { void add(LogRecord r) {
_records.addLast(r); _records.addLast(r);
_sourceCounts.update(r.source, (n) => n + 1, ifAbsent: () => 1); _sourceCounts.update(r.source, (n) => n + 1, ifAbsent: () => 1);
@@ -1,7 +1,7 @@
/// Snapshots the kernel's live tabs into [ViewPane]s so `pane list` reflects /// Snapshots the kernel's live tabs into [ViewPane]s so `pane list` reflects
/// the panes the user actually sees in the GUI (T-219, D-6 parity / D-83). /// the panes the user actually sees in the GUI (T-219, D-6 parity / D-83).
/// ///
/// Read-at-request-time: no state is mirrored into the IPC [PaneRegistry], so /// Read-at-request-time: no state is mirrored into the IPC `PaneRegistry`, so
/// nothing can drift from the live UI. Lives in the kernel (not `lib/src/panes/`) /// nothing can drift from the live UI. Lives in the kernel (not `lib/src/panes/`)
/// because it reads Flutter-coupled kernel state; it produces the Flutter-free /// because it reads Flutter-coupled kernel state; it produces the Flutter-free
/// [ViewPane] the pane command serialises. /// [ViewPane] the pane command serialises.
+4 -2
View File
@@ -42,10 +42,12 @@ class QuickOpenController extends ChangeNotifier {
return _selectedIndex.clamp(0, n - 1); return _selectedIndex.clamp(0, n - 1);
} }
void open() { /// Open the picker. An optional [seed] pre-fills the filter — used by the
/// ex-line `:e <path>` command (T-407) to jump straight to a query.
void open({String? seed}) {
if (_open) return; if (_open) return;
_open = true; _open = true;
_filter = ''; _filter = seed ?? '';
_selectedIndex = 0; _selectedIndex = 0;
notifyListeners(); notifyListeners();
} }
+54
View File
@@ -0,0 +1,54 @@
/// Pure syntax-highlight result types + the capture-role→theme-color map
/// (T-438 web fence, D-100). No `dart:ffi`, so it is shared by the FFI-backed
/// [TreeSitterService] impl and its web stub — both expose identical data types.
library;
import 'dart:typed_data';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/theme/tokens.dart';
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
/// Throws on missing or unreadable assets.
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
/// Loads the highlight query (`.scm` source) for [language], or returns
/// null if no query is bundled for it.
typedef GrammarQueryLoader = Future<String?> Function(String language);
class SyntaxSpan {
const SyntaxSpan({required this.start, required this.end, required this.role});
final int start;
final int end;
final String role;
}
class SyntaxResult {
const SyntaxResult(this.spans);
final List<SyntaxSpan> spans;
static const empty = SyntaxResult([]);
}
/// Map a tree-sitter capture [role] to a theme color.
Color syntaxColorForRole(String role, SurfaceTokens tokens) {
return switch (role) {
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
'string' || 'string.special' => tokens.syntaxString,
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
'comment' => tokens.syntaxComment,
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
'property' || 'field' => tokens.syntaxMethod,
'constant' || 'constant.builtin' => tokens.syntaxNumber,
'tag' || 'attribute' => tokens.syntaxKeyword,
'namespace' || 'module' => tokens.syntaxType,
'text.title' => tokens.syntaxKeyword,
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
'text.emphasis' || 'text.strong' => tokens.syntaxType,
_ => tokens.globalForeground,
};
}
@@ -0,0 +1,8 @@
/// Desktop tree-sitter bootstrap (T-438 web fence, D-100): dlopen the vendored
/// libtree-sitter once at startup. The web build uses [tree_sitter_boot_stub.dart].
library;
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
/// Initialize the tree-sitter library; returns false if it can't be loaded.
bool initTreeSitter() => TreeSitterLib.init();
@@ -0,0 +1,4 @@
/// Web stub (T-438 web fence, D-100): no tree-sitter FFI to initialize.
library;
bool initTreeSitter() => false;
+5 -301
View File
@@ -1,303 +1,7 @@
/// Platform facade for the tree-sitter highlighter (T-438 web fence, D-100):
/// the FFI-backed [TreeSitterService] on desktop, a no-op stub on web. Both
/// re-export the shared [SyntaxSpan]/[SyntaxResult] types and `colorForRole`,
/// so consumers import this file unchanged.
library; library;
import 'dart:convert' show utf8; export 'tree_sitter_service_stub.dart' if (dart.library.ffi) 'tree_sitter_service_ffi.dart';
import 'dart:ffi';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
/// Throws on missing or unreadable assets.
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
/// Loads the highlight query (`.scm` source) for [language], or returns
/// null if no query is bundled for it.
typedef GrammarQueryLoader = Future<String?> Function(String language);
class SyntaxSpan {
const SyntaxSpan({required this.start, required this.end, required this.role});
final int start;
final int end;
final String role;
}
class SyntaxResult {
const SyntaxResult(this.spans);
final List<SyntaxSpan> spans;
static const empty = SyntaxResult([]);
}
class _LoadedGrammar {
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
final Pointer<Void> language;
final Pointer<TSQuery> query;
final List<String> captureNames;
}
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
: _injectedLib = lib,
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
final TreeSitterLib? _injectedLib;
final GrammarBytesLoader _grammarBytes;
final GrammarQueryLoader _grammarQuery;
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
static Future<Uint8List> _defaultGrammarBytes(String language) async {
final data = await rootBundle.load('assets/grammars/$language.wasm');
return data.buffer.asUint8List();
}
static Future<String?> _defaultGrammarQuery(String language) async {
try {
return await rootBundle.loadString('assets/queries/$language.scm');
} catch (_) {
return null;
}
}
final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {};
Pointer<TSWasmStore>? _store;
Pointer<TSParser>? _parser;
Pointer<TSQueryCursor>? _cursor;
bool _initDone = false;
bool _init() {
if (_initDone) return _parser != null;
_initDone = true;
final lib = _lib;
if (lib == null) return false;
final engine = lib.wasmEngineNew();
if (engine == nullptr) return false;
final error = calloc<TSWasmError>();
_store = lib.wasmStoreNew(engine, error);
lib.wasmEngineDelete(engine);
if (_store == null || _store == nullptr) {
calloc.free(error);
return false;
}
calloc.free(error);
_parser = lib.parserNew();
if (_parser == null || _parser == nullptr) return false;
lib.parserSetWasmStore(_parser!, _store!);
_cursor = lib.queryCursorNew();
return true;
}
Future<_LoadedGrammar?> _loadGrammar(String language) async {
if (_unavailable.contains(language)) return null;
final cached = _grammars[language];
if (cached != null) return cached;
if (!_init()) {
_unavailable.add(language);
return null;
}
final lib = _lib!;
try {
// Load grammar WASM bytes.
final wasmBytes = await _grammarBytes(language);
// Load into WASM store.
final nameNative = language.toNativeUtf8();
final wasmNative = calloc<Uint8>(wasmBytes.length);
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
final error = calloc<TSWasmError>();
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
calloc.free(wasmNative);
calloc.free(nameNative);
if (lang == nullptr) {
final msg = error.ref.message;
if (msg != nullptr) calloc.free(msg);
calloc.free(error);
_unavailable.add(language);
return null;
}
calloc.free(error);
// Load highlight query.
final querySource = await _grammarQuery(language);
Pointer<TSQuery> query = nullptr;
List<String> captureNames = [];
if (querySource != null) {
final queryNative = querySource.toNativeUtf8();
final queryLen = utf8.encode(querySource).length;
final errorOffset = calloc<Uint32>();
final errorType = calloc<Int32>();
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
calloc.free(queryNative);
calloc.free(errorOffset);
calloc.free(errorType);
if (query != nullptr) {
final count = lib.queryCaptureCount(query);
final lenOut = calloc<Uint32>();
for (var i = 0; i < count; i++) {
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
final len = lenOut.value;
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
}
calloc.free(lenOut);
}
}
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
_grammars[language] = grammar;
return grammar;
} catch (_) {
_unavailable.add(language);
return null;
}
}
Future<bool> hasGrammar(String path) async {
final lang = grammarForPath(path);
if (lang == null) return false;
return (await _loadGrammar(lang)) != null;
}
Future<String?> languageFor(String path) async {
final lang = grammarForPath(path);
if (lang == null) return null;
return (await _loadGrammar(lang)) != null ? lang : null;
}
List<String> get loadedLanguages => _grammars.keys.toList();
Future<SyntaxResult> highlight(String path, String source) async {
final lang = grammarForPath(path);
if (lang == null) return SyntaxResult.empty;
final grammar = await _loadGrammar(lang);
if (grammar == null || grammar.query == nullptr) {
return SyntaxResult.empty;
}
final lib = _lib!;
final parser = _parser!;
final cursor = _cursor!;
// Set language on parser for this parse.
lib.parserSetLanguage(parser, grammar.language);
// Parse source.
final sourceNative = source.toNativeUtf8();
final sourceLen = utf8.encode(source).length;
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
if (tree == nullptr) {
calloc.free(sourceNative);
return SyntaxResult.empty;
}
final root = lib.treeRootNode(tree);
// Run highlight query.
lib.queryCursorExec(cursor, grammar.query, root);
final match = calloc<TSQueryMatch>();
final spans = <SyntaxSpan>[];
while (lib.queryCursorNextMatch(cursor, match)) {
final m = match.ref;
for (var i = 0; i < m.captureCount; i++) {
final cap = m.captures[i];
final captureIndex = cap.index;
if (captureIndex < grammar.captureNames.length) {
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
}
}
}
calloc.free(match);
lib.treeDelete(tree);
calloc.free(sourceNative);
return SyntaxResult(spans);
}
void dispose() {
final lib = _lib;
if (lib == null) return;
for (final grammar in _grammars.values) {
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
}
_grammars.clear();
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
// Parser and WASM store are cleaned up together — deleting the parser
// does not delete the store, but the store owns the languages.
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
_parser = null;
_store = null;
_cursor = null;
_unavailable.clear();
}
/// Resets the service to a pre-init state. Tests use this to re-exercise
/// `_init()` without constructing a new singleton; production code never
/// needs it.
@visibleForTesting
void resetForTests() {
dispose();
_initDone = false;
}
static Color colorForRole(String role, SurfaceTokens tokens) {
return switch (role) {
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
'string' || 'string.special' => tokens.syntaxString,
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
'comment' => tokens.syntaxComment,
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
'property' || 'field' => tokens.syntaxMethod,
'constant' || 'constant.builtin' => tokens.syntaxNumber,
'tag' || 'attribute' => tokens.syntaxKeyword,
'namespace' || 'module' => tokens.syntaxType,
'text.title' => tokens.syntaxKeyword,
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
'text.emphasis' || 'text.strong' => tokens.syntaxType,
_ => tokens.globalForeground,
};
}
}
@@ -0,0 +1,269 @@
/// FFI-backed tree-sitter highlighter (T-438 web fence, D-100). Selected by the
/// [tree_sitter_service.dart] facade when `dart.library.ffi` is available; the
/// web build gets [tree_sitter_service_stub.dart] instead. Pure result types
/// live in [syntax_result.dart] (re-exported so consumers import only the
/// facade).
library;
import 'dart:convert' show utf8;
import 'dart:ffi';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/syntax/syntax_result.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
export 'package:clide/kernel/src/syntax/syntax_result.dart';
class _LoadedGrammar {
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
final Pointer<Void> language;
final Pointer<TSQuery> query;
final List<String> captureNames;
}
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
: _injectedLib = lib,
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
final TreeSitterLib? _injectedLib;
final GrammarBytesLoader _grammarBytes;
final GrammarQueryLoader _grammarQuery;
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
static Future<Uint8List> _defaultGrammarBytes(String language) async {
final data = await rootBundle.load('assets/grammars/$language.wasm');
return data.buffer.asUint8List();
}
static Future<String?> _defaultGrammarQuery(String language) async {
try {
return await rootBundle.loadString('assets/queries/$language.scm');
} catch (_) {
return null;
}
}
final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {};
Pointer<TSWasmStore>? _store;
Pointer<TSParser>? _parser;
Pointer<TSQueryCursor>? _cursor;
bool _initDone = false;
bool _init() {
if (_initDone) return _parser != null;
_initDone = true;
final lib = _lib;
if (lib == null) return false;
final engine = lib.wasmEngineNew();
if (engine == nullptr) return false;
final error = calloc<TSWasmError>();
_store = lib.wasmStoreNew(engine, error);
lib.wasmEngineDelete(engine);
if (_store == null || _store == nullptr) {
calloc.free(error);
return false;
}
calloc.free(error);
_parser = lib.parserNew();
if (_parser == null || _parser == nullptr) return false;
lib.parserSetWasmStore(_parser!, _store!);
_cursor = lib.queryCursorNew();
return true;
}
Future<_LoadedGrammar?> _loadGrammar(String language) async {
if (_unavailable.contains(language)) return null;
final cached = _grammars[language];
if (cached != null) return cached;
if (!_init()) {
_unavailable.add(language);
return null;
}
final lib = _lib!;
try {
// Load grammar WASM bytes.
final wasmBytes = await _grammarBytes(language);
// Load into WASM store.
final nameNative = language.toNativeUtf8();
final wasmNative = calloc<Uint8>(wasmBytes.length);
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
final error = calloc<TSWasmError>();
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
calloc.free(wasmNative);
calloc.free(nameNative);
if (lang == nullptr) {
final msg = error.ref.message;
if (msg != nullptr) calloc.free(msg);
calloc.free(error);
_unavailable.add(language);
return null;
}
calloc.free(error);
// Load highlight query.
final querySource = await _grammarQuery(language);
Pointer<TSQuery> query = nullptr;
List<String> captureNames = [];
if (querySource != null) {
final queryNative = querySource.toNativeUtf8();
final queryLen = utf8.encode(querySource).length;
final errorOffset = calloc<Uint32>();
final errorType = calloc<Int32>();
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
calloc.free(queryNative);
calloc.free(errorOffset);
calloc.free(errorType);
if (query != nullptr) {
final count = lib.queryCaptureCount(query);
final lenOut = calloc<Uint32>();
for (var i = 0; i < count; i++) {
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
final len = lenOut.value;
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
}
calloc.free(lenOut);
}
}
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
_grammars[language] = grammar;
return grammar;
} catch (_) {
_unavailable.add(language);
return null;
}
}
Future<bool> hasGrammar(String path) async {
final lang = grammarForPath(path);
if (lang == null) return false;
return (await _loadGrammar(lang)) != null;
}
Future<String?> languageFor(String path) async {
final lang = grammarForPath(path);
if (lang == null) return null;
return (await _loadGrammar(lang)) != null ? lang : null;
}
List<String> get loadedLanguages => _grammars.keys.toList();
Future<SyntaxResult> highlight(String path, String source) async {
final lang = grammarForPath(path);
if (lang == null) return SyntaxResult.empty;
final grammar = await _loadGrammar(lang);
if (grammar == null || grammar.query == nullptr) {
return SyntaxResult.empty;
}
final lib = _lib!;
final parser = _parser!;
final cursor = _cursor!;
// Set language on parser for this parse.
lib.parserSetLanguage(parser, grammar.language);
// Parse source.
final sourceNative = source.toNativeUtf8();
final sourceLen = utf8.encode(source).length;
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
if (tree == nullptr) {
calloc.free(sourceNative);
return SyntaxResult.empty;
}
final root = lib.treeRootNode(tree);
// Run highlight query.
lib.queryCursorExec(cursor, grammar.query, root);
final match = calloc<TSQueryMatch>();
final spans = <SyntaxSpan>[];
while (lib.queryCursorNextMatch(cursor, match)) {
final m = match.ref;
for (var i = 0; i < m.captureCount; i++) {
final cap = m.captures[i];
final captureIndex = cap.index;
if (captureIndex < grammar.captureNames.length) {
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
}
}
}
calloc.free(match);
lib.treeDelete(tree);
calloc.free(sourceNative);
return SyntaxResult(spans);
}
void dispose() {
final lib = _lib;
if (lib == null) return;
for (final grammar in _grammars.values) {
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
}
_grammars.clear();
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
// Parser and WASM store are cleaned up together — deleting the parser
// does not delete the store, but the store owns the languages.
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
_parser = null;
_store = null;
_cursor = null;
_unavailable.clear();
}
/// Resets the service to a pre-init state. Tests use this to re-exercise
/// `_init()` without constructing a new singleton; production code never
/// needs it.
@visibleForTesting
void resetForTests() {
dispose();
_initDone = false;
}
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
}
@@ -0,0 +1,27 @@
/// Web stub for [TreeSitterService] (T-438 web fence, D-100): no tree-sitter
/// FFI on web, so highlighting is a no-op — every query returns no spans and
/// the editor / code block render plain text. Mirrors the FFI impl's public
/// API (and re-exports the shared result types) so the facade is transparent.
library;
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/syntax_result.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
export 'package:clide/kernel/src/syntax/syntax_result.dart';
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
TreeSitterService();
Future<bool> hasGrammar(String path) async => false;
Future<String?> languageFor(String path) async => null;
List<String> get loadedLanguages => const [];
Future<SyntaxResult> highlight(String path, String source) async => SyntaxResult.empty;
void dispose() {}
void resetForTests() {}
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
}
+3 -6
View File
@@ -10,6 +10,7 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -20,7 +21,6 @@ export 'toolchain_paths.dart';
class Toolchain extends ChangeNotifier implements ToolchainView { class Toolchain extends ChangeNotifier implements ToolchainView {
String? _git; String? _git;
String? _pql; String? _pql;
String? _tmux;
String? _shell; String? _shell;
Map<String, String>? _gitEnv; Map<String, String>? _gitEnv;
bool _resolved = false; bool _resolved = false;
@@ -30,9 +30,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
@override @override
String get pql => _pql ?? 'pql'; String get pql => _pql ?? 'pql';
@override @override
String get tmux => _tmux ?? 'tmux'; String get shell => _shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
@override
String get shell => _shell ?? '/bin/bash';
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite). /// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
@override @override
@@ -44,7 +42,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
bool get allOk => _resolved && missing.isEmpty; bool get allOk => _resolved && missing.isEmpty;
@override @override
List<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql', if (_tmux == null) 'tmux']; List<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql'];
/// Returns a Future that completes when resolution finishes. /// Returns a Future that completes when resolution finishes.
Future<void> waitForResolution() { Future<void> waitForResolution() {
@@ -65,7 +63,6 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
void applyResolved(ResolvedPaths p) { void applyResolved(ResolvedPaths p) {
_git = p.git; _git = p.git;
_pql = p.pql; _pql = p.pql;
_tmux = p.tmux;
_shell = p.shell; _shell = p.shell;
_gitEnv = p.gitEnv; _gitEnv = p.gitEnv;
_resolved = true; _resolved = true;
+42 -39
View File
@@ -10,13 +10,19 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:clide/src/env/shell_env.dart';
// The canonical PATH-expansion logic now lives in shell_env (T-439, the single
// source of truth shared with git/pql/PTY/claude). Re-exported so existing
// importers/tests keep resolving it from here.
export 'package:clide/src/env/shell_env.dart' show expandToolPath;
/// Serializable result of tool resolution (crosses isolate boundary). /// Serializable result of tool resolution (crosses isolate boundary).
class ResolvedPaths { class ResolvedPaths {
const ResolvedPaths({this.git, this.pql, this.tmux, this.shell, this.gitEnv}); const ResolvedPaths({this.git, this.pql, this.shell, this.gitEnv});
final String? git; final String? git;
final String? pql; final String? pql;
final String? tmux;
final String? shell; final String? shell;
final Map<String, String>? gitEnv; final Map<String, String>? gitEnv;
} }
@@ -32,7 +38,6 @@ abstract class ToolchainView {
String get git; String get git;
String get pql; String get pql;
String get tmux;
String get shell; String get shell;
Map<String, String>? get gitEnv; Map<String, String>? get gitEnv;
bool get resolved; bool get resolved;
@@ -50,9 +55,7 @@ class _StaticToolchain implements ToolchainView {
@override @override
String get pql => _paths.pql ?? 'pql'; String get pql => _paths.pql ?? 'pql';
@override @override
String get tmux => _paths.tmux ?? 'tmux'; String get shell => _paths.shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
@override
String get shell => _paths.shell ?? '/bin/bash';
@override @override
Map<String, String>? get gitEnv => _paths.gitEnv; Map<String, String>? get gitEnv => _paths.gitEnv;
@override @override
@@ -60,7 +63,7 @@ class _StaticToolchain implements ToolchainView {
@override @override
bool get allOk => missing.isEmpty; bool get allOk => missing.isEmpty;
@override @override
List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql', if (_paths.tmux == null) 'tmux']; List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql'];
} }
/// Top-level function for compute/isolate use. Returns a plain-data /// Top-level function for compute/isolate use. Returns a plain-data
@@ -83,13 +86,17 @@ ResolvedPaths resolveToolchainPaths() {
git = _findOnPath('git'); git = _findOnPath('git');
} }
return ResolvedPaths( return ResolvedPaths(git: git, pql: _findOnPath('pql'), shell: _resolveShell(), gitEnv: gitEnv);
git: git, }
pql: _findOnPath('pql'),
tmux: _findOnPath('tmux'), /// The user's interactive shell. POSIX honours `$SHELL`; Windows has
shell: _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'), /// no such convention — prefer PowerShell 7 (`pwsh`), fall back to
gitEnv: gitEnv, /// Windows PowerShell (present on every supported Windows).
); String? _resolveShell() {
if (Platform.isWindows) {
return _findOnPath('pwsh') ?? _findOnPath('powershell');
}
return _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
} }
/// Locate the dugite-bundled git binary in trusted install locations /// Locate the dugite-bundled git binary in trusted install locations
@@ -104,6 +111,10 @@ ResolvedPaths resolveToolchainPaths() {
/// ///
/// Returns null if no dugite is found; caller falls back to PATH git. /// Returns null if no dugite is found; caller falls back to PATH git.
String? _resolveDugiteGit() { String? _resolveDugiteGit() {
// dugite-native's Windows layout differs (cmd\git.exe, mingw64
// libexec) and isn't wired up yet — PATH git serves Windows until
// the bundle work lands.
if (Platform.isWindows) return null;
final candidates = <String>[]; final candidates = <String>[];
final envDir = Platform.environment['CLIDE_DUGITE_DIR']; final envDir = Platform.environment['CLIDE_DUGITE_DIR'];
if (envDir != null && envDir.isNotEmpty) { if (envDir != null && envDir.isNotEmpty) {
@@ -116,10 +127,19 @@ String? _resolveDugiteGit() {
} }
String? _findOnPath(String name) { String? _findOnPath(String name) {
for (final dir in _expandedPath().split(':')) { final sep = Platform.isWindows ? ';' : ':';
for (final dir in _expandedPath().split(sep)) {
if (dir.isEmpty) continue; if (dir.isEmpty) continue;
final f = File('$dir/$name'); if (Platform.isWindows) {
if (f.existsSync()) return f.path; // PATHEXT-style probe — a bare `pql` on PATH is really pql.exe.
for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) {
final f = File('$dir\\$name$ext');
if (f.existsSync()) return f.path;
}
} else {
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
} }
return null; return null;
} }
@@ -131,25 +151,8 @@ String? _firstExisting(List<String> candidates) {
return null; return null;
} }
/// Build expanded PATH inline — must be self-contained for isolate use. /// The full tool search PATH — the login-shell PATH (probed once at startup)
String _expandedPath() => /// unioned with the well-known user/local bin dirs, shared with every other
expandToolPath(Platform.environment['PATH'] ?? '', isMac: Platform.isMacOS, isLinux: Platform.isLinux, home: Platform.environment['HOME']); /// spawn site via [shell_env] (T-439). In an isolate that never primed the
/// probe it degrades to the process PATH + the well-known dirs (T-347).
/// Pure PATH-expansion logic, extracted so it's testable without touching the String _expandedPath() => resolvedToolPath();
/// process environment.
///
/// A desktop-launched app (macOS or Linux) inherits a minimal PATH that lacks
/// the user bin dirs where tools like `pql` install (`~/.local/bin`), so tool
/// resolution fails even though a terminal launch would find them. Re-add the
/// common user/local bin dirs — that any are missing means they're prepended,
/// so they take precedence over a stale system copy (T-347). Homebrew dirs are
/// macOS-only. On other platforms the base PATH passes through unchanged.
String expandToolPath(String base, {required bool isMac, required bool isLinux, String? home}) {
if (!isMac && !isLinux) return base;
final h = home ?? '';
final extras = <String>[if (h.isNotEmpty) '$h/.local/bin', if (isMac) '/opt/homebrew/bin', if (isMac) '/opt/homebrew/sbin', '/usr/local/bin'];
final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base;
return [...missing, ...existing].join(':');
}
+203
View File
@@ -0,0 +1,203 @@
/// Crash-diagnostic watchdog (T-435, under the T-425 observability epic).
///
/// The Windows freeze leaves no evidence partly because it is a *whole-process*
/// stall: a main-isolate `Timer` heartbeat would freeze WITH the main isolate
/// and tell us nothing. So the watchdog runs in a DEDICATED isolate that:
///
/// - appends + fsyncs a heartbeat every ~500ms, so the last heartbeat on disk
/// bounds a freeze to ~500ms ("it was alive at T, dead by T+0.5s"); and
/// - every ~2s samples this process's resource counts — threads, open
/// handles/fds, child/ConPTY-host processes, RSS — and appends + fsyncs
/// them. A monotonically climbing child/thread/handle count is the leak
/// signature the soak couldn't reproduce on CI but a real freeze would show.
///
/// Output is JSON-lines in `logDirectory()/clide-watchdog.log`, matching
/// [FileLogSink] so it greps/parses the same way, bounded by the same
/// truncate-on-cap scheme as `IsolateCrumbFile`. Everything is synchronous and
/// swallows its own errors — the watchdog must never add a second hang or take
/// the app down.
///
/// Flutter-free (dart:io / dart:isolate / dart:convert + a thin FFI sampler on
/// Windows) so the entry point is `Isolate.spawn`-able and it unit-tests under
/// `dart test`.
library;
import 'dart:convert';
import 'dart:io';
// 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.
class ResourceSample {
const ResourceSample({this.threads = -1, this.handles = -1, this.children = -1, this.rssBytes = -1});
/// Live OS thread count (culprit #2: blocked-FFI isolate threads piling up).
final int threads;
/// Open handle count (Windows) / open fd count (POSIX).
final int handles;
/// Child / ConPTY-host process count (the orphan-accumulation leak signature).
final int children;
/// Resident set size in bytes.
final int rssBytes;
Map<String, Object?> toJson() => {
if (threads >= 0) 'threads': threads,
if (handles >= 0) 'handles': handles,
if (children >= 0) 'children': children,
if (rssBytes >= 0) 'rssMB': (rssBytes / (1024 * 1024)).round(),
};
}
/// Samples the CURRENT process's resource counts. Cheap, synchronous, never
/// throws (returns `-1` fields on failure).
abstract class ResourceSampler {
ResourceSample sample();
/// The backend for the running OS — `/proc` on POSIX, a thin Win32 FFI
/// snapshot on Windows.
static ResourceSampler forPlatform() => Platform.isWindows ? WindowsResourceSampler() : PosixResourceSampler();
}
/// POSIX sampler — reads `/proc/self`. Runs (and is tested) on the Linux CI box.
class PosixResourceSampler implements ResourceSampler {
@override
ResourceSample sample() => ResourceSample(threads: _threads(), handles: _fdCount(), children: _childCount(), rssBytes: _rss());
int _threads() {
try {
for (final line in File('/proc/self/status').readAsLinesSync()) {
if (line.startsWith('Threads:')) return int.parse(line.split(RegExp(r'\s+'))[1]);
}
} catch (_) {}
return -1;
}
int _fdCount() {
try {
return Directory('/proc/self/fd').listSync().length;
} catch (_) {
return -1;
}
}
int _childCount() {
// Sum each thread's direct-children list (`/proc/<pid>/task/<tid>/children`,
// Linux 5.3+). Best-effort: absent file / old kernel → 0 from that thread.
try {
var n = 0;
for (final task in Directory('/proc/self/task').listSync()) {
final f = File('${task.path}/children');
if (!f.existsSync()) continue;
final s = f.readAsStringSync().trim();
if (s.isNotEmpty) n += s.split(RegExp(r'\s+')).length;
}
return n;
} catch (_) {
return -1;
}
}
int _rss() {
try {
return ProcessInfo.currentRss;
} catch (_) {
return -1;
}
}
}
/// The watchdog's on-disk writer: bounded, synchronously-fsynced JSON-lines
/// (heartbeats + samples). Separated from the loop so it unit-tests in
/// isolation. Mirrors `IsolateCrumbFile`'s truncate-on-cap bound.
class WatchdogFile {
WatchdogFile(String? path, {int capBytes = 256 * 1024}) : _capBytes = capBytes {
if (path == null) return;
try {
final f = File(path);
f.parent.createSync(recursive: true);
final raf = f.openSync(mode: FileMode.append);
_raf = raf;
_size = raf.lengthSync();
} catch (_) {
_raf = null;
}
}
final int _capBytes;
RandomAccessFile? _raf;
int _size = 0;
bool get enabled => _raf != null;
void heartbeat() => _write({'ts': _now(), 'evt': 'hb'});
void sample(ResourceSample s) => _write({'ts': _now(), 'evt': 'sample', 'pid': pid, ...s.toJson()});
String _now() => DateTime.now().toUtc().toIso8601String();
void _write(Map<String, Object?> json) {
final raf = _raf;
if (raf == null) return;
try {
if (_size >= _capBytes) {
raf.truncateSync(0);
raf.setPositionSync(0);
_size = 0;
}
final bytes = utf8.encode('${jsonEncode(json)}\n');
raf.writeFromSync(bytes);
raf.flushSync();
_size += bytes.length;
} catch (_) {}
}
void close() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
/// The watchdog loop. Extracted from [watchdogEntry] so a test can bound it
/// with [maxTicks]; production passes null and the loop runs until the isolate
/// is killed at shutdown. Heartbeats fire every [hbIntervalMs], samples every
/// [sampleIntervalMs]; a short sleep between keeps the cadence without spinning.
void runWatchdog(WatchdogFile file, ResourceSampler sampler, {required int hbIntervalMs, required int sampleIntervalMs, int? maxTicks}) {
if (!file.enabled) return;
final sw = Stopwatch()..start();
// Seed both "last" markers a full interval in the past so the first tick
// emits an immediate heartbeat + sample (a baseline at startup).
var lastHb = -hbIntervalMs;
var lastSample = -sampleIntervalMs;
var ticks = 0;
while (maxTicks == null || ticks < maxTicks) {
final e = sw.elapsedMilliseconds;
if (e - lastHb >= hbIntervalMs) {
file.heartbeat();
lastHb = e;
}
if (e - lastSample >= sampleIntervalMs) {
file.sample(sampler.sample());
lastSample = e;
}
ticks++;
if (maxTicks != null && ticks >= maxTicks) break;
sleep(const Duration(milliseconds: 25));
}
file.close();
}
/// Top-level entry for `Isolate.spawn`. Args are a sendable tuple — the log
/// path (not a Logger; isolates can't share one) and the two intervals in ms.
void watchdogEntry((String, int, int) msg) {
final (logPath, hbMs, sampleMs) = msg;
runWatchdog(WatchdogFile(logPath), ResourceSampler.forPlatform(), hbIntervalMs: hbMs, sampleIntervalMs: sampleMs);
}
+141
View File
@@ -0,0 +1,141 @@
// coverage:ignore-file
//
// Windows-only resource sampler for the watchdog (T-435). All of it is Win32
// FFI through kernel32/psapi, so it cannot execute on the Linux CI runner that
// produces the coverage report (PosixResourceSampler is used there). It is
// validated only when the app actually runs on Windows — which is acceptable
// because it is a DIAGNOSTIC that reads, never mutates, and is exhaustively
// defensive: every probe is wrapped so any failure yields a `-1` field rather
// than an exception, and the toolhelp snapshot handle is always closed. A
// missing sample is fine; a sampler that throws or leaks would not be.
library;
import 'dart:ffi' as ffi;
import 'dart:io' show ProcessInfo;
import 'package:ffi/ffi.dart';
import 'watchdog.dart';
const int _kTh32csSnapprocess = 0x00000002;
final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll');
final ffi.DynamicLibrary _psapi = ffi.DynamicLibrary.open('psapi.dll');
final _getCurrentProcess = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(), ffi.Pointer<ffi.Void> Function()>('GetCurrentProcess');
final _getCurrentProcessId = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetCurrentProcessId');
final _createToolhelp32Snapshot = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(ffi.Uint32, ffi.Uint32), ffi.Pointer<ffi.Void> Function(int, int)>(
'CreateToolhelp32Snapshot',
);
final _process32First = _k32
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
'Process32First',
);
final _process32Next = _k32
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
'Process32Next',
);
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('CloseHandle');
final _getProcessHandleCount = _psapi
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>)>(
'GetProcessHandleCount',
);
/// Win32 `PROCESSENTRY32` (ANSI). szExeFile is `CHAR[MAX_PATH]`.
final class _ProcessEntry32 extends ffi.Struct {
@ffi.Uint32()
external int dwSize;
@ffi.Uint32()
external int cntUsage;
@ffi.Uint32()
external int th32ProcessID;
@ffi.IntPtr()
external int th32DefaultHeapID;
@ffi.Uint32()
external int th32ModuleID;
@ffi.Uint32()
external int cntThreads;
@ffi.Uint32()
external int th32ParentProcessID;
@ffi.Int32()
external int pcPriClassBase;
@ffi.Uint32()
external int dwFlags;
@ffi.Array<ffi.Uint8>(260)
external ffi.Array<ffi.Uint8> szExeFile;
}
/// Samples the current process via a single toolhelp snapshot (thread count +
/// ConPTY-host children) plus GetProcessHandleCount and ProcessInfo.currentRss.
class WindowsResourceSampler implements ResourceSampler {
@override
ResourceSample sample() {
final (threads, children) = _snapshotThreadsAndHosts();
return ResourceSample(threads: threads, children: children, handles: _handleCount(), rssBytes: _rss());
}
/// One toolhelp snapshot → (this process's thread count, count of its direct
/// conhost/OpenConsole children). Both `-1`/unavailable on any failure.
(int, int) _snapshotThreadsAndHosts() {
var threads = -1;
var conhosts = 0;
var sawAny = false;
ffi.Pointer<ffi.Void>? snap;
final entry = calloc<_ProcessEntry32>();
try {
final myPid = _getCurrentProcessId();
snap = _createToolhelp32Snapshot(_kTh32csSnapprocess, 0);
entry.ref.dwSize = ffi.sizeOf<_ProcessEntry32>();
var ok = _process32First(snap, entry);
while (ok != 0) {
sawAny = true;
if (entry.ref.th32ProcessID == myPid) threads = entry.ref.cntThreads;
if (entry.ref.th32ParentProcessID == myPid) {
final name = _exeName(entry.ref.szExeFile).toLowerCase();
if (name == 'conhost.exe' || name == 'openconsole.exe') conhosts++;
}
ok = _process32Next(snap, entry);
}
} catch (_) {
// any FFI failure → unavailable, not a crash
} finally {
if (snap != null) {
try {
_closeHandle(snap);
} catch (_) {}
}
calloc.free(entry);
}
return (threads, sawAny ? conhosts : -1);
}
int _handleCount() {
final out = calloc<ffi.Uint32>();
try {
final ok = _getProcessHandleCount(_getCurrentProcess(), out);
return ok != 0 ? out.value : -1;
} catch (_) {
return -1;
} finally {
calloc.free(out);
}
}
int _rss() {
try {
return ProcessInfo.currentRss;
} catch (_) {
return -1;
}
}
String _exeName(ffi.Array<ffi.Uint8> arr) {
final bytes = <int>[];
for (var i = 0; i < 260; i++) {
final b = arr[i];
if (b == 0) break;
bytes.add(b);
}
return String.fromCharCodes(bytes);
}
}
+15
View File
@@ -0,0 +1,15 @@
/// Web/non-FFI stub for the Windows resource sampler (T-438 web fence, D-100).
///
/// [watchdog.dart] selects this when `dart.library.ffi` is absent, keeping the
/// `kernel32`/`psapi` FFI bindings out of the wasm graph. The watchdog isolate
/// never spawns on web, and `forPlatform()` never returns the Windows sampler
/// there — this exists only to satisfy the import. Returns an all-unavailable
/// sample (every field `-1`) if ever called.
library;
import 'watchdog.dart';
class WindowsResourceSampler implements ResourceSampler {
@override
ResourceSample sample() => const ResourceSample();
}
+67 -8
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:isolate';
import 'package:clide/app.dart'; import 'package:clide/app.dart';
import 'package:clide/test_app.dart'; import 'package:clide/test_app.dart';
@@ -39,6 +40,7 @@ import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart'; import 'package:clide/src/daemon/git_commands.dart';
import 'package:clide/src/daemon/image_commands.dart'; import 'package:clide/src/daemon/image_commands.dart';
import 'package:clide/src/daemon/log_commands.dart';
import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart';
import 'package:clide/src/daemon/status_command.dart'; import 'package:clide/src/daemon/status_command.dart';
import 'package:clide/src/daemon/ui_command.dart'; import 'package:clide/src/daemon/ui_command.dart';
@@ -49,14 +51,18 @@ import 'package:clide/src/daemon/search_commands.dart';
import 'package:clide/src/editor/registry.dart' show EditorRegistry; import 'package:clide/src/editor/registry.dart' show EditorRegistry;
import 'package:clide/src/git/client.dart'; import 'package:clide/src/git/client.dart';
import 'package:clide/src/cli/argv_dispatch.dart'; import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath;
import 'package:clide/src/ipc/envelope.dart'; import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/mcp_server.dart'; import 'package:clide/src/ipc/mcp_server.dart';
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath; import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory;
import 'package:clide/src/pty/pty_log.dart';
import 'package:clide/src/ipc/server.dart'; import 'package:clide/src/ipc/server.dart';
import 'package:clide/src/panes/event_sink.dart'; import 'package:clide/src/panes/event_sink.dart';
import 'package:clide/src/panes/registry.dart'; import 'package:clide/src/panes/registry.dart';
import 'package:clide/src/pql/client.dart'; import 'package:clide/src/pql/client.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; // Web fence (T-438, D-100): tree-sitter init is FFI-backed on desktop, a no-op
// on web (highlighting degrades to plain text there).
import 'package:clide/kernel/src/syntax/tree_sitter_boot_stub.dart' if (dart.library.ffi) 'package:clide/kernel/src/syntax/tree_sitter_boot_io.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -78,7 +84,7 @@ Future<void> main() async {
binding.ensureSemantics(); binding.ensureSemantics();
TreeSitterLib.init(); initTreeSitter();
final appDir = await _resolveAppDir(); final appDir = await _resolveAppDir();
final themes = await _loadBundledThemes(); final themes = await _loadBundledThemes();
@@ -89,7 +95,17 @@ Future<void> main() async {
// at the last project instead so the daemon targets the real repo from the // at the last project instead so the daemon targets the real repo from the
// first request. (T-352) // first request. (T-352)
Directory startupWorkRoot = resolveWorkspaceRoot(Directory.current); Directory startupWorkRoot = resolveWorkspaceRoot(Directory.current);
// Crash-survivable logging (T-425): resolve the dev/prod verbosity once and
// attach a FileLogSink as the leading sink so a freeze leaves on-disk
// breadcrumbs. Desktop-only — the sink uses dart:io.
LogLevel bootLogLevel = kReleaseMode ? LogLevel.warn : LogLevel.info;
List<LogSink> bootLogSinks = const [];
if (!kIsWeb) { if (!kIsWeb) {
// Resolve the user's real login-shell PATH once, before any tool resolution
// or spawn — a desktop/dock launch inherits a sparse PATH that misses
// ~/.local/bin, brew, nvm, etc. (T-439). Bounded + graceful: a slow/failed
// probe just falls back to the process PATH + well-known dirs.
await primeLoginShellPath();
final bootSettings = SettingsStore(appDir: appDir); final bootSettings = SettingsStore(appDir: appDir);
await bootSettings.load(); await bootSettings.load();
startupWorkRoot = resolveStartupWorkspace( startupWorkRoot = resolveStartupWorkspace(
@@ -97,6 +113,21 @@ Future<void> main() async {
lastProject: bootSettings.get<String>('app.lastProject'), lastProject: bootSettings.get<String>('app.lastProject'),
isGitRepo: (d) => Directory('${d.path}/.git').existsSync(), isGitRepo: (d) => Directory('${d.path}/.git').existsSync(),
); );
bootLogLevel = resolveLogLevel(
isRelease: kReleaseMode,
dartDefine: const String.fromEnvironment('CLIDE_LOG'),
envVar: Platform.environment['CLIDE_LOG'],
settingValue: bootSettings.get<String>('app.log.level'),
);
bootLogSinks = [FileLogSink(dir: Directory(logDirectory())).call];
// Crash-diagnostic watchdog in its own isolate (T-435): heartbeats +
// resource samples that survive a frozen main isolate. Non-fatal — a
// leak-detector that breaks startup is worse than a missing one. The OS
// reaps the isolate on exit; every line is fsynced, so abrupt death loses
// nothing.
try {
await Isolate.spawn(watchdogEntry, ('${logDirectory()}/clide-watchdog.log', 500, 2000));
} catch (_) {}
} }
// Resolve toolchain + boot daemon inline — same as Linux. // Resolve toolchain + boot daemon inline — same as Linux.
@@ -119,6 +150,12 @@ Future<void> main() async {
// The filter-state cache, captured post-boot so `ui.filter` can read a // The filter-state cache, captured post-boot so `ui.filter` can read a
// box's current value back — the observe-half of D-6 (T-270). // box's current value back — the observe-half of D-6 (T-270).
FilterStateCache? kernelFilterStates; FilterStateCache? kernelFilterStates;
// The kernel Logger, captured in the factory so a post-boot project switch
// can rebuild the dispatcher with PTY breadcrumbs wired (T-434).
Logger? kernelLog;
// The kernel settings store, captured post-boot so `clide log level` can
// persist app.log.level (T-433).
SettingsStore? kernelSettings;
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per // IPC socket server (T-99 / T-124, per D-70/71/72). One server per
// workspace; restarted when the active project switches because the // workspace; restarted when the active project switches because the
// socket path is workspace-derived. The local DaemonClient connects // socket path is workspace-derived. The local DaemonClient connects
@@ -228,15 +265,33 @@ Future<void> main() async {
Toolchain tc, Toolchain tc,
Directory workRoot, Directory workRoot,
LayoutArrangement arrangement, LayoutArrangement arrangement,
PanelRegistry panels, PanelRegistry panels, {
) { Logger? log,
}) {
final dispatcher = DaemonDispatcher(); final dispatcher = DaemonDispatcher();
final eventSink = _BusEventSink(events); final eventSink = _BusEventSink(events);
final paneRegistry = PaneRegistry(events: eventSink); // FFI breadcrumbs (T-434): route PTY crumbs to the kernel Logger (source
// 'conpty', an eager FileLogSink source) and a sendable crumb file the
// reader/waiter isolates open themselves. Verbose (per-syscall) crumbs only
// when the log level is debug/trace.
final ptyLog = (log == null || kIsWeb)
? PtyLog.none
: PtyLog(
onCrumb: (m) => log.trace('conpty', m),
crumbPath: '${logDirectory()}/clide-pty.crumbs.log',
verbose: log.minLevel.index <= LogLevel.debug.index,
);
final paneRegistry = PaneRegistry(events: eventSink, ptyLog: ptyLog);
// D-6 parity (T-219, D-83): make the tabs the user sees in the GUI // D-6 parity (T-219, D-83): make the tabs the user sees in the GUI
// visible to `pane list` by snapshotting the kernel PanelRegistry + // visible to `pane list` by snapshotting the kernel PanelRegistry +
// LayoutArrangement at request time — no mirrored state to drift. // LayoutArrangement at request time — no mirrored state to drift.
registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement)); registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement));
// `clide log level [<level>]` — the live verbosity toggle's CLI half (T-433,
// D-6 parity with the output-dock Level chip). Persists via the kernel
// settings, captured post-boot.
if (log != null) {
registerLogCommands(dispatcher, log, (name) async => await kernelSettings?.set<String>('app.log.level', name));
}
// Trusted read-only roots beyond the workspace: the global Claude // Trusted read-only roots beyond the workspace: the global Claude
// config dir (~/.claude), so the reader can open user-scope skill / // config dir (~/.claude), so the reader can open user-scope skill /
// agent / command markdown the Config tab surfaces (D-80, T-195). // agent / command markdown the Config tab surfaces (D-80, T-195).
@@ -341,14 +396,17 @@ Future<void> main() async {
preloadNamespaces: _tier0Namespaces, preloadNamespaces: _tier0Namespaces,
autoStartDaemonClient: false, autoStartDaemonClient: false,
toolchain: toolchain, toolchain: toolchain,
minLogLevel: bootLogLevel,
additionalSinks: bootLogSinks,
daemonClientFactory: kIsWeb daemonClientFactory: kIsWeb
? null ? null
: (log, events, arrangement, panels) { : (log, events, arrangement, panels) {
daemonBus = events; daemonBus = events;
kernelArrangement = arrangement; kernelArrangement = arrangement;
kernelPanels = panels; kernelPanels = panels;
kernelLog = log;
final workRoot = startupWorkRoot; final workRoot = startupWorkRoot;
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels); final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels, log: log);
// Build the client at the workspace's socket path. The // Build the client at the workspace's socket path. The
// server is started below (swapBackend) which the // server is started below (swapBackend) which the
// client will then auto-connect to via its reconnect // client will then auto-connect to via its reconnect
@@ -373,7 +431,7 @@ Future<void> main() async {
final arrangement = kernelArrangement; final arrangement = kernelArrangement;
final panels = kernelPanels; final panels = kernelPanels;
if (bus == null || arrangement == null || panels == null) return; if (bus == null || arrangement == null || panels == null) return;
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels); final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels, log: kernelLog);
await swapBackend(dispatcher, teardown, Directory(path)); await swapBackend(dispatcher, teardown, Directory(path));
}, },
); );
@@ -383,6 +441,7 @@ Future<void> main() async {
kernelReaderNav = services.readerNav; kernelReaderNav = services.readerNav;
kernelMessages = services.messages; kernelMessages = services.messages;
kernelFilterStates = services.filterStates; kernelFilterStates = services.filterStates;
kernelSettings = services.settings;
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54) // Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
// shows socket-side logs alongside kernel/extension ones. // shows socket-side logs alongside kernel/extension ones.
ipcLog.addSink(services.logRing.add); ipcLog.addSink(services.logRing.add);
+25 -1
View File
@@ -3,7 +3,7 @@
/// Verb list matches CLAUDE.md's tier-2 surface: /// Verb list matches CLAUDE.md's tier-2 surface:
/// editor.open editor.active editor.activate editor.insert /// editor.open editor.active editor.activate editor.insert
/// editor.replace-selection editor.save editor.close editor.list /// editor.replace-selection editor.save editor.close editor.list
/// editor.read editor.set-selection editor.set-content /// editor.read editor.set-selection editor.set-content editor.goto-line
/// ///
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map /// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
/// one-to-one onto these via the IPC dispatch layer. /// one-to-one onto these via the IPC dispatch layer.
@@ -50,6 +50,14 @@ void registerEditorCommands(DaemonDispatcher d, EditorRegistry registry) {
d.register('editor.set-content', (req) => _setContent(req, registry)); d.register('editor.set-content', (req) => _setContent(req, registry));
d.register('editor.save', (req) => _save(req, registry), schema: _idArg); d.register('editor.save', (req) => _save(req, registry), schema: _idArg);
d.register('editor.close', (req) => _close(req, registry), schema: _idArg); d.register('editor.close', (req) => _close(req, registry), schema: _idArg);
d.register(
'editor.goto-line',
(req) => _gotoLine(req, registry),
schema: const CommandSchema(
positional: ['line'],
args: {'line': ArgSpec(type: ArgType.number)},
),
);
} }
IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err( IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err(
@@ -220,3 +228,19 @@ Future<IpcResponse> _close(IpcRequest req, EditorRegistry r) async {
r.close(id); r.close(id);
return IpcResponse.ok(id: req.id, data: {'id': id}); return IpcResponse.ok(id: req.id, data: {'id': id});
} }
/// Jump the active (or [id]'d) buffer's caret to the start of a 1-based line —
/// the ex-line `:N` goto (T-407) and the CLI `clide editor goto-line <n>`.
/// Reuses the [_offsetForLine] mapping `editor.open --line` uses; out-of-range
/// lines clamp to the buffer end via setSelection.
Future<IpcResponse> _gotoLine(IpcRequest req, EditorRegistry r) async {
final id = _resolveId(req, r);
if (id == null) return _notFound(req.id, 'no active buffer');
final buf = r.get(id);
if (buf == null) return _notFound(req.id, 'no such buffer: $id');
final rawLine = req.args['line'];
final line = rawLine is num ? rawLine.toInt() : int.tryParse('$rawLine');
if (line == null || line < 1) return _userErr(req.id, 'line must be a positive integer');
r.setSelection(id, Selection.collapsed(_offsetForLine(buf.content, line)));
return IpcResponse.ok(id: req.id, data: {'id': id, 'line': line});
}
+37
View File
@@ -0,0 +1,37 @@
/// Registers `log.level` — the live dev/prod verbosity toggle (T-433).
///
/// With no arg it reports the running [Logger]'s minimum level; with
/// `level=<name>` it sets the Logger AND persists `app.log.level` so the
/// choice survives a restart (resolved at boot by `resolveLogLevel`). The UI
/// half is the output dock's Level chip — D-6 parity. Flutter-free + trivially
/// testable: the handler takes the Logger and a persist callback.
library;
import 'package:clide/kernel/src/log.dart';
import '../ipc/envelope.dart';
import 'dispatcher.dart';
/// Persists the chosen level name (the owner wires this to
/// `settings.set('app.log.level', name)`).
typedef LogLevelPersist = Future<void> Function(String levelName);
void registerLogCommands(DaemonDispatcher d, Logger log, LogLevelPersist persist) {
d.register('log.level', (req) async {
final raw = req.args['level'];
if (raw == null) {
// Read: report the current level + the vocabulary.
return IpcResponse.ok(id: req.id, data: {'level': log.minLevel.name, 'levels': LogLevel.values.map((l) => l.name).toList()});
}
final level = parseLogLevel(raw is String ? raw : raw.toString());
if (level == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(code: 64, kind: 'bad_arg', message: 'unknown log level: $raw', hint: 'one of: ${LogLevel.values.map((l) => l.name).join(', ')}'),
);
}
log.minLevel = level;
await persist(level.name);
return IpcResponse.ok(id: req.id, data: {'level': level.name});
});
}

Some files were not shown because too many files have changed in this diff Show More