151 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
jpmschweitzerandClaude Fable 5 70d71f2a3a release v2.4.0
Minor release. Adds live tail inside expanded Bash activity cards
(T-325) and double-tap-modifier shortcuts with double-Shift quick-open
across all keymap presets (T-341); each spawned subagent now gets its
own activity card (T-342). Carries a large stability sweep — PTY fd
and process leaks, IPC framing, settings durability, UTF-8 decoding
across chunk boundaries, transactional extension lifecycle — plus two
security fixes: the MCP HTTP server now requires a per-start auth
token (T-362) and editor.open/save are workspace-confined (T-363).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:27:59 +02:00
jpmschweitzerandClaude Fable 5 f378be4084 persist the ticket dep-graph export
The pre-commit hook staged the ticket/history tables but left the
deps + idmap exports (the T-398..T-402 and T-403..T-407 blocker
links) unstaged on the previous commit; sweep them in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:23:47 +02:00
jpmschweitzerandClaude Fable 5 395a125241 file the vim cross-pane interaction tickets (T-403..T-407)
Review outcome: the vim layer (T-65) is editor-only today — the mode
flags are global but every binding drives the focused editor, and the
tree/board/git/conversation panes have no keyboard handling at all.
T-403 carries the findings; children map vim idioms onto existing
panel commands (ctrl+w family incl. ctrl+w o → focus mode), add the
missing workspace tab cycling (gt/gT + ctrl+pgup/pgdn for every
preset), introduce nav intents for pane-local j/k navigation, and the
minimal ex command line the mode service already reserved space for.

Also sweeps in the regenerated governance index (D-96..D-99 listed,
Q-23 moved to resolved) from the decisions sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:23:15 +02:00
jpmschweitzerandClaude Fable 5 b9c0ec4dea resolve Q-23: no-install ssh-exec remote model (D-96..D-99, T-330)
The user picked the zero-footprint model for SSH-remote workspaces:
stock OpenSSH only, nothing clide-specific installed on the remote.

- D-96 — footprint: ssh -tt PTYs, ControlMaster exec channels, polling
  watcher, RemoteExecutionContext seam; D-56's single-process rule is
  strengthened (no clide process anywhere but the local app).
- D-97 — ssh://[user@]host[:port]/path naming; auth delegates wholly
  to system ssh in BatchMode; Windows is a known v1 gap.
- D-98 — remote-tool contract: shell+git required, pql/claude degrade
  behind banners, one batched connect preflight.
- D-99 — session + per-workspace state identity re-keys on
  (host, repo), amending D-41/D-77; local keeps its identity.

T-330 closes; T-336 expands into T-398..T-402 (connection manager,
ExecutionContext sweep, remote PTY, polling watcher, preflight) with
the blocker graph encoded in the board.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:17:26 +02:00
jpmschweitzerandClaude Fable 5 6817abaf96 add WorkspaceRef + remote identity on RecentProject (T-332)
The model-independent half of the ssh:// open scheme. WorkspaceRef is
the value type for "where a workspace lives" — a local path or
ssh://[user@]host[:port]/abs/path, with parse/uri round-tripping and a
host:path display form. RecentProject carries host/port/user
(back-compatible JSON: absent keys deserialize as local) so remote
recents survive restarts and render with their host badge.

The remaining T-332 scope — ProjectManager.current off bare Directory,
open() branching, remote resolveProject — is gated on the execution
layer (T-336), which is itself blocked on the T-330 footprint pick;
the epic's blocker graph now encodes that gating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:11:57 +02:00
jpmschweitzerandClaude Fable 5 be28bdef82 record the SSH-remote footprint decision menu in Q-23 (T-330)
The Phase-0 spike's core pick — no-install ssh-exec vs auto-pushed
remote agent — is the user's call (they've said they don't want to
manage remote installs; the agent model buys inotify + a stateful
backend). Q-23 now carries the 2026-06-12 triage block with both
options, the agent-model sub-questions, the remote-tool-contract
D-record need, and the latency-probe evidence gap (no sshd reachable
from the dev box). T-330 is annotated blocked-on-user; the
model-independent backbone phases proceed meanwhile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:05:56 +02:00
jpmschweitzerandClaude Fable 5 051ceea3b2 route DaemonClient through a DaemonTransport seam (T-331)
The UI's backend client connected straight to the workspace unix
socket, hard-coding the local shape. It now talks JSON-lines through a
DaemonTransport (new lib/src/ipc/transport.dart, Flutter-free), with
LocalSocketTransport reproducing today's connect byte-for-byte — zero
behavior change, proven by the untouched client test suite plus new
seam tests driving the client over an in-memory transport.

This is the slot the SSH-remote backend (T-329/Q-23) plugs into:
request correlation, reconnect/backoff, and event forwarding live
above the seam and won't change when the endpoint is remote.
main.dart's swapIpcServer becomes swapBackend per the same plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:04:58 +02:00
jpmschweitzerandClaude Fable 5 c3dd1d3e3f capture CSI intermediate bytes; stop bare-final mis-dispatch (T-123)
_consumeCsi silently discarded intermediate bytes (0x20-0x2f), so an
intermediate-bearing sequence dispatched on its bare final byte —
`CSI 5 SP @` (VT420 scroll-left) ran as "insert 5 blank characters",
and `CSI Ps SP q` (DECSCUSR) could collide with any future bare-q
handler. The parser now records intermediates on the CSI scratch
object and routes any sequence carrying them to unknownCSI, since no
intermediate form is implemented yet.

Implementing DECSCUSR itself (cursor shape + renderer support) is
filed as T-397.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:55:58 +02:00
jpmschweitzerandClaude Fable 5 f8958fd372 split escape parser handlers into part files (T-123)
parser.dart (1139 LOC) now keeps only the core — byte queue, dispatch
tables, ESC/CSI consumers — at 373 LOC. The handler groups move to
mixins in part files of the same library, so every private member
stays library-scoped and no public surface is added:

- csi_handlers.dart — cursor/erase/scroll ops, DA/DSR, margins, tab
  clear, repeat, window manipulation
- sgr_handlers.dart — SGR incl. the guarded 38/48 extended-color path
  (T-369)
- mode_handlers.dart — ANSI + DEC private mode set/reset
- osc_handlers.dart — OSC string parsing + dispatch

An abstract _EscapeParserBase carries the shared state (handler sink,
queue, token bookkeeping, the reusable _Csi scratch) the mixins are
`on`. All 76 parser tests (and the rest of the terminal suite) pass
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:50:17 +02:00
jpmschweitzerandClaude Fable 5 31df537770 split claude_meta_sidebar.dart into meta_sidebar/ (T-395)
The 1192-LOC sidebar monolith now keeps only its lifecycle — stats
polling, team membership streams, primary-session binding, broker
subscription, inject + accordion state — and switches between
stateless, props-driven tab views under meta_sidebar/:

- models.dart — SidebarTab/ConfigSection/ConfigPermKind enums, the
  MetaSection/MetaRow models, and the shared table geometry both
  Activity and Config render on
- activity_tab.dart / team_tab.dart / config_tab.dart — the three
  bodies; accordion expansion stays in the parent (survives tab
  switches) and arrives as prop + callback
- roster_row.dart, permission_badge.dart, task_row.dart,
  tab_strip.dart, icon_button.dart, inject_field.dart — the widgets

Public API unchanged: ClaudeMetaSidebar stays put and SidebarTab is
re-exported from the root file, so all 44 sidebar tests (and
extension.dart) pass without a single edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:38:57 +02:00
jpmschweitzerandClaude Fable 5 7f16016bc3 split lib/app.dart into lib/src/shell/ (T-394)
app.dart was 1187 LOC mixing five concerns. It now keeps ClideApp +
the WidgetsApp root (~60 LOC); the shell moved to lib/src/shell/:

- root_shell.dart — keyboard/intent routing (keymap resolution,
  double-tap modifiers, menu mnemonics), the overlay stack, and the
  welcome overlay
- hat_bar.dart + project_switcher.dart — the window-chrome bar and
  its recents/file-actions dropdown (now in src/shell, not builtin/ —
  they're app chrome, not extension-shaped contributions)
- slot_host.dart — slot mounting, focus-scope integration, the
  per-slot bodies incl. the workspace split + editor drag handle;
  _SlotBody's static title resolver became the shared resolveTabTitle
- layout.dart — the three-column grid, status bar, collapse toggles,
  bottom icon rails

app.dart re-exports RootLayout, SlotHost, StatusbarHost, and
StatusbarCollapseToggle, so every existing import (incl. the three
app-level test files) is unchanged. Pure move + minimal publics
(RootShell, HatBar, ProjectSwitcherButton); full suite green with no
test edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:29:46 +02:00
jpmschweitzerandClaude Fable 5 05584eb9e5 drop the unused mocktail dep; amend D-25 (T-385)
mocktail was pinned and documented as the IO-mocking strategy, but
after the T-91 coverage drive it had zero imports — every IO seam
ended up with an injected hand-rolled fake instead. D-25 is amended
to record that the hand-rolled-fakes rule covers IO seams too;
licenses.yaml and the lockfile follow. The ptyc binary removal noted
in this sweep landed with the git-API commit (it was already staged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:18:14 +02:00
jpmschweitzerandClaude Fable 5 37f3ad0796 remove the tmux-era TranscriptPublisher class (T-385)
No production code constructed it since the stream-json pivot (D-77)
— only its own test did. The ClaudeConversation bus-addressing
constants stay; the meta sidebar and team panel host still consume
them for member-status messages. The companion finding — the team
roster surfaces listening to TeamMemberJoined events nothing emits —
is real rewiring work, split out as T-396 (drive the roster from
TeamBroker membership, then delete the ghost event types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:17:37 +02:00
jpmschweitzerandClaude Fable 5 06c2e76be4 remove the dead ColumnHat widget; keep hatHeight (T-385)
ColumnHat was superseded by the hat bar in app.dart and survived only
through a zero-coverage smoke test. Its file also carried the live
hatHeight constant (D-57's 24px hats) consumed by the hat bar and the
menu bar — that moves to widgets/src/chrome_metrics.dart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:17:15 +02:00
jpmschweitzerandClaude Fable 5 21ddecffef trim libc.dart to the symbols the PTY layer uses (T-385)
The fd-passing-era surface — recvmsg with the msghdr/cmsghdr/iovec
struct family, raw read/write, ioctl/winsize, the fcntl non-blocking
helpers — had no callers since the daemon dissolution (D-56);
NativePty binds its own symbols. What remains is what's actually
consumed: socketpair + close (the ClideTestApp harness), errno, the
poll event bits, and the two signal numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:16:55 +02:00
jpmschweitzerandClaude Fable 5 401b1e1ce5 delete ToolCheck and the GraphView placeholder (T-385)
ToolCheck had zero callers. GraphView was unreachable — the graph
builtin contributes nothing, so no surface ever built it; the flat
pql-connections ListView it held was never the owned-canvas graph
anyway (T-7 cancelled). The Governance Graph idea (Q-46/Q-49) starts
fresh if it lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:16:33 +02:00
jpmschweitzerandClaude Fable 5 a59c3658a9 remove the legacy free-function git API (T-385)
operations.dart carried a full second git operation surface
(gitStage/gitCommit/gitStash/gitPush/...) that duplicated GitClient
verb-for-verb, was kept alive only by its own tests, and hid a latent
pipe deadlock in _applyPatch (stdin written without draining stderr).
The file keeps the genuinely shared plumbing — gitBin resolution,
GitException, validateGitRef, GitLogEntry — which GitClient, the
status/diff readers, and the git command handlers consume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:16:06 +02:00
jpmschweitzerandClaude Fable 5 99dc52052a dispose-safe teardown for KeymapService + ClaudeConfig; cover the
claude extension lifecycle

make test-integration failed at widget-tree finalization: the
palette's dispose() clears its scope flag, which during app teardown
runs AFTER KernelServices.dispose() has disposed the KeymapService —
notifyListeners asserted. Scope-flag mutations now use the same
fire-and-forget guard SettingsStore established. Same family in
ClaudeConfig: activation's unawaited load() could notify (and start
watchers on) a disposed notifier when a teardown raced it.

The claude extension's activation lifecycle and command success paths
are now exercised end-to-end through the kernel fixture — the file
entered the coverage denominator with the T-391 failure-path tests,
so per the ratchet discipline the rest of it gets covered too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:55:56 +02:00
jpmschweitzerandClaude Fable 5 4443e1c834 run dart format over the scorpion-fix files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:40:09 +02:00
jpmschweitzerandClaude Fable 5 41af83f024 repoint the UI harness at the repo root; CI via make targets (T-384)
tools/ui/build.sh and serve.sh still cd'd into the app/ directory the
flattening removed, so make test-e2e / ui-dev / ui-smoke died at the
first line. The staged Gitea workflow had the same stale cd in every
job, plus a coverage gate with no coverage run before it — it now
goes through the make targets (tooling discipline: the make layer owns
env setup) with make test-coverage feeding make coverage-gate.

Fixing the paths exposed the real break: flutter build web --wasm
cannot compile the tree since the dart:ffi pivot (tree-sitter, native
PTY) — dart:ffi does not exist on the wasm target. Fence vs park vs
drop is filed as Q-50; the workflow's e2e job is withheld with a
pointer there, and T-384 sits in review until Q-50 resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:39:24 +02:00
jpmschweitzerandClaude Fable 5 bc3c47ee81 make extension activation transactional (T-377)
Three lifecycle gaps, benign among curated builtins but hazardous the
day Tier-6 Lua extensions land: a throw mid-contribution left earlier
contributions mounted while the extension recorded as failed (a retry
then double-applied them); deactivate ignored active dependents; and
the panel/command registries silently clobbered on id collision.

Activation now tracks what it mounted and unwinds it all on failure
(including the extension's own deactivate when its activate had
succeeded); deactivate refuses with a logged warning while active
dependents exist — disable the dependents first; duplicate
contribution/command ids throw, which the transactional path turns
into a clean failed activation with first-wins semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:35:24 +02:00
jpmschweitzerandClaude Fable 5 c31f5bfb14 add Terminal.writeBytes with a persistent UTF-8 decoder (T-373)
The terminal's only ingestion API was write(String), so both byte
consumers decoded per chunk — a multi-byte rune split across PTY
reads (or a tail window starting mid-character, which FileTailFollower
does by construction) rendered as U+FFFD garbage. writeBytes feeds a
per-instance chunked Utf8Decoder that carries partial-rune state
across calls; the terminal pane and the Bash live-tail follower now
use it, and write(String) stays for tests and programmatic writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:32:13 +02:00
jpmschweitzerandClaude Fable 5 928dede847 return error envelopes from failed claude commands (T-391)
Sixteen claude.* handlers reported ok with an `error` field buried in
the payload — `clide claude.agent.set-permission-mode bogus` exited 0,
so scripts could not detect failure, drifting from the D-6 exit-code
contract every other subsystem honors. Missing/invalid args are now
userError, missing sessions notFound, a missing orchestrator
toolError, and a failed task reassign no longer reports ok:false as a
success. No UI consumer read the old payloads. Table-driven test
walks every failure path asserting non-zero codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:28:50 +02:00
jpmschweitzerandClaude Fable 5 638869621e remove dead welcome tiles; advertise only real shortcuts (T-383)
Clone-from-git and Start-a-Claude-session were inert onTap: () {}
stubs whose printed shortcuts were never registered — dead UI on the
first screen a new user sees. No advertised dead ends: the tiles are
removed until their flows exist. The tips card was also fiction
(four of six shortcuts unregistered, ⌘ glyphs for a ctrl-based
default keymap) — it now lists six bindings that exist in the shipped
default preset / contributed commands, and the Open-folder glyph
matches the real ctrl+o binding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:24:58 +02:00
jpmschweitzerandClaude Fable 5 d9ae2d7585 surface kernel notifications as toasts (T-382)
The Notifications service had zero widget consumers — anything pushed
through ctx.notify (cli_install's dogfood warnings, install results)
accumulated in a list nothing rendered. The service now takes the
kernel MessageBus and publishes each notification to the toast
channel with mapped severity, so the existing ToastOverlay renders
them; the active list stays for API compatibility. Chose routing over
building a notifications tray nobody asked for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:21:16 +02:00
jpmschweitzerandClaude Fable 5 01c3de37e1 render markdown hard breaks and image placeholders (T-379)
Both node types fell through the inline-span switch to an empty
textContent span: words on either side of a hard break glued
together, and images vanished with no trace. A br now emits a
newline; an img renders a muted italic "[image: alt]" placeholder
(falling back to the src) — no inline network loading in the owned
renderer; live-pane images keep going through clide image show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:18:41 +02:00
jpmschweitzerandClaude Fable 5 e413380ea9 make settings persistence safe for nested data and crashes (T-376)
Three failure modes in the YAML store: maps nested inside lists (the
documented keymap-overlay shape) fell through _emitScalar to
toString() and corrupted on the next read; writes went straight to
the live file, so a crash mid-write truncated every setting; and a
parse failure silently returned an empty map that the next set()
wrote over the user's file. Maps in lists now emit as YAML flow
mappings, writes are temp-file + rename, and an unparseable file is
preserved as .broken with a warning through the kernel Logger (new
onError hook, wired in the facade).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:16:48 +02:00
jpmschweitzerandClaude Fable 5 5d52694889 serve IPC clients with one await-for read loop (T-372)
The async onData handler never paused its subscription, so pipelined
requests interleaved mid-handler — violating D-72's serial-dispatch
contract — while the shared StringBuffer could re-frame underneath an
in-flight await and the per-chunk utf8.decode corrupted runes split
across reads. One `await for` over a persistent Utf8Decoder +
LineSplitter fixes framing, decoding, and serialization at once.
Tests: two frames pipelined in one write dispatch strictly in order;
a frame split mid-rune across writes decodes intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:13:43 +02:00
jpmschweitzerandClaude Fable 5 bbc6899df2 consume the fork source on first bind (T-375)
widget.forkSourceId took precedence over the fresh/resume logic on
EVERY (re)bind, so /clear in a fork pane re-forked the original
conversation instead of clearing, and /resume re-forked the same way.
The source is now copied into one-shot pane state and cleared after
the first successful fork spawn; later respawns operate on the pane's
own session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:11:17 +02:00
jpmschweitzerandClaude Fable 5 51957eb0ac coalesce concurrent session spawns onto one future (T-374)
Orchestrator.spawn() check-then-acts on the session registry across
two awaits (transcript-tail read, process start) — two racing callers
for the same id both passed the check and the loser's live claude
process was orphaned, never killed, never observed. The first caller
now installs the spawn future synchronously; later callers await the
same future, and a failed spawn clears the entry so a retry proceeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:08:33 +02:00
jpmschweitzerandClaude Fable 5 5d9443d9f7 spawn terminal panes in the open workspace root (T-381)
The shell spawned with Directory.current — $HOME for desktop-entry
launches, and stale after a project switch since the process CWD
never moves. Use the kernel project root, falling back to the
process CWD only when no project is open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:06:45 +02:00
jpmschweitzerandClaude Fable 5 5f9c054420 replay-latest ValueStream for session state streams (T-386, T-274)
Broadcast streams drop the current value for late subscribers — the
shape behind T-274: the init event fires while spawn() is still
awaiting the transcript-tail read, before the pane subscribes, so the
status bar stayed blank. New pure-Dart ValueStream<T> (no rxdart —
prefer-zero-deps) replays the latest value to each new subscriber;
statusStream, busyStream, and pendingPromptStream in the claude
builtin now use it. busyStream subscribers see the current state
first (seeded false), which the busy test now asserts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:05:08 +02:00
jpmschweitzerandClaude Fable 5 0e7353bf9c run dart format over the dragon-fix files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:58:37 +02:00
jpmschweitzerandClaude Fable 5 9889afdc35 require a bearer token on the MCP HTTP server (T-362)
D-71's threat model — another user on the same host must not drive my
IDE — was enforced with 0600 on the unix socket and then bypassed
wholesale by the unauthenticated localhost SSE port, which since D-86
serves every clide verb as a tool. The server now mints 32 bytes of
CSPRNG token per start, publishes it via the /ide discovery lock
file's authToken slot (the field Claude Code's client reads), chmods
the lock to 0600, and rejects any request that doesn't present the
token in x-claude-code-ide-authorization with 401.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:58:01 +02:00
jpmschweitzerandClaude Fable 5 a919d79ce1 watch the claude process: drain stderr, surface exit (T-361)
The session observed its child only via stdout. Two failure modes:
with --verbose the CLI chats on stderr, and an undrained 64KB pipe
blocks the child mid-turn with zero diagnostics; and nothing watched
the exit code, so a crashed process just looked thoughtful forever.

ClaudeStreamJsonProcess now drains stderr from construction into a
bounded tail buffer, and StreamJsonSession watches exitCode: on death
it flips busy off, clears any unanswerable pending prompt, and emits
a SessionEnd (exit code + stderr tail) — replayed via session.end for
late binders. The pane reports the exit in its status line and logs
the stderr tail; a deliberate dispose suppresses the watch so /clear
and teardown don't read as crashes. Test fakes extend the process
base instead of implementing it, so its defaults carry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:54:12 +02:00
jpmschweitzerandClaude Fable 5 77c4341318 scope collapser-card semantics exclusion to the header (T-370)
The summarized button semantics (label, expanded/collapsed state)
wrapped the entire card with excludeSemantics, so every expanded
child vanished from the a11y tree — a screen-reader user could expand
a run and hear nothing inside it. The exclusion now wraps only the
header (ticker when collapsed, header row when expanded); inner item
cards stay readable, and the redundant background-toggle tappable is
explicitly excluded so the header stays the single AT stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:47:06 +02:00
jpmschweitzerandClaude Fable 5 664a8da72e tear down the previous workspace's services on project switch (T-367)
buildDispatcher composed a fresh PaneRegistry, FilesService,
SearchService, and EditorRegistry per workspace, but their shutdown()
methods had zero callers — every project switch left the old set's
file watcher emitting into the new workspace's bus and its PTYs
alive. The dispatcher now pairs with a teardown closure that the
serialized swap invokes after the old server stops; the same-path
reuse fast-path drops the unused new set without teardown since its
services are inert until a command starts them. SearchService gains
the shutdown() it was missing (cancels in-flight searches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:44:24 +02:00
jpmschweitzerandClaude Fable 5 ba6ab51118 confine editor.open/editor.save to the workspace (T-363)
The editor registry resolved buffer paths with a string join that
passed absolute paths through verbatim and never normalized `..` —
an unconfined read and write primitive over IPC while files.read was
carefully guarded. Buffer paths now resolve through
resolveUnderRootFollowingSymlinks: traversal, absolute escapes, and
symlinks-out are rejected at open, and re-checked at save so a
symlink swapped in under an open buffer's path can't redirect the
write. D-80's extra read roots deliberately do not apply — a buffer
is a write surface. Handlers map PathOutsideRoot to the same error
files.read uses. Also merges a duplicate Added heading that had crept
into the Unreleased changelog section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:39:23 +02:00
jpmschweitzerandClaude Fable 5 88d72789f4 apply include/exclude globs in search.replace (T-364)
computeReplacements accepted the query's glob filters and silently
dropped them — replace could rewrite files the equivalent search
would never have matched. The grep engine's glob helpers are now
public and shared, so search and replace can't disagree on scope;
both the preview and the apply path go through the filtered list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:34:47 +02:00
jpmschweitzerandClaude Fable 5 8477e302ef detect symlinks from the lister entity, never descend them (T-365)
stat() follows links, so `stat.type == link` was always false: every
FileEntry reported isSymlink=false and walkFiles happily descended
symlinked directories — an escape hatch out of the workspace and a
cycle risk for the search engine built on the walk. The lister already
runs with followLinks: false, so the Link entity itself is the signal.
listDir keeps reporting the target type for the UI; walkFiles skips
descent into symlinked dirs and still emits file symlinks as entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:32:24 +02:00
jpmschweitzerandClaude Fable 5 34821fbc39 guard SGR 38/48 lookahead; parse colon sub-parameters (T-369)
printf '\e[38m' was a RangeError inside Terminal.write — the
extended-color branches indexed params[i+1..i+4] unguarded. An
emulator must never throw on hostile bytes. Both branches now share a
bounds-checked helper that ignores truncated sequences.

Colons were silently dropped mid-CSI, fusing 38:2:255:0:0 into one
bogus parameter; the consumer now records ECMA-48 sub-parameter
links, so ITU T.416 colon-form truecolor/256-color (with or without
the colorspace slot) parses identically to the semicolon form, and a
malformed colon group is dropped whole instead of bleeding into
neighbouring SGR codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:30:41 +02:00
jpmschweitzerandClaude Fable 5 390ab2b64e gate conversation auto-scroll on the bottom pin (T-368)
New items arrive on every streamed token, and _onChanged jumped to
maxScrollExtent unconditionally — so a reader who scrolled up was
dragged back to the tail continuously for the whole reply. The
_atBottom pin already existed for viewport resizes (T-297); apply it
to the new-item path too, re-checking after layout since the user can
scroll during the frame. Twin tests added beside the T-297 pair:
pinned view keeps following, scrolled-up view stays put.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:32 +02:00
jpmschweitzerandClaude Fable 5 75fc2719a0 cache the kernel ref so dispose() can actually clean up (T-366)
ClideKernel.of(context) is an illegal ancestor lookup inside
dispose(); both panes wrapped it in catch (_) and silently did
nothing. The terminal pane therefore never sent pane.close (backend
PTY + daemon pane leaked per closed pane) and the Claude pane never
removed its settings listener. Both now cache KernelServices in
didChangeDependencies and the swallow-everything helpers are gone.
New terminal_pane_test covers the close-on-dispose path; note in it
why the whole tree must unmount (harness Overlay keeps
initialEntries across rebuilds).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:24:27 +02:00
jpmschweitzerandClaude Fable 5 466383671d close the PTY master fd when the child exits naturally (T-360)
_reap() flipped _dead without releasing the master fd, and close()
short-circuits on _dead — so every naturally-exited child leaked its
fd and pty device for the life of the app. The reader isolate sends
EOF only after leaving its poll loop, so releasing the fd inside
_reap() cannot race the reader. Regression test counts /dev/ptmx
entries in /proc/self/fd across a natural exit; verified to fail
against the unfixed code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:18:47 +02:00
jpmschweitzerandClaude Fable 5 647c22d32a file Q-35 through Q-49 for the Fable review feature proposals
New governance/questions/design.md holds one open question per Part IV
feature proposal — Tier 1 (agent blame, context x-ray, trust ledger,
activity HUD, active-ticket chip), Tier 2 (twin-timeline rewind, visual
dialog, immortal terminals, cost ledger, ticket dispatch), Tier 3
(semantic terminal, codebase map, live mixed documents, sealed
workspace) — plus one batch record for the honorable mentions, so each
can resolve into a D-record + initiative or an R-record. Remote Claude
over SSH got a context append on existing Q-23 instead of a duplicate
record. README index regenerated by pql decisions sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:14:21 +02:00
jpmschweitzerandClaude Fable 5 3ed90fc318 file the 2026-06-11 Fable review under epic T-359
Commit fable-ous.md (13-reviewer multi-agent assessment of the whole
tree) and the ticket tree it produced: epic T-359 with 26 children
covering the dragon bugs (PTY fd leak, undrained claude stderr,
unauthenticated MCP HTTP, path-confinement gaps, dispose-path leaks,
SGR crash, a11y semantics), the medium scorpions, a dead-code sweep,
the systemic-pattern work, and split plans for app.dart and the claude
meta sidebar. Root causes appended to existing T-274, T-283 context,
and the parser split plan to T-123. One review claim (ColumnHat
duplicated in app.dart) was refuted during verification and is
annotated on T-385/T-394.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:13:52 +02:00
jpmschweitzerandClaude Opus 4.8 662325d5db trim CHANGELOG bullets to the 60-word cap
The T-325 and T-342 entries leaked commit-body detail into the changelog;
shorten to user-facing impact per the changelog-gate cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:17:50 +02:00
jpmschweitzerandClaude Opus 4.8 1e24f0022d close out T-325 in the ticket board
Persist the live-tail story's done transition (ticket-DB sweep only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:17:09 +02:00
jpmschweitzerandClaude Opus 4.8 d43377ac89 claude: live-tail terminal sub-card in expanded Bash cards (T-325, UI)
Wire the detection + follower core into the Bash tool card. A Bash card
with a follow intent (`tail -f …`) gains a "live tail" segment below the
result: an embedded read-only TerminalView fed by FileTailFollower on the
file the command follows, resolved against the open workspace.

Lazy lifecycle for free: the collapser builds its children only when
expanded (clide_collapser_card.dart), so _BashLiveTail starts the follower
in didChangeDependencies on expand and stops it in dispose on collapse —
no follower runs until the card is expanded. No resolvable file-backed
source → a muted "no independent source to follow" note, never an empty
terminal. The workspace root comes from kernel.project.current, so no new
plumbing through the conversation widget tree.

Tests: a tail Bash card surfaces the segment (+ the muted note when no
project/source); an ordinary `ls` card gets no segment; the segment only
builds on expand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:09:01 +02:00
jpmschweitzerandClaude Opus 4.8 898a0316e5 claude: Bash live-tail detection + read-only file follower (T-325, core)
The detection/follow core for the live-tail sub-card, with the UI wiring
to follow. Claude Code runs every Bash tool itself and clide only sees the
final tool_result block — we can't mirror the running process, so instead
we detect a file-backed source the command follows and open our own
read-only follower on the same file.

- bash_tail_source.dart: detectBashTailSource() parses a Bash command for a
  single, safe, file-backed source (tail/cat/less with one file arg, inside
  the workspace via resolveUnderRoot). Returns null for a pipe-into-tail, a
  redirect, two files, or a path outside the repo — the caller then shows a
  "nothing to follow" note. bashHasTailIntent() gates WHEN the segment
  appears: v1 triggers on `tail`/follow-flags only, so ordinary cat/ls/git
  cards stay clean (cat/less remain detectable for later).
- file_tail_follower.dart: a polling, read-only `tail -f`-style follower
  (no subprocess, no touching Claude's command) that emits the trailing
  window then appended deltas, and re-reads from the top on truncation.

Tested: 19 parser cases (incl. the `git push | tail -25` and outside-
workspace null cases), the intent predicate, and the follower (initial
window / appended delta / missing file / rotation / start / stop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:12:53 +02:00
jpmschweitzerandClaude Opus 4.8 d43ddfed9c pql: refine T-47 (clide self-update) and return it to the backlog
Fleshed out the self-update story with grounded constraints, a blocking
prerequisite, decisions, and a phased breakdown:
- D-64 ("no auto-update checks without user action") is stricter than the
  original "opt-in or gated" wording → the check must be explicitly
  user-initiated every time (palette / About button), not a startup poll.
- POLICY.md grudging-allowance criteria apply to the explicit fetch.
- Hard prereq: no release channel exists (2 stale tags, no CI, no signed
  artifacts) → recommended splitting a "release channel" sibling under T-46.
- Phases P0 prereq → P1 check+notify → P2 download+verify → P3 apply+relaunch
  (tmux sessions survive, D-41) → P4 macOS/deltas.

Moved back to backlog pending the release-channel prerequisite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:33:36 +02:00
jpmschweitzerandClaude Opus 4.8 4c33a85bf0 claude: give each spawned subagent its own collapsing card (T-342)
A fan-out of N agents (Task/Agent) merged into one shared "Activity / N
steps" cluster — groupConversation folded an Agent spawn like any Bash/
Read call. Now an Agent spawn is a cluster boundary, rendering as its own
first-class collapsing card (reusing the existing sticky-agent path: folded
prompt T-263 + nested run T-264), while adjacent non-agent foldables keep
clustering into the normal Activity card.

Two changes:
- activity_cluster: a shared isAgentTool() predicate; _isFoldable returns
  false for agent spawns at every level (incl. L3), so parallel agents
  never merge. Only the grouping boundary changes; fold mechanics are
  unchanged.
- conversation_view: harden resolveOwner. Its nearest-preceding-agent
  fallback is safe with one agent but mis-routes under a parallel fan-out
  (an unattributable item lands in whichever agent was emitted last —
  a sibling's card). With >1 agent, drop the fallback so the item orphans
  (rendered inline) instead of cross-attributed. The T-338 direct route
  (parent_tool_use_id) still attributes interleaved items correctly.

Tests: two consecutive agents → two cards (not one cluster); agent breaks
a sibling cluster; agents first-class at L3; regression — consecutive
Bash still one cluster; interleaved parallel-agent runs route to their own
card; an unattributable item orphans instead of being swept into the last
agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:57:21 +02:00
jpmschweitzerandClaude Opus 4.8 ce200765cc claude: accepting ExitPlanMode exits plan mode in the panel (T-337)
ExitPlanMode arrives as a can_use_tool permission prompt and was approved
like any other tool — the control_response was sent but the tracked
SessionStatus.permissionMode never changed, so the mode indicator and
composer stayed on "plan" after the plan was accepted.

On approving an ExitPlanMode prompt, sync the tracked mode to 'default'
(the CLI performs the transition itself, so no set_permission_mode control
request is sent — we only mirror it). The change rides the existing
statusStream → claude_pane._status plumbing, so the permission-mode
control and status indicator update with no extra wiring. Deny, and any
non-ExitPlanMode tool, leave the mode untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:34:50 +02:00
jpmschweitzerandClaude Opus 4.8 430189d714 keymap: support bare-modifier double-tap chords; double-Shift → quick-open (T-341)
The chord matcher couldn't represent a bare or double-tapped modifier:
KeyChord.parse required a base key, so `shift shift` failed, and JetBrains
"Search Everywhere" (double-Shift) was unbindable.

Design decision: search-everywhere aliases clide's existing quick-open
finder (not a new overlay) — bound across all four presets per the user.

Changes:
- KeyChord: a bare modifier name (`shift`, `ctrl`, `cmd`, …) parses as a
  modifier-free chord on that modifier's logical key, so parseSequence(
  'shift shift') yields a two-chord double-tap. Adds KeyChord.bareModifier
  and modifierForLogicalKey.
- ModifierTapTracker: headless, clock-injected double-tap detector. A bare
  modifier never forms a single chord; an intervening key breaks the gesture.
- app.dart global handler feeds bare-modifier KeyDowns to the tracker and,
  on a double-tap, resolves the 2-chord sequence via the new
  KeymapService.resolveSequence. The existing single-chord path is untouched
  (zero behavioural risk to normal keys).
- Presets: default/vim/vscode/jetbrains add `shift shift` → quickOpen.open.
  jetbrains header updated (the gesture is now expressible).

Tests: bare-modifier parse/equality/round-trip; tracker window/reset/
different-modifier/consume; each shipped preset resolves double-Shift to
QuickOpenIntent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:26:54 +02:00
jpmschweitzerandClaude Opus 4.8 c1b78d2845 release v2.3.3
Patch release: the daemon boots its pql/git/files workspace at the last
opened project instead of the launch directory (HOME) on a desktop
launch, so the ticket/decision sidebars load on first open instead of
erroring against a stale ~/.pql/pql.db (T-352). Also raises the toolchain
floor to Flutter 3.35 / Dart 3.9 and refreshes dependencies after a clean
CVE audit (T-353).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:35:08 +02:00
jpmschweitzerandClaude Opus 4.8 fc4021e98b main: boot the daemon at the last project, not HOME (T-352)
Confirmed root cause of the sidebar failure: a desktop launch starts in
HOME, which isn't a git repo, so resolveWorkspaceRoot returns HOME and
the daemon's pql/git/files all target HOME. pql then finds a stale
~/.pql/pql.db (left from earlier HOME-workdir runs) and errors
"pql.db is from an earlier schema" — exactly what the sidebars showed.
A manual refresh worked only because by then the workspace had swapped
to the repo. (Verified directly: `cd $HOME && pql ticket list` reproduces
the schema error against ~/.pql/pql.db.)

Settings confirmed the user is in the picker path (no startupSticky on
any recent), so nothing auto-opens the repo at boot and the daemon sits
on HOME until the project is opened.

Fix: resolve the startup workspace before boot — prefer the launch CWD
when it's a git repo, else fall back to app.lastProject (the repo). The
daemon now points at the real workspace from its first request, so the
sidebars load immediately without depending on swap/refetch timing. The
ProjectOpened refetch and swap serialization from 2.3.x stay in place for
genuine mid-session project switches.

Pure resolveStartupWorkspace() helper is unit-tested; app boot green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:32:18 +02:00
jpmschweitzerandClaude Opus 4.8 46f84728d2 deps: bump markdown 7.2.2 -> 7.3.1, unblocked by the Dart 3.9 floor (T-353)
Now that the SDK floor is Dart 3.9 (markdown 7.3.1 requires ^3.9.0), take
the bump. 7.3.0/7.3.1 are bug-fixes + perf with no breaking AST/Node
changes (we walk the parsed tree in our own renderer); only API-level
deprecations (LinkContext, BlockParser.standardBlockSyntaxes) we don't
use. licenses.yaml updated in the same commit; markdown goldens green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:12:03 +02:00
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00
jpmschweitzerandClaude Opus 4.8 bcea5f15b7 ci: run osv-scanner in CI, not push-check (T-353)
Keep the security gate out of the local pre-push path so dev machines
don't need osv-scanner installed. The `make security` target and
ci/osv_scan.sh stay for the CI PR-merge pipeline (and on-demand local
runs); only the push-check prerequisite is removed, with a NOTE in the
Makefile explaining where it runs instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:08:30 +02:00
jpmschweitzerandClaude Opus 4.8 34ce01cbf1 ci: add osv-scanner supply-chain gate to push-check (T-353)
`make push-check` now runs a `security` step (ci/osv_scan.sh) that scans
pubspec.lock with osv-scanner and fails the push if any resolved
dependency has a known advisory. This is a hard, fail-closed gate on top
of `dart pub get`'s passive (non-failing) advisory print.

Replaces the old manual-review `security` no-op target. Slots in among
the instant fail-fast gates, before the coverage suite. Resolves the
osv-scanner binary from PATH, falling back to a brew prefix so the gate
works under the pre-push hook's leaner PATH; if absent it fails with an
install hint (brew install osv-scanner). Native deps (dugite,
tree-sitter, wasmtime) are vendored by SHA and reviewed separately on
bump (D-42), so they're out of scope for the lockfile scan.

Verified clean against the current lockfile (80 packages, no issues).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:06:22 +02:00
jpmschweitzerandClaude Opus 4.8 1238802f54 deps: CVE audit + refresh safe pins, document held ones (T-353)
Reviewed every direct and transitive dependency against the GitHub
Advisory Database / OSV (Pub ecosystem). No advisory affects any
dependency at its current pin or upgrade target — the "N packages have
newer versions" noise is freshness, not security. (Consistent with
`dart pub get` printing no advisory warnings.)

Bumped the safe pins + their licenses.yaml entries in the same commit:
- ffi 2.1.3 -> 2.2.0
- jovial_svg 1.1.26 -> 1.1.30 (pulls jovial_misc 0.10.0 + xml 7.0.1)
- mocktail 1.0.4 -> 1.0.5

Held, with the reason recorded inline in pubspec.yaml:
- markdown 7.2.2: 7.3.1 requires Dart ^3.9.0 — defer to an SDK-floor bump
- alchemist 0.12.1: 0.13.0 disabled text anti-aliasing -> golden churn
- test 1.31.0: flutter_test SDK-locks the resolvable ceiling

make test green (incl. SVG/xml goldens — the xml 6->7 major didn't churn
rendering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:58:54 +02:00
623 changed files with 32556 additions and 17614 deletions
@@ -112,9 +112,37 @@ tooltip → tooltipBackground / tooltipForeground / tooltipBorder
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
```
## Settings & grouped lists — sectioned cards
Settings surfaces and any long grouped list (e.g. the Claude config lists)
read as **sectioned cards**, not bare rows floating on the panel. Each logical
group gets its own card; the small-caps section label (+ optional count) sits
just **above** the card.
```
panel bg → panelBackground (#20202C)
card surface → surface (#242838) fill + dividerColor/border (1px), ~6px corners
section head → sidebarSectionHeader (small-caps), with the count muted to its right
control inset → inputs INSIDE a card recede to panelBackground, so they still
read as fields against the elevated card
```
- **One card per group** — a settings table, each config list. The card's
elevated fill + border do the visual separation; don't rely on spacing alone.
- **Field row inside a card:** label (`globalForeground`) + help
(`globalTextMuted`) + the control right-aligned, with the per-field scope tag
in the far-right column.
- **Scroll, don't cram:** when stacked cards exceed the modal/pane viewport, the
pane scrolls vertically (sticky header, scrolling body) — prefer that over
shrinking content to fit one screen.
- Pattern reference: the settings wireframes under
`docs/design/wireframes/settings/` (T-302).
## Anti-patterns
- `globalBackground` for panel fill → use `panelBackground`
- Bare settings rows on the panel where a group of them should be one card →
see "Settings & grouped lists".
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
- Tab active border = `globalFocus` → use `panelActiveBorder`
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.44.1"
}
-92
View File
@@ -1,92 +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.
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
name: unit + widget + golden + a11y
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: dart pub get
- run: (cd app && flutter pub get)
- run: ci/test.sh
- run: ci/test_a11y.sh
- run: ci/coverage_gate.sh
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: dart pub get
- run: (cd app && flutter pub get)
- uses: coactions/setup-xvfb@v1
with: { run: ci/test_integration.sh }
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: dart pub get
- run: (cd app && flutter pub get)
- run: ci/smoke_bundle.sh
e2e:
name: daemon subprocess + web WASM smoke
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: dart pub get
- run: (cd app && flutter pub get)
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
- run: ci/test_e2e.sh
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: dart 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
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout)
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout"
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch 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/macos-arm64/clide
/native/macos-x64/clide
/native/windows-x64/clide.exe
/native/windows-x64/clide.obj
# -- Test, coverage, profile output ------------------------------------
*.test
+6 -6
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
channel: "stable"
project_type: app
@@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
- platform: web
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
- platform: windows
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
# User provided section
+9
View File
@@ -26,3 +26,12 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:09', '2026-06-10 13:27:09', NULL, '1d5185ae9c9676bd70d0e02e3a5e79a1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '90dca94aa700c143290b2b1afaca09ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '9b9081edc77f0e9a4b689bbc191771db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', '06FB3DQEMTDHF8SV27AKAB8JHW', '2026-06-10 13:27:05', '2026-06-12 01:11:12', NULL, '17f1c884268a172f803f407a2ad47c8c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DN94MBCTYJW17ZCYVSXE0', '2026-06-10 13:27:09', '2026-06-12 01:11:17', NULL, 'a67368ff15089dce57838840632fe07e', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-12 01:11:22', NULL, '0ec8ff6c455136e45fbb1d06a2a690f1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-12 01:11:26', NULL, '3c985828c591c88d492eb261c6d26d33', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DMF20SYFDT6WX2RFBQXKW', '2026-06-12 01:11:31', '2026-06-12 01:11:31', NULL, '81fd318a43138a3bef82e31f928ff0b2', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKMXSVCE98K1H76N00TYCQR', '2026-06-12 03:16:35', '2026-06-12 03:16:35', NULL, '28d9785a1f9696b6b579a1dbb9fdb889', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN09R21H3AWR2Q2ZTSGNSW', '2026-06-12 03:16:40', '2026-06-12 03:16:40', NULL, '7e00348c10432c65b03f9dce36520e48', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', '06FBKN2MP35NPPK1BRDYY2M428', '2026-06-12 03:16:44', '2026-06-12 03:16:44', NULL, 'a6b1c20279ac30f692a30c802cf8f3e5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN4QVFVE51MY2N0CWCVXHM', '2026-06-12 03:16:49', '2026-06-12 03:16:49', NULL, '9063328f18ba32c0737997ac9af0911a', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
File diff suppressed because it is too large Load Diff
+106
View File
@@ -177,3 +177,109 @@ 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 ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'T-350', '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'a5c0c22d84621b14a5208317414d6026', 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 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 ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'T-353', '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, '53374633101d04f94981baaf4f2e0315', 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 ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'T-354', '2026-06-11 13:36:51', '2026-06-11 13:36:51', NULL, '32bb5d359401599022a8771031d8a08a', 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 ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'T-355', '2026-06-11 13:36:55', '2026-06-11 13:36:55', NULL, '2eb6809e958613a924b35e080ab17609', 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 ('06FBDSM0PRGYR61R0NWYAT9VDC', 'T-356', '2026-06-11 13:37:00', '2026-06-11 13:37:00', NULL, 'bdb597080218a3e8783f6c5cf74c529a', 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 ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'T-357', '2026-06-11 13:37:19', '2026-06-11 13:37:19', NULL, '96cf88e036dc3a45487cdbffff26cdde', 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 ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'T-358', '2026-06-11 13:37:25', '2026-06-11 13:37:25', NULL, '2a656aaec54bf50c34c7e28347b1fb29', 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 ('06FBHBGHNEQTAEPGNJKN42C1E8', 'T-359', '2026-06-11 21:54:36', '2026-06-11 21:54:36', NULL, '1930fad7b18c79cf97d913d8372b77bd', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'T-360', '2026-06-11 21:54:49', '2026-06-11 21:54:49', NULL, '1ffef3c00cdca28566ab67a11ec17e53', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'T-361', '2026-06-11 21:55:01', '2026-06-11 21:55:01', NULL, '5f8bdede98496496bf031a40a09dee16', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'T-362', '2026-06-11 21:55:13', '2026-06-11 21:55:13', NULL, '610e74eb7b4ff9db3949ed01a0268380', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'T-363', '2026-06-11 21:55:26', '2026-06-11 21:55:26', NULL, '5daf7bcd9ccdc37f8aed531ef12d395f', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'T-364', '2026-06-11 21:55:38', '2026-06-11 21:55:38', NULL, '025de46ba9e8d005fd8b1f74687cd8b6', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'T-365', '2026-06-11 21:55:49', '2026-06-11 21:55:49', NULL, 'b366b41e4737a2761a01284fd7dd44e0', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'T-366', '2026-06-11 21:56:01', '2026-06-11 21:56:01', NULL, '8a75dc7dea83a0e643d1912bda46dd57', 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 ('06FBHBWE2W1226T58CX37E50HC', 'T-367', '2026-06-11 21:56:13', '2026-06-11 21:56:13', NULL, '578b51eafd19044dc0e2720f6e55d633', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'T-368', '2026-06-11 21:56:33', '2026-06-11 21:56:33', NULL, '0df409c83fe28af2d49a156118ed6ece', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'T-369', '2026-06-11 21:56:47', '2026-06-11 21:56:47', NULL, '7f47ddc3e84f9fea915200992a5a0baf', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'T-370', '2026-06-11 21:57:04', '2026-06-11 21:57:04', NULL, '1908b7c0903c389ad5b743fb89ca32e0', 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 ('06FBHC46SEHH8NQY481VMGK66R', 'T-371', '2026-06-11 21:57:17', '2026-06-11 21:57:17', NULL, '2095f10159561a5e81b2a9b990c79fa2', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'T-372', '2026-06-11 21:57:31', '2026-06-11 21:57:31', NULL, '9d22f370e726af56d47d6c8acd93bc87', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'T-373', '2026-06-11 21:57:44', '2026-06-11 21:57:44', NULL, '231c399183a83e569c0645e1d149625f', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'T-374', '2026-06-11 21:57:56', '2026-06-11 21:57:56', NULL, '9832ac0b3e0bebd85f3271a5ea96d4ed', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'T-375', '2026-06-11 21:58:08', '2026-06-11 21:58:08', NULL, 'de3569d098b5c4cf6a3d49ee3d33d1c9', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'T-376', '2026-06-11 21:58:22', '2026-06-11 21:58:22', NULL, 'fe23c94d8971d21963a2b2e2739e05a3', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'T-377', '2026-06-11 21:58:40', '2026-06-11 21:58:40', NULL, '1585df7f3826ddbcef8a030ffd1e0890', 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 ('06FBHCG84F4SPFW111CC4K26A8', 'T-378', '2026-06-11 21:58:55', '2026-06-11 21:58:55', NULL, 'c6b0d9d124401f1d2bdc403567cbfdf8', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'T-379', '2026-06-11 21:59:13', '2026-06-11 21:59:13', NULL, '0d9aed402c9840ef6fb75edfcfcbc3f4', 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 ('06FBHCM1RBAF72SCZBKRTXJSYC', 'T-380', '2026-06-11 21:59:26', '2026-06-11 21:59:26', NULL, '1a0d767f30c9bc0e9c96f39d468ca67b', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'T-381', '2026-06-11 21:59:42', '2026-06-11 21:59:42', NULL, 'cfac236e079164f9588d936f5101c71c', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'T-382', '2026-06-11 21:59:55', '2026-06-11 21:59:55', NULL, 'b54be44ec4cfbbc649324471a5e2141f', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'T-383', '2026-06-11 22:00:14', '2026-06-11 22:00:14', NULL, '0e18de4e94e4fcf36fc40de763522cbd', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'T-384', '2026-06-11 22:00:29', '2026-06-11 22:00:29', NULL, '57d7b6a6c05791acadd1cebe611c8991', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'T-385', '2026-06-11 22:00:44', '2026-06-11 22:00:44', NULL, 'ebda2dd9c7dfe92bab2260dc34212ac4', 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 ('06FBHD098CV2N73823KX4Z99P4', 'T-386', '2026-06-11 22:01:07', '2026-06-11 22:01:07', NULL, 'de90501f2d4be80231651d25d04f4649', 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 ('06FBHD2FWTDFYA00W4QXTE41M0', 'T-387', '2026-06-11 22:01:25', '2026-06-11 22:01:25', NULL, '8a247364a872c223b37682c6496d3920', 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 ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'T-388', '2026-06-11 22:01:43', '2026-06-11 22:01:43', NULL, 'cc238ee850d4d0ed90d05b9a42c8d506', 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 ('06FBHD6FQMXMQQQ877KMNCK6QC', 'T-389', '2026-06-11 22:01:57', '2026-06-11 22:01:57', NULL, 'bb0657e304f4ad1445d756ef7170c62d', 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 ('06FBHD8F2NBEFZNPKSJ9W253J0', 'T-390', '2026-06-11 22:02:14', '2026-06-11 22:02:14', NULL, '35453f0d8c7e0fb89c243481f870de03', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'T-391', '2026-06-11 22:02:31', '2026-06-11 22:02:31', NULL, '0647245b4da95532bb3abd6f20cd87de', 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 ('06FBHDC7B2MFQZVR1K9E30FXDR', 'T-392', '2026-06-11 22:02:44', '2026-06-11 22:02:44', NULL, 'a1612ff75df3d134b8e05c716d24ebfe', 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 ('06FBHDE5A3965GNRFS5WPZW878', 'T-393', '2026-06-11 22:03:00', '2026-06-11 22:03:00', NULL, 'f8f29fb8b31cc8afdd8af989c3f711f1', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'T-394', '2026-06-11 22:03:21', '2026-06-11 22:03:21', NULL, 'f9cee5dc96cedfabc3eaaf7352e732c8', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'T-395', '2026-06-11 22:03:26', '2026-06-11 22:03:26', NULL, '382a5742e32da0f38c1e143715a0b656', 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 ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'T-396', '2026-06-12 00:11:50', '2026-06-12 00:11:50', NULL, '20577481e56f82bb0df9e56a266303c9', 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 ('06FBJM6XXQZ3EMGRC13XRYVBEM', 'T-397', '2026-06-12 00:52:25', '2026-06-12 00:52:25', NULL, 'bf89e25b384be60faa65e9eb1ec2fab9', 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 ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'T-398', '2026-06-12 03:15:00', '2026-06-12 03:15:00', NULL, 'd0ed46ee279c148f1d76683e86e0d4aa', 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 ('06FBKMXSVCE98K1H76N00TYCQR', 'T-399', '2026-06-12 03:15:21', '2026-06-12 03:15:21', NULL, 'eacf3522aeb2bccbaf006c9703b9794d', 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 ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'T-400', '2026-06-12 03:15:41', '2026-06-12 03:15:41', NULL, 'a7971f428145d04ae82d4cd89f3eeb9d', 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 ('06FBKN2MP35NPPK1BRDYY2M428', 'T-401', '2026-06-12 03:16:00', '2026-06-12 03:16:00', NULL, '29980034db5fe381473b156ece7d8a1a', 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 ('06FBKN4QVFVE51MY2N0CWCVXHM', 'T-402', '2026-06-12 03:16:18', '2026-06-12 03:16:18', NULL, 'c65717bf20ea6886ef7a86f5cb4b2929', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'T-403', '2026-06-12 03:20:52', '2026-06-12 03:20:52', NULL, '372a686b214820b5d093b11b9f3d57a5', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'T-404', '2026-06-12 03:21:11', '2026-06-12 03:21:11', NULL, 'c479a9dea6687b553bc638a9849cc5de', 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 ('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
+343
View File
@@ -16,6 +16,349 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
## [2.6.0] — 2026-06-16
### Added
- **Vim `gt` / `gT` tab motions.** Under the Vim preset, `gt`/`gT` cycle the
workspace tab strip (the same `workspace.tab.*` commands as `ctrl+pagedown`/
`ctrl+pageup`), resolved by the focused editor or pane and sharing the `g`
prefix with `gg`. Completes the T-403 cross-pane vim layer. (T-405)
- **Vim ex command-line (`:`).** Under the Vim preset, `:` opens a transient
one-line overlay running a fixed table — `:w` save, `:q` close the active tab
(the split self-collapses on the last one), `:wq`/`:x` and `ZZ` save+close,
`:e <path>` jump to quick-open seeded with the path, `:<n>` goto-line. Unknown
commands flash and stay open; with no active buffer it no-ops. Opens from the
editor or a focused pane; Esc dismisses. Adds the `editor.goto-line` CLI/IPC
verb. (T-407)
- **Crash-survivable logging.** clide writes a durable JSON-lines log to a
persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`,
Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so
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
### Added
- **Live tail inside expanded Bash activity cards.** A Bash card that follows a
file (`tail -f …`) now shows a live, scrolling read-only tail of that file
below the result — connected only while the card is expanded. Commands with no
followable file show a muted "nothing to follow" note. (T-325)
- **Double-tap-modifier shortcuts (e.g. double-Shift "Search Everywhere").**
The keymap can now bind a bare modifier and a double-tap sequence
(`shift shift`). All four presets (default, vim, vscode, jetbrains) map
double-Shift to the quick-open finder — JetBrains' "Search Everywhere"
gesture, aliased to clide's existing fuzzy file finder. (T-341)
### Changed
- **Each spawned subagent gets its own collapsing activity card.** A fan-out of
N agents no longer merges into one "Activity / N steps" cluster — each spawn is
its own card with its prompt and nested run. Non-agent tool calls still group
as before. (T-342)
### Removed
- **Dead-code sweep.** The legacy free-function git API (with its latent
pipe deadlock), ToolCheck, the fd-passing-era libc bindings, the GraphView
placeholder, the superseded ColumnHat widget, the tmux-era
TranscriptPublisher, the committed `ptyc` binary, and the unused
`mocktail` dev-dependency (D-25 amended) are gone. (T-385)
- **Dead welcome-screen tiles.** "Clone from git…" and "Start a Claude
session" did nothing on tap and advertised shortcuts that were never
registered; the tips card now lists only shortcuts that exist in the
default keymap. Each tile returns when its flow ships. (T-383)
### Fixed
- **Terminal CSI sequences with intermediate bytes no longer mis-dispatch.**
The parser dropped intermediates, so e.g. VT420 scroll-left (`CSI 5 SP @`)
executed as "insert 5 blank characters"; such sequences are now reported
as unknown instead. (T-123)
- **PTY master fd no longer leaks when a child exits on its own.** Every
terminal or Claude pane whose process ended naturally left its pty device
open for the life of the app; natural exit now releases the fd. (T-360)
- **Closing a terminal pane now closes its shell.** Pane disposal looked up
the kernel illegally and swallowed the failure, so `pane.close` was never
sent — the backend PTY and daemon pane leaked on every closed terminal
pane, and Claude panes leaked a settings listener the same way. (T-366)
- **Scrolling up during a streaming reply no longer fights the auto-scroll.**
The conversation followed the tail on every streamed token regardless of
scroll position, dragging a reader back to the bottom; it now follows only
while already pinned there. (T-368)
- **The terminal no longer crashes on truncated SGR color sequences.**
`ESC[38m` and friends threw a RangeError inside the emulator; incomplete
38/48 sequences are now ignored, and colon-form truecolor/256-color
sub-parameters (`38:2:r:g:b`, ITU T.416) parse like the semicolon form
instead of being mangled. (T-369)
- **File listing flags symlinks again and the workspace walk no longer
follows them.** Symlink detection was dead code, so `files.walk` (and the
search engine on top of it) silently descended symlinked directories —
including ones pointing outside the workspace. (T-365)
- **Search-and-replace now honors its include/exclude globs.** The filters
were accepted but never applied, so replace could rewrite files outside
the scope the user typed; replace now uses the same glob filtering as
search. (T-364)
- **Switching projects releases the previous workspace's services.** The old
file watcher, pane PTYs, in-flight searches, and editor buffers were left
alive on every project switch, with stale watcher events leaking into the
new workspace. (T-367)
- **Expanded activity cards are readable by screen readers again.** The
collapser's summarized button semantics excluded the whole card, so
expanding a run announced nothing inside it; the exclusion is now scoped
to the header and the inner cards stay in the a11y tree. (T-370)
- **A crashed Claude process no longer looks like it's still thinking.**
stderr is now drained continuously (an undrained pipe could block the
child mid-turn) and the exit code is watched: when the process dies the
pane stops spinning, clears any unanswerable permission prompt, reports
the exit in the status line, and logs the stderr tail. (T-361)
- **The Claude status bar populates reliably after a session starts.** The
session's init event often fired before the pane subscribed and the plain
broadcast stream dropped it, leaving the model/mode/context line blank;
session state streams now replay their latest value to late subscribers.
(T-274, T-386)
- **New terminal panes open in the project root.** The shell spawned in the
app process's working directory — `$HOME` for desktop launches, and the
wrong repo after a project switch. (T-381)
- **Two simultaneous spawns of the same Claude session no longer leak a
process.** Concurrent spawn calls for one pane id both passed the registry
check and the loser's live process was orphaned; spawns for an id are now
coalesced onto one in-flight future. (T-374)
- **`/clear` in a fork pane clears instead of re-forking.** The fork source
took precedence on every respawn, so clearing a fork tab silently branched
the original conversation again; the source now seeds only the first
bind. (T-375)
- **Pipelined IPC requests are now truly serial and framing-safe.** The
server's read handler could interleave concurrent requests (against
D-72's contract), drop or double frames split across reads, and corrupt
multi-byte characters split across chunks. (T-372)
- **Settings survive nested structures, crashes, and corruption.** Maps
inside lists (the keymap overlay shape) were corrupted on save; writes
are now atomic (temp file + rename), and a file that fails to parse is
preserved as `.broken` with a logged warning instead of being silently
reset. (T-376)
- **Markdown hard breaks break lines and images leave a visible trace.**
Both rendered as empty text — words on either side of a hard break glued
together and images vanished; breaks now emit a newline and images render
an italic `[image: alt]` placeholder. (T-379)
- **Extension notifications actually appear on screen.** Messages pushed
through the kernel Notifications service (e.g. the CLI-install dogfood
warnings) accumulated in a list no surface rendered; they now raise
toasts with matching severity. (T-382)
- **Failed `clide claude.*` commands now exit non-zero.** Sixteen handlers
reported success with an error message buried in the payload, so scripts
could not detect failures like an unknown permission mode; they now
return proper error envelopes per the D-6 contract. (T-391)
- **Terminal output no longer garbles multi-byte characters split across
reads.** PTY output and live-tail bytes were decoded per chunk, turning a
rune split across reads into replacement-character noise; the terminal
now ingests bytes through a persistent decoder. (T-373)
- **Extension lifecycle is transactional.** A throw mid-activation now
unwinds every contribution it had mounted (a retry no longer
double-applies), deactivating an extension is refused while active
extensions depend on it, and duplicate contribution/command ids are
rejected instead of silently clobbering. (T-377)
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
session out of plan mode, but clide's tracked permission mode didn't follow,
so the mode indicator and composer stayed stuck on "plan". The approval now
syncs the tracked mode to `default`. (T-337)
### Security
- **The MCP HTTP server now requires a per-start auth token.** The localhost
SSE port served the entire clide command surface unauthenticated,
bypassing the unix socket's 0600 gate; requests must now present the
token published in the 0600 `/ide` lock file. (T-362)
- **`editor.open` / `editor.save` are now workspace-confined.** Both verbs
accepted absolute paths and `..` traversal verbatim — an unconfined read
and write primitive over IPC. They now pass the same path-safety guard as
`files.read`, including a symlink re-check at save time. (T-363)
## [2.3.3] — 2026-06-11
### Fixed
- **Ticket/decision sidebars load on first open after a desktop launch.** A
desktop launch starts in HOME (not a git repo), and the daemon booted its
pql/git/files workspace there — so pql ran in HOME, hit a stale
`~/.pql/pql.db`, and the sidebars showed "pql … failed" until the project was
reopened (a manual refresh worked once the workspace had swapped to the repo).
The daemon now boots at the last opened project when the launch directory
isn't itself a repo, so pql targets the real workspace from the first request.
(T-352)
### Changed
- **Minimum toolchain raised to honest values.** `pubspec.yaml` now declares
Flutter `>=3.35.0` / Dart `>=3.9.0` (was `3.19.0` / `3.5.0`) — the real
minimums our deps already required (`alchemist` needs Flutter 3.32; Dart 3.9
ships in Flutter 3.35). The exact build toolchain is pinned in `.fvmrc`
(Flutter 3.44.1). Moving to the Dart 3.9 language level reformatted the tree
to the new "tall" style and adopted two new lints (`unnecessary_underscores`,
`use_null_aware_elements`). (T-353)
### Security
- **Dependency audit + refresh.** Reviewed every pinned and transitive
dependency against the GitHub Advisory Database / OSV (Pub ecosystem) — no
advisory affects any current or candidate version. Refreshed the safe pins:
`ffi` 2.1.3→2.2.0, `jovial_svg` 1.1.26→1.1.30 (pulls `jovial_misc` 0.10.0 +
`xml` 7.0.1), `mocktail` 1.0.4→1.0.5, and `markdown` 7.2.2→7.3.1 (unblocked by
the Dart 3.9 floor below). Deliberately held with reasons in `pubspec.yaml`:
`alchemist` (0.13 golden churn), `test` (Flutter-SDK locked). (T-353)
- **Supply-chain gate (`make security`).** A new `security` target runs
`osv-scanner` over `pubspec.lock` and fails if any resolved dependency has a
known advisory — a hard gate on top of `dart pub`'s passive, non-failing
advisory print. Intended for the CI PR-merge pipeline (kept out of
`push-check` so dev machines don't need the scanner installed); run locally
any time with `make security`. (T-353)
## [2.3.2] — 2026-06-11
### Fixed
+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.
- **[`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/).
+23 -4
View File
@@ -148,7 +148,7 @@ changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bulle
ci/changelog_gate.sh
.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
# -- 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).
flutter build macos
.PHONY: build-windows
build-windows: gen-build-info ## flutter build windows (desktop bundle).
flutter build windows
# -- install / uninstall -----------------------------------------------------
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
@@ -196,6 +200,9 @@ ifeq ($(FLUTTER_OS),linux)
else ifeq ($(FLUTTER_OS),macos)
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
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
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.
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
.PHONY: clide-cli
@@ -299,7 +310,11 @@ clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
@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)
endif
@echo "==> built $(CLIDE_CLI_BIN)"
.PHONY: clide-cli-clean
@@ -309,8 +324,8 @@ clide-cli-clean: ## Remove the compiled C `clide` client.
# -- security -------------------------------------------------------------
.PHONY: security
security: ## Dart advisory review.
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps."
security: ## Supply-chain gate — osv-scanner over pubspec.lock (CI PR-merge pipeline; run locally on demand). Fails on a known advisory.
ci/osv_scan.sh
# -- pre-push gate --------------------------------------------------------
@@ -319,6 +334,10 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
pql decisions validate
.PHONY: push-check
# NOTE: the `security` (osv-scanner) gate is deliberately NOT in push-check —
# it runs in the CI PR-merge pipeline (where the scanner is provisioned) so we
# don't force every dev machine to install osv-scanner. Run it locally any time
# with `make security`.
push-check: decisions-validate changelog-gate test-coverage coverage-gate test-core ## Pre-push gate (fast — <2 min target). Order is fail-fast: instant gates (decisions, changelog) first, then the coverage suite + gate (the expensive, most-likely-to-fail stage) BEFORE test-core — a coverage miss aborts here instead of after running everything, so a fix doesn't force a full re-run of the rest. test-coverage already runs the a11y suite (test/a11y), so no separate test-a11y pass.
.PHONY: push-check-full
+3 -1
View File
@@ -58,8 +58,10 @@ bindings:
# only claims ctrl+p for selectPrevious `when: palette.open`, so this
# is conflict-free. Arrow/enter/escape inside the overlay are handled
# locally by the widget.
# `shift shift` (double-tap) is the JetBrains "Search Everywhere" gesture;
# clide aliases it to the quick-open finder across all presets (T-341).
- intent: quickOpen.open
keys: [ctrl+p, meta+p]
keys: [ctrl+p, meta+p, shift shift]
when: "!palette.open"
# Nav stays on arrows/ctrl+n (not ctrl+p — that's the open chord and
# would collide while the overlay is up).
+7 -4
View File
@@ -12,11 +12,12 @@
# IntelliJ's editor-scoped contexts have no producer yet, so global chords
# stay ungated (they're global in IntelliJ too).
#
# "Search Everywhere" (double-Shift) maps to clide's quick-open finder via
# the `shift shift` double-tap gesture (T-341) — see the quick-open binding.
#
# Not bound — no clide command analogue (kept out of scope per the ticket):
# Run (Shift+F10), Debug (Shift+F9), Rename/Refactor (Shift+F6), Settings
# (Ctrl+Alt+S). And "Search Everywhere" (double-Shift) is not expressible by
# the current chord matcher (bare/double modifiers unsupported) — tracked by
# T-341; Go to File / Find Action below are the practical stand-ins.
# (Ctrl+Alt+S).
name: jetbrains
@@ -52,8 +53,10 @@ bindings:
# -- Quick open: Go to File / Go to Class / Recent Files --------------
# win/linux: Ctrl+Shift+N, Ctrl+N, Ctrl+E. mac: Cmd+Shift+O, Cmd+O,
# Cmd+E. clide has one fuzzy file finder, so all land on quick-open.
# `shift shift` (double-tap) is IntelliJ's "Search Everywhere"; clide
# aliases it to the quick-open finder (T-341).
- intent: quickOpen.open
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e]
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e, shift shift]
when: "!palette.open"
- intent: quickOpen.selectNext
keys: down
+97 -1
View File
@@ -34,8 +34,9 @@ bindings:
- intent: dismiss
keys: escape
when: palette.open
# `shift shift` (double-tap) aliases to quick-open across presets (T-341).
- intent: quickOpen.open
keys: [ctrl+p, meta+p]
keys: [ctrl+p, meta+p, shift shift]
when: "!palette.open"
- intent: quickOpen.selectNext
keys: [down, ctrl+n]
@@ -49,6 +50,11 @@ bindings:
- intent: dismiss
keys: escape
when: quickOpen.open
# Ex command-line overlay (T-407): Esc dismisses it back to normal mode (the
# overlay's own EditableText handles Enter via onSubmitted).
- intent: dismiss
keys: escape
when: exline.open
- intent: findInFiles.open
keys: [ctrl+shift+f, meta+shift+f]
- intent: focus.nextPanel
@@ -62,6 +68,96 @@ bindings:
- intent: text.scaleReset
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 ------------------------------------------------
- intent: command:vim.mode.visual
keys: v
+2 -1
View File
@@ -51,8 +51,9 @@ bindings:
when: palette.open
# -- Quick open / Go to File (Ctrl+P) ---------------------------------
# `shift shift` (double-tap) aliases to quick-open across presets (T-341).
- intent: quickOpen.open
keys: [ctrl+p, meta+p]
keys: [ctrl+p, meta+p, shift shift]
when: "!palette.open"
- intent: quickOpen.selectNext
keys: [down, ctrl+n]
+4 -13
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead.
version: "2.3.2"
version: "2.5.0"
homepage: https://github.com/postmeridiem/clide
license: MIT
license_file: assets/LICENSE
@@ -116,7 +116,7 @@ dependencies:
- name: ffi
kind: dart-package
version: "2.1.3"
version: "2.2.0"
homepage: https://pub.dev/packages/ffi
license: BSD-3-Clause
purpose: >-
@@ -171,7 +171,7 @@ dependencies:
- name: jovial_svg
kind: dart-package
version: "1.1.26"
version: "1.1.30"
homepage: https://pub.dev/packages/jovial_svg
license: BSD-3-Clause
purpose: >-
@@ -182,7 +182,7 @@ dependencies:
- name: markdown
kind: dart-package
version: "7.2.2"
version: "7.3.1"
homepage: https://pub.dev/packages/markdown
license: BSD-3-Clause
purpose: >-
@@ -205,15 +205,6 @@ dependencies:
# Build-time-only dependencies — test runners, mocks, lints. Tracked
# here for audit completeness; NOT rendered in the About screen.
dev_dependencies:
- name: mocktail
kind: dart-package
version: "1.0.4"
homepage: https://pub.dev/packages/mocktail
license: MIT
purpose: >-
Mocks at IO / IPC boundaries. ChangeNotifier facades use
hand-rolled fakes instead of mocks (D-025).
- name: alchemist
kind: dart-package
version: "0.12.1"
+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
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Supply-chain gate — fails the push if any resolved dependency in
# pubspec.lock has a known advisory (OSV / GitHub Advisory Database).
#
# Complements `dart pub get`'s passive advisory print (informational,
# non-failing) with a hard, fail-closed gate. Native deps (dugite,
# tree-sitter, wasmtime) are vendored by SHA and not in a lockfile OSV
# reads — they're reviewed separately on bump (D-42, CLAUDE.md supply chain).
set -euo pipefail
cd "$(dirname "$0")/.."
# Resolve osv-scanner: PATH first, then a brew prefix (the git pre-push hook
# may run with a leaner PATH than the dev's interactive shell).
OSV="$(command -v osv-scanner || true)"
if [[ -z "$OSV" ]] && command -v brew >/dev/null 2>&1; then
cand="$(brew --prefix 2>/dev/null)/bin/osv-scanner"
[[ -x "$cand" ]] && OSV="$cand"
fi
if [[ -z "$OSV" ]]; then
echo "==> osv gate: osv-scanner not found on PATH." >&2
echo " Install it: brew install osv-scanner" >&2
echo " (or: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest)" >&2
exit 2
fi
echo "==> osv gate: scanning pubspec.lock for known advisories"
if "$OSV" scan source --lockfile=pubspec.lock; then
echo "==> osv gate OK: no known advisories"
else
echo "==> osv gate FAIL: a dependency has a known advisory (see above)." >&2
echo " Bump the affected package (+ its assets/licenses.yaml entry), or" >&2
echo " document an explicit, justified exception before pushing." >&2
exit 1
fi
+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
# parallel, which flaked them (registry/session). Serialize — the proper fix
# 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
# 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
# a time on desktop; we iterate to avoid the "Unable to start the app"
# 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
cd "$(dirname "$0")/.."
for f in integration_test/*_test.dart; do
echo "==> integration_test: $f"
flutter test "$f"
flutter test -d linux "$f"
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
+96 -96
View File
@@ -71,112 +71,112 @@ class ClideTheme {
// ─── ThemeData ────────────────────────────────────────────────────────
static ThemeData get data => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: _accent,
onPrimary: Color(0xFF0D1020),
secondary: _synKeyword,
onSecondary: Color(0xFF0D1020),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF0D1020),
),
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: _accent,
onPrimary: Color(0xFF0D1020),
secondary: _synKeyword,
onSecondary: Color(0xFF0D1020),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF0D1020),
),
textTheme: const TextTheme(
// Josefin Sans Light for display; JetBrains Mono for code/body.
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
),
textTheme: const TextTheme(
// Josefin Sans Light for display; JetBrains Mono for code/body.
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
cardTheme: CardThemeData(
color: _surface,
elevation: 0,
shape: RoundedRectangleBorder(
side: const BorderSide(color: _border),
borderRadius: BorderRadius.circular(6),
),
margin: EdgeInsets.zero,
),
cardTheme: CardThemeData(
color: _surface,
elevation: 0,
shape: RoundedRectangleBorder(
side: const BorderSide(color: _border),
borderRadius: BorderRadius.circular(6),
),
margin: EdgeInsets.zero,
),
inputDecorationTheme: InputDecorationTheme(
isDense: true,
filled: true,
fillColor: _bgSunken,
hintStyle: const TextStyle(color: _textMute),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _accent, width: 1.5),
),
),
inputDecorationTheme: InputDecorationTheme(
isDense: true,
filled: true,
fillColor: _bgSunken,
hintStyle: const TextStyle(color: _textMute),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _accent, width: 1.5),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _accent,
foregroundColor: const Color(0xFF0D1020),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: _accent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _accent,
foregroundColor: const Color(0xFF0D1020),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: _accent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
),
),
// Pill-style chips (matches the Projects row in the dashboard).
chipTheme: ChipThemeData(
backgroundColor: _surface,
side: const BorderSide(color: _borderHi),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
// Pill-style chips (matches the Projects row in the dashboard).
chipTheme: ChipThemeData(
backgroundColor: _surface,
side: const BorderSide(color: _borderHi),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
iconTheme: const IconThemeData(color: _textDim, size: 14),
iconTheme: const IconThemeData(color: _textDim, size: 14),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: _surfaceHi,
border: Border.all(color: _borderHi),
borderRadius: BorderRadius.circular(3),
),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: _surfaceHi,
border: Border.all(color: _borderHi),
borderRadius: BorderRadius.circular(3),
),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
),
scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll(_border),
thickness: const WidgetStatePropertyAll(6),
radius: const Radius.circular(3),
),
);
scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll(_border),
thickness: const WidgetStatePropertyAll(6),
radius: const Radius.circular(3),
),
);
}
/// Raw color tokens — use when Material widgets can't carry the meaning.
+29 -29
View File
@@ -63,33 +63,33 @@ class MidnightTheme {
);
static ThemeData get data => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF0B1220),
secondary: _synKeyword,
onSecondary: Color(0xFF0B1220),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF0B1220),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14),
);
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF0B1220),
secondary: _synKeyword,
onSecondary: Color(0xFF0B1220),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF0B1220),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14),
);
}
+29 -29
View File
@@ -65,33 +65,33 @@ class PaperTheme {
);
static ThemeData get data => ThemeData(
useMaterial3: true,
brightness: Brightness.light,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.light(
primary: _accent,
onPrimary: Color(0xFFFBF8F1),
secondary: _info,
onSecondary: Color(0xFFFBF8F1),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFFFBF8F1),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _text, size: 14),
);
useMaterial3: true,
brightness: Brightness.light,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.light(
primary: _accent,
onPrimary: Color(0xFFFBF8F1),
secondary: _info,
onSecondary: Color(0xFFFBF8F1),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFFFBF8F1),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _text, size: 14),
);
}
+30 -30
View File
@@ -65,34 +65,34 @@ class TerminalTheme {
);
static ThemeData get data => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF000000),
secondary: _info,
onSecondary: Color(0xFF000000),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF000000),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
// Terminal mockups go full-mono even for display.
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14),
);
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF000000),
secondary: _info,
onSecondary: Color(0xFF000000),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF000000),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
// Terminal mockups go full-mono even for display.
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14),
);
}
+1 -7
View File
@@ -10,13 +10,7 @@
import 'dart:ui' show Color;
class ClideTheme {
const ClideTheme({
required this.name,
required this.dark,
required this.subtitle,
required this.palette,
required this.syntax,
});
const ClideTheme({required this.name, required this.dark, required this.subtitle, required this.palette, required this.syntax});
final String name;
final bool dark;
final String subtitle;
@@ -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

+428
View File
@@ -0,0 +1,428 @@
# fable-ous.md
*A fable about clide, as told by Fable.*
*Produced by 13 parallel subsystem reviewers reading ~58k LOC of Dart, ~100 raw
findings put through 70 adversarial verification passes (exactly one finding was
refuted — it had missed D-14), 5 feature-ideation lenses, and a full read of the
pql planning vault: 94 confirmed decisions, 24 open questions, 11 rejected
alternatives, 358 tickets. Everything below cites real file:line evidence that a
skeptical second agent re-read and failed to knock down.*
---
## TL;DR scoreboard
| Dimension | Verdict |
|---|---|
| Build health | **Green.** `make analyze` 0 issues, 1383 tests pass, format clean, coverage 95.30% over the 95 floor |
| Documentation discipline | **Best-in-class.** Near-every file cites its D-NNN/T-NNN; zero TODO/FIXME debt in 58k LOC |
| Honesty | **High.** CHANGELOG claims verified against code; stubs self-describe as stubs |
| Real bugs found | ~14 distinct high-severity, ~35 medium — mostly in process lifecycle, a11y, and terminal conformance |
| Systemic risks | Broadcast-streams-without-replay, silent `catch (_)`, kernel lookup in `dispose()`, copy-paste drift |
| Release process | **Stalled.** No git tag since v2.1.0 despite five CHANGELOG releases; `ci/release.sh` is a stub |
| Killer-feature headroom | Enormous — the moats (owned agent loop, owned renderer, pql vault) are real and barely exploited |
The fable in one sentence: **a castle with exceptional masonry, a few unlocked
side doors, and a dragon hoard of features in the basement nobody has spent yet.**
---
## Part I — The state of the realm (what's genuinely excellent)
Credit where due, because this codebase does several things better than most
production repos:
- **Governance traceability is real, not ceremonial.** Nearly every non-trivial
declaration carries its ticket/decision id. Multiple reviewers independently
called it "the best I've seen at this scale." Code-to-decision drift is
*auditable from the code itself* — and indeed most drift findings below were
found exactly that way.
- **Schema-validated IPC dispatch (D-74)** is beautifully executed: schemas
co-registered with handlers, the MCP tool surface and `clide capabilities`
both generated from the same registry (`lib/src/daemon/dispatcher.dart:90-137`)
so three surfaces cannot drift apart.
- **The keymap subsystem (D-82)** — layered precedence, headless
`SequenceMatcher`, clock-injected `ModifierTapTracker` — is exemplary,
near-fully test-mirrored design.
- **`ClideTappable`, the anchored-overlay/menu family (D-88), reduced-motion
honoring in every animated primitive** — the owned widget layer is coherent
and disciplined.
- **Battle scars are encoded where they happened.** `pumpAsync` documents why
`pumpAndSettle` wedges; `KernelFixture` encodes the T-280 teardown-hang fix;
flake postmortems live as comments in the test that almost shipped them.
- **Security instincts**: 0600 socket + live-listener probe before unlink, git
argv hardening with `--` terminators, `path_safety.dart` documenting its
threat models inline, workspace-relative binary resolution forbidden (T-98).
- **Zero TODO/FIXME/HACK** across the tree. The clean-board policy is lived.
Patterns worth *extending* (the reviewers kept wishing other code did this):
the coalesced-notify `Timer(Duration.zero)` trick in `ConversationController`,
the pure-Dart-core/thin-Flutter-shell split, and `RecordingEventSink`-style
event-driven test waits.
---
## Part II — The bestiary (bugs I would fix, in order)
### 🐉 Dragons (high severity, verified, fix this week)
1. **Every naturally-exited PTY leaks its master fd — forever.**
`lib/src/pty/native_pty.dart:444-450` — on child EOF, `_reap()` sets
`_dead = true` but never closes `_fd`; a later `close()` short-circuits at
`if (_dead) return;` (line 460) so `_nativeClose(_fd)` (line 477) never runs.
Two reviewers found this independently. Every terminal/Claude pane whose
child exits on its own leaks an fd and a pty device for the life of the app.
2. **The Claude child process is observed only via stdout.** Three reviewers
converged here. `lib/builtin/claude/src/stream_json_session.dart:43-78`
stderr is *never drained* (≥64KB of `--verbose` spew = pipe fills = child
blocks mid-turn = the flagship pane wedges with zero diagnostics), and
nothing watches `exitCode` or `onDone` (line 303), so a crashed/dead
session just looks… thoughtful. Drain stderr into a ring buffer, surface a
terminal `SessionEnded` state. This also intersects T-283 (no resume
timeout/fallback).
3. **The MCP HTTP server exposes the entire dispatcher with zero auth.**
`lib/src/ipc/mcp_server.dart:138-195`, started unconditionally at boot
(`lib/main.dart:174-180`). D-71's threat model ("another user on the same
host should not drive my IDE") is enforced with 0600 on the unix socket —
and then bypassed wholesale by an unauthenticated localhost HTTP port that,
since D-86, serves *every* clide verb as a tool. Generate a token in the
lock file (Claude Code's own `/ide` lock format has a slot for it), require
the header.
4. **`editor.open`/`editor.save` skip path confinement entirely.**
`lib/src/editor/registry.dart:215-219` returns absolute paths verbatim, no
`..` normalization, no `path_safety` call — an unconfined read *and write*
primitive over IPC while `files.read` is carefully guarded. Same family:
**`search.replace` silently ignores its include/exclude globs**
(`lib/src/search/replace_engine.dart:124-143`) and will happily rewrite
files outside the filter the user typed; and **`listDir`'s symlink
detection is dead code** (`lib/src/files/listing.dart:46-54``stat()`
follows links, so `isSymlink` is always false) which means `walkFiles`
descends symlinked dirs the docs claim it skips.
5. **Closing a terminal pane never closes the shell.**
`lib/builtin/terminal/src/terminal_pane.dart:131-137` calls
`ClideKernel.of(context)` from `dispose()` — illegal ancestor lookup,
swallowed by `catch (_)` — so `pane.close` is never sent and the backend
PTY + daemon pane leak. The same idiom leaks the settings listener in every
disposed `ClaudePane` (`claude_pane.dart:460-466`). Combined with dragon #1
this is a two-stage leak pipeline. Cache the kernel ref in
`didChangeDependencies`, delete the catch-all.
6. **Project switch leaks the entire previous workspace.**
`lib/main.dart:335-344` — a new dispatcher gets fresh `PaneRegistry`,
`FilesService`, `EditorRegistry`, etc., but nothing calls the old set's
`shutdown()` methods (which exist and have zero callers). Old watchers keep
emitting into the new workspace's bus.
7. **T-274's root cause, found and verified:** the status bar is empty because
`statusStream` is a plain broadcast controller — the `system/init` event
fires while `spawn()` is still awaiting a 256KB transcript-tail read, before
the pane ever subscribes (`claude_pane.dart:322`,
`session_orchestrator.dart:222-225`). Seed from `session.status` on bind, or
make it replay-latest. (The broadcast-without-replay shape is a recurring
bug factory — see Part III.)
8. **Auto-scroll yanks a scrolled-up reader to the bottom on every streamed
token.** `conversation_view.dart:268-277` — the `_atBottom` pin exists but
is only consulted on viewport *resize*, not on new items. Anyone reading
earlier output during a long streaming reply is dragged to the bottom
continuously. One-line gate + the missing twin test.
9. **The terminal can crash on garbled output.** SGR 38/48 extended-color
parsing does unguarded `params[i + 1]` lookahead
(`lib/src/terminal/src/core/escape/parser.dart:501-516`) — `printf '\e[38m'`
throws RangeError inside `Terminal.write`. An emulator must never throw on
hostile bytes. While in there: colon-form SGR sub-parameters are mangled
into bogus params.
10. **A11y has drifted despite being a Tier-0 contract.** Two independent
verified findings: `ClideCollapserCard`'s `excludeSemantics: true` wipes
*every expanded child* from the a11y tree
(`lib/widgets/src/clide_collapser_card.dart:92-101`) — a screen-reader user
can expand a run and hear nothing; and the three a11y gate tests
hand-enumerate their subjects and have measurably fallen behind `lib/`
(contrast checks fewer themes than `main.dart:443-454` loads; i18n checks 4
of 8 namespaces). The gates stay green while covering less. Make `lib/`
export the canonical lists and iterate them in the gates.
### 🦂 Scorpions (medium — real, will sting eventually)
- **D-72's "serial dispatch" isn't.** `lib/src/ipc/server.dart:151-180` uses an
`async` onData without pausing the subscription — pipelined requests
interleave, and the shared `StringBuffer` framing can drop/double lines.
`client.cast<List<int>>().transform(utf8.decoder).transform(LineSplitter())`
+ `await for` fixes framing, UTF-8 split chunks, and serialization at once.
- **Split-chunk UTF-8 corruption is endemic at byte→String seams**: the
terminal's only ingestion API is `write(String)` (`terminal.dart:218`) so
both consumers decode per-chunk; `FileTailFollower` starts mid-character by
construction. Add `writeBytes()` with a persistent chunked decoder.
- **`Orchestrator.spawn()` races itself** — check-then-act across two awaits;
concurrent spawns for one id leak a live `claude` process
(`session_orchestrator.dart:191-249`). Hold a `Map<String, Future<ManagedSession>>`.
- **Fork panes misbehave on `/clear`, `/resume`, `/fork`**
`widget.forkSourceId` wins forever, so `/clear` *re-forks the original
conversation* instead of clearing (`claude_pane.dart:266-281`).
- **Settings persistence corrupts maps-inside-lists on write**
(`settings.dart:199-219` emits `toString()`), breaking the documented keymap
overlay across restarts; writes are non-atomic and a parse failure silently
resets all settings.
- **Extension activation isn't transactional** — a throw mid-contribution
leaves contributions mounted while the extension records as failed; retry
double-applies (`extensions_manager.dart:133-192`). Plus: disabling an
extension ignores dependents, and registries clobber silently on id
collision — fine among curated builtins, hazardous the day Tier-6 Lua lands.
- **Terminal conformance debt** (the fork fixes what it trips over but has no
vttest-style suite): HTS is a no-op (`isSetAt` instead of `setAt`,
`terminal.dart:423`), DECCKM is tracked but never consumed, legacy mouse rows
are off-by-one *and the test enshrines the bug*, CPR replies 0-based where
every real terminal is 1-based, scrollback is maintained but structurally
unreachable (`ViewportOffset.zero()` pinned every build,
`terminal_view.dart:224`).
- **Markdown renderer**: hard breaks and images render as empty text (words
glue together; `clide_markdown.dart:408-410`), and the whole document
re-parses with sync `existsSync()` calls *inside build* on every streaming
delta — multiplied by the conversation view re-deriving everything O(n) per
notification and token streaming re-encoding the full reply per delta
(O(n²) churn, `stream_json_session.dart:410-431`).
- **Terminal panes spawn in `Directory.current`**, not the open project root
(`terminal_pane.dart:69`) — desktop launches get `$HOME` shells.
- **The Notifications service renders nowhere**`notify.dart` has zero widget
consumers; cli_install's dogfood warnings vanish into an unrendered list
while `ToastService` sits right there.
- **Welcome screen no-ops**: "Clone from git…" and "Start a Claude session"
advertise shortcuts that don't exist and do nothing on tap
(`welcome_view.dart:173-174`).
- **`make test-e2e`/`ui-dev`/`ui-smoke` are dead** — `tools/ui/*.sh` still `cd`
into the removed `app/` directory; the staged Gitea CI workflow would fail in
three independent ways on activation, while D-32 calls it "ready."
### 🐀 Rats (low, but they breed)
Dead code worth a one-day extermination sweep: the entire legacy free-function
git API (~250 LOC duplicating `GitClient`, kept alive only by tests, *with its
own latent pipe-deadlock bug*), `ToolCheck`, ~60% of `ffi/libc.dart` (fd-passing
era), `GraphView` (unreachable placeholder), `ColumnHat` (duplicated
line-for-line in app.dart, kept alive by a zero-coverage test), the tmux-era
team pipeline (`TranscriptPublisher`, `TeamMemberJoined` — *nothing emits these
events*, yet the team roster UI still listens to them exclusively, meaning team
tiles are populated by ghosts), the dead `ptyc` binary still committed in
`native/linux-x64/` against D-62/D-63, and `mocktail` — pinned, documented in
D-25 as the IO-mocking strategy, and imported by exactly zero files.
---
## Part III — Patterns I would change (the systemic stuff)
1. **Broadcast streams that carry state need replay-latest.** This one shape
caused T-274, the meta sidebar's manual compensation, and the
prompt-stream's `initialData` workaround. Write a tiny `ValueStream` wrapper
once; retrofit `statusStream`, `busyStream`, `pendingPromptStream`.
2. **Ban `catch (_) {}` on I/O and lifecycle paths.** The silent-swallow idiom
turned an illegal-lookup-in-dispose into two resource leaks and turned
process-spawn failures into blank panes. Cleanup paths may swallow; spawn,
read, and dispose paths must log through the kernel Logger they already have.
3. **Sync I/O in async handlers on the single isolate.** `files.read` does a
sync 10MB read; the replace engine reads and rewrites the workspace
synchronously *while grep right next to it fans out to isolates per D-79*.
Decide the rule (offload above N KB), write it into a D-record, apply it.
4. **Path confinement belongs at the dispatch layer, not per-verb.** files.read
remembered, search.replace half-remembered, editor.* forgot. A confinement
check keyed off the co-registered schema (the registry already knows which
params are paths) ends the per-verb lottery.
5. **Copy-paste is the repo's main duplication tax.** The welcome screen clones
FileActions' entire open-folder flow verbatim; palette and quick-open are
~230-line near-twins; three private "tail a growing file" implementations in
the claude builtin alone; five hand-rolled `_userErr` helpers; five
copy-pasted git test sandboxes (none isolating host git config); two
parallel ANSI flag enums that already drifted (strikethrough is stored but
never painted). Each is small; together they're how a solo-dev repo rots.
6. **Hand-enumerated lists drift; export the truth.** Bundled themes (already
drifted between `main.dart` and the testmode harness — catppuccin is
silently unvalidated), a11y gate subjects, i18n namespaces. One exported
const each, consumed by both sides.
7. **The claude builtin returns `ok` with an `error` payload in 16 handlers**,
drifting from the D-6 exit-code contract every other subsystem honors. A
scripted `clide claude.agent.set-permission-mode bogus` exits 0 today.
8. **God-files**: `app.dart` (1187 LOC, five concerns — split plan is in the
findings), `claude_meta_sidebar.dart` (1192), `parser.dart` (1139, T-123
already exists — and the split should also fix `_consumeCsi` discarding
intermediate bytes, which permanently blocks DECSCUSR/DECSTR).
9. **Docs drift at the front door**: CLAUDE.md and README still say "tmux owns
Claude session persistence (D-41)" — superseded by D-75/D-77 per
`docs/architecture.md`; README says "Pre-v2.0 (2.0.0-dev)" at v2.3.3 and
headlines "canvas and graph surfaces" that are a 17-line stub and a flat
ListView respectively. clide's honesty is its brand; the README is the one
place currently off-brand.
10. **Close the release loop.** Five CHANGELOG releases since the last git tag;
`ci/release.sh` exits 64 and references the dissolved sidecar; the pre-push
fast path's safety argument cites "release CI on tagged versions" that
doesn't exist; and the fast path skips ALL tests for pushes touching
`test/`, `ci/`, or the hook itself. Back-tag 2.2.02.3.3, add tagging to
the git-commit skill ritual, widen the fast-path regex. (This is also the
blocking prerequisite your own T-47 refinement identified for self-update.)
---
## Part IV — Killer features (the dragon hoard)
Five ideation lenses, 27 proposals, deduplicated and ranked. The convergence
test mattered: **two lenses independently invented the flight recorder, and two
independently invented the visual canvas round-trip** — when separate agents
with different briefs land on the same feature, that's the market talking.
Clide's structural moats, verified against code: it *spawns and owns* the agent
process (D-77/D-78) where competitors are sandboxed extension guests; it owns
every pixel (terminal, markdown, canvas); everything is local-and-committed
(transcripts, costs, decisions, tickets) where competitors' business models
require cloud custody; and the pql vault is structured planning data no
mainstream IDE has an analogue for.
### Tier 1 — do these (high leverage, mostly M-effort, plumbing exists)
1. **Agent Blame + Session Flight Recorder** `[L]` — gutter action on any line:
*which session, which turn, which prompt, which permission grant, what it
cost* — opening the native conversation at the exact `tool_use`. Timeline
scrubber to replay a session. The transcript pipeline
(`transcript_reader.dart`, `session_index.dart`) already parses everything
needed. Cursor/Copilot cannot ship this: their logs live server-side by
business design. *Two lenses converged here.*
2. **Context X-ray** `[M]` — per-card token attribution ("this 40KB Bash tail
is 12% of your window") + a real compaction indicator. `stream_json_session.dart`
already parses usage and contextWindow per event; the renderer owns the
cards. Fixes T-244 (invisible compaction) as a side effect. Context is the
scarcest resource in agent pairing and every tool renders it as one opaque
percentage.
3. **Trust Ledger + decision-aware permission prompts** `[M]` — every
permission rule with provenance (which prompt, which session, which ticket),
ticket-scoped expiry; and when a `can_use_tool` request arrives, chip the
relevant D-record onto the card (edit touching pubspec.yaml → D-31
prefer-zero-deps, one keystroke to deny *with the decision cited*).
Governance stops being documentation and becomes live agent policy. No
competitor has a queryable in-repo decision system to even attempt this.
4. **Agent Activity HUD** `[M]` — four backlog tickets and one open question
are secretly one feature: build T-59's `OperationsRegistry` once and feed it
git/pql progress (T-59), sidebar badges (T-58), compaction state (T-244),
the status strip (T-274), with Q-34's budget slot reserved. Fix the T-274
plumbing bug first or the HUD inherits blank-slot syndrome.
5. **Active Ticket Context** `[S]`*cheapest win in the whole list* — picking
up a ticket binds it to the session: a "working on T-244" chip, auto
`in_progress` flip, `(T-NNN)` pre-suggested in commit messages, changelog
reminder on done. Makes kanban ambient instead of homework, and makes every
trail/ledger feature below reliable.
### Tier 2 — the differentiators (L/XL, each could headline a release)
6. **Twin-timeline rewind** `[L]` — snapshot the worktree as hidden git refs
(`git write-tree``refs/clide/checkpoints`) at every turn boundary, keyed
to turn uuid; every user-message card gains "restore files to before this."
Claude's `/rewind` only restores what Claude itself edited; clide owns both
timelines.
7. **Visual Dialog** `[L]` — one bidirectional scene schema: Claude draws
(D-91 canvas cards), the user annotates in the interaction zone (T-260), and
the annotations return as *structured geometry + flattened PNG*, not prose.
Merge the T-317/T-318 and T-260 specs into one protocol before they become
two dialects. *Two lenses converged here.*
8. **Immortal terminals** `[M]` — T-258 (terminal as editor-mode peer) fused
with T-325 live-tails: any long process — user shell *or* agent-spawned
build — promotes to a full tmux-backed surface that survives restart.
"The build that never dies" is structural for clide, a plugin fantasy for
Electron. Resolve Q-27 (swap vs split) as part of it, as T-258 already notes.
9. **Local cost ledger** `[M]` — per-turn cost/tokens persisted against the
active pql ticket; the board shows what each feature actually cost.
Flat-subscription opacity is the competitors' business model; turn-level
local cost data is clide's birthright. (Tokens primary, dollars advisory —
subscription auth reports notional costs.)
10. **Label-routed work queues / ticket dispatch** `[M→XL]` — T-277 labels +
the shipped pick-up path turn the board into an agent control surface;
the XL extension dispatches a ticket to a teammate session in an isolated
git worktree (SpawnSpec.cwd already exists). Local, no-telemetry
background agents with the work item, isolation, review surface, and audit
trail all in-repo.
### Tier 3 — moonshots (XL, pick one per quarter, they compound)
11. **Semantic terminal** — OSC 133 markers (clide spawns the shell, injection
is trivial) lift scrollback into foldable command regions with recognizers
for test runners and stack traces; real widgets between rows is something
xterm.js structurally cannot do. *Note: fix the scrollback-unreachable bug
first — a semantic scrollback you can't scroll is a koan.*
12. **Living codebase map** — tree-sitter imports + pql links + git churn on
the owned canvas, with live agent heat from `tool_use` events: watch Claude
*move through your codebase* in real time.
13. **Live mixed documents** — fenced blocks in the owned markdown renderer
become live embeds (canvas scenes, pql query results, decision cards,
confirm-gated command buttons). Notebook-grade, zero webview. The
`ClideMarkdownHooks` seam already exists.
14. **Remote Claude over SSH** — T-329 is fully ticketed and undersold: agent
on the buildbox, permission prompts rendering natively local. VS Code
Remote moves the editor; nobody remotes the *agent control channel*.
15. **Sealed-workspace mode** — an egress-audit proxy around the whole agent
stack, operationalizing D-60/D-64 into a provable property. The one
feature in this list competitors *cannot* copy without breaking their own
products.
### Honorable mentions
Plan-to-Board bridge (ExitPlanMode approval files the plan as a ticket tree),
Release Cockpit (renders `[Unreleased]` with word-count badges + one-action
release cut — would also unstall Part III #10), Daily Helm ("since you were
last here" pulse on the welcome screen, computed from data the repo already
commits), Total-recall conversation search (D-79 grep over the transcript
corpus + fork-from-here), Shared Gaze (attach editor selection/scroll context
to each outgoing turn), Speakable Layouts (`clide layout apply review.yaml`
the agent stages your workspace), Agent fleet tray (enumerate per-workspace
sockets, show every repo's agent state — *requires fixing T-247's stale-socket
litter first*), Governance Graph (the D/Q/R/T web as a navigable map — gives
the placeholder graph view a flagship dataset).
---
## Part V — If I were you, Monday morning
1. **One leak-fix commit**: PTY fd on natural exit + kernel-lookup-in-dispose
(terminal & claude panes) + project-switch service disposal. Three findings,
one theme, one afternoon.
2. **One Claude-resilience commit**: drain stderr, watch exitCode, seed status
on bind, gate auto-scroll on `_atBottom`. The flagship pane stops having
silent failure modes.
3. **One security commit**: MCP auth token + editor.* path confinement +
search.replace glob filter + symlink-walk fix.
4. **One rat-extermination day**: dead git API, ToolCheck, libc bindings,
ColumnHat, GraphView, tmux-era team pipeline, ptyc binary, mocktail. The
diff is gloriously red and the coverage denominator thanks you.
5. **Tag your releases.** Five releases of honest changelog work are currently
unaddressable commits.
6. Then go build the **Active Ticket chip** (S!) and the **Context X-ray**, and
let clide start showing people things no other IDE can.
---
*Findings methodology: every medium/high claim above survived an independent
adversarial re-read of the cited lines (one claim did not — the proposed
"can't-disable core extensions" guard, which D-14 deliberately rejects, so it
stays out of this report). The full per-finding evidence, severities, and
suggested fixes live in the review transcripts; ~34 additional low-severity
findings were verified by citation only.*
*— Fable, 2026-06-11*
+29 -3
View File
@@ -66,14 +66,14 @@ You might also want, project-permitting:
- [D-22: WCAG-AA contrast gate on bundled themes](decisions/accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes) — _accessibility_
- [D-23: Test pyramid — seven layers](decisions/testing.md#d-23-test-pyramid--seven-layers) — _testing_
- [D-24: Golden tests — primitives only, Alchemist + Ahem](decisions/testing.md#d-24-golden-tests--primitives-only-alchemist--ahem) — _testing_
- [D-25: Mocks — mocktail at IO, hand-rolled fakes for ChangeNotifiers](decisions/testing.md#d-25-mocks--mocktail-at-io-hand-rolled-fakes-for-changenotifiers) — _testing_
- [D-25: Mocks — hand-rolled fakes throughout; mocktail dropped](decisions/testing.md#d-25-mocks--hand-rolled-fakes-throughout-mocktail-dropped) — _testing_
- [D-26: Web driver — raw Playwright + Flutter semantics](decisions/testing.md#d-26-web-driver--raw-playwright--flutter-semantics) — _testing_
- [D-27: Startup regression gate](decisions/testing.md#d-27-startup-regression-gate) — _testing_
- [D-28: Test organisation — mirror `lib/` in `test/`](decisions/testing.md#d-28-test-organisation--mirror-lib-in-test) — _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-31: Prefer-zero-deps, exact-pin](decisions/tooling.md#d-31-prefer-zero-deps-exact-pin) — _tooling_
- [D-32: CI — Gitea primary, Linux-only runners, not yet activated](decisions/tooling.md#d-32-ci--gitea-primary-linux-only-runners-not-yet-activated) — _tooling_
- [D-32: CI — GitHub Actions, Linux + Windows runners, active](decisions/tooling.md#d-32-ci--github-actions-linux--windows-runners-active) — _tooling_
- [D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts](decisions/tooling.md#d-33-golden-output-ignore-pattern--coverage-excludes-output-not-scripts) — _tooling_
- [D-34: Q&D record system](decisions/process.md#d-34-qd-record-system) — _process_
- [D-35: Kanban / waterfall, not Scrum](decisions/process.md#d-35-kanban--waterfall-not-scrum) — _process_
@@ -133,6 +133,15 @@ You might also want, project-permitting:
- [D-89: inline pasted-image thumbnails that expand to the lightbox](decisions/design.md#d-89-inline-pasted-image-thumbnails-that-expand-to-the-lightbox) — _design_
- [D-90: clide:// deep links — paranoid allowlist + user confirmation](decisions/architecture.md#d-90-clide-deep-links--paranoid-allowlist--user-confirmation) — _architecture_
- [D-91: Unified conversation drawing card backed by a canvas renderer](decisions/architecture.md#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer) — _architecture_
- [D-92: Ship pql bundled with clide](decisions/tooling.md#d-92-ship-pql-bundled-with-clide) — _tooling_
- [D-93: clide writes no directories of its own into the workspace](decisions/architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) — _architecture_
- [D-94: Workspace mode is a first-class, extensible declared capability](decisions/architecture.md#d-94-workspace-mode-is-a-first-class-extensible-declared-capability) — _architecture_
- [D-95: Workspace validity and onboarding flow](decisions/architecture.md#d-95-workspace-validity-and-onboarding-flow) — _architecture_
- [D-96: Remote-execution footprint — no-install ssh-exec](decisions/architecture.md#d-96-remote-execution-footprint--no-install-ssh-exec) — _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-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
@@ -152,7 +161,6 @@ You might also want, project-permitting:
- [Q-17: Icon set growth](questions/process.md#q-17-icon-set-growth) — _process_
- [Q-18: Theme hot-reload in release builds](questions/process.md#q-18-theme-hot-reload-in-release-builds) — _process_
- [Q-20: Kernel DB service — namespaced SQL access?](questions/process.md#q-20-kernel-db-service--namespaced-sql-access) — _process_
- [Q-23: SSH-remote development — run clide against a remote workspace](questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace) — _architecture_
- [Q-25: Body text face — mono everywhere vs Josefin Sans UI + mono code](questions/architecture.md#q-25-body-text-face--mono-everywhere-vs-josefin-sans-ui--mono-code) — _architecture_
- [Q-26: Small screen layout (< 1000px)](questions/architecture.md#q-26-small-screen-layout--1000px) — _architecture_
- [Q-27: Two-editor split](questions/architecture.md#q-27-two-editor-split) — _architecture_
@@ -160,6 +168,22 @@ You might also want, project-permitting:
- [Q-30: Focus behavior when editor is dirty and viewer is peeked](questions/architecture.md#q-30-focus-behavior-when-editor-is-dirty-and-viewer-is-peeked) — _architecture_
- [Q-31: XWayland fallback for frameless — proper Wayland protocol needed](questions/architecture.md#q-31-xwayland-fallback-for-frameless--proper-wayland-protocol-needed) — _architecture_
- [Q-34: How + when to surface the account/team token budget given upstream doesn't expose it](questions/architecture.md#q-34-how--when-to-surface-the-accountteam-token-budget-given-upstream-doesnt-expose-it) — _architecture_
- [Q-35: Agent Blame + Session Flight Recorder — implement?](questions/design.md#q-35-agent-blame--session-flight-recorder--implement) — _design_
- [Q-36: Context X-ray — implement?](questions/design.md#q-36-context-x-ray--implement) — _design_
- [Q-37: Trust Ledger + decision-aware permission prompts — implement?](questions/design.md#q-37-trust-ledger--decision-aware-permission-prompts--implement) — _design_
- [Q-38: Agent Activity HUD — implement?](questions/design.md#q-38-agent-activity-hud--implement) — _design_
- [Q-39: Active Ticket Context — implement?](questions/design.md#q-39-active-ticket-context--implement) — _design_
- [Q-40: Twin-timeline rewind — implement?](questions/design.md#q-40-twin-timeline-rewind--implement) — _design_
- [Q-41: Visual Dialog — one bidirectional scene schema — implement?](questions/design.md#q-41-visual-dialog--one-bidirectional-scene-schema--implement) — _design_
- [Q-42: Immortal terminals — implement?](questions/design.md#q-42-immortal-terminals--implement) — _design_
- [Q-43: Local cost ledger — implement?](questions/design.md#q-43-local-cost-ledger--implement) — _design_
- [Q-44: Label-routed work queues / ticket dispatch — implement?](questions/design.md#q-44-label-routed-work-queues--ticket-dispatch--implement) — _design_
- [Q-45: Semantic terminal — implement?](questions/design.md#q-45-semantic-terminal--implement) — _design_
- [Q-46: Living codebase map — implement?](questions/design.md#q-46-living-codebase-map--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-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
- [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
@@ -169,9 +193,11 @@ You might also want, project-permitting:
- [Q-19: (withdrawn)](questions/process.md#q-19-withdrawn) — _process_
- [Q-21: Pql absorbs planning vs keeps separate](questions/architecture.md#q-21-pql-absorbs-planning-vs-keeps-separate) — _architecture_
- [Q-22: Ticket persistence strategy](questions/architecture.md#q-22-ticket-persistence-strategy) — _architecture_
- [Q-23: SSH-remote development — run clide against a remote workspace](questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace) — _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-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
+66 -1
View File
@@ -23,6 +23,7 @@ Core, rendering, IPC, kernel, panel manager.
### D-4: Ignore file strategy
- **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage)
- **Amendment (2026-06-11):** Per [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), clide no longer writes a `.clide/` directory into the repo; only `.pql/` is added to `.gitignore` at install time. The `.clide/` mention below is retained for history.
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
- **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept.
- **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface.
@@ -72,7 +73,7 @@ Core, rendering, IPC, kernel, panel manager.
### D-10: State management — `ChangeNotifier` + `ListenableBuilder`
- **Date:** 2026-04-21
- **Decision:** Per-feature state uses `ChangeNotifier` exposed through a feature facade (singleton-per-kernel); widgets subscribe via `ListenableBuilder`. No Riverpod, Provider, BLoC, or Redux.
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-25](testing.md#d-25-mocks--mocktail-at-io-hand-rolled-fakes-for-changenotifiers)). Violates [D-31 prefer-zero-deps](tooling.md#d-31-prefer-zero-deps-exact-pin) otherwise. See [R-8](../rejected/architecture.md#r-8-riverpod--provider--bloc-for-state).
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-25](testing.md#d-25-mocks--hand-rolled-fakes-throughout-mocktail-dropped)). Violates [D-31 prefer-zero-deps](tooling.md#d-31-prefer-zero-deps-exact-pin) otherwise. See [R-8](../rejected/architecture.md#r-8-riverpod--provider--bloc-for-state).
- **Cost:** No codegen ergonomics; manual `notifyListeners()` discipline. The `ListenableBuilder.listenable` contract rejects rebuilds outside the subscribed notifier — intentional.
- **Raised by:** 2026-04-21 planning.
@@ -190,6 +191,7 @@ Core, rendering, IPC, kernel, panel manager.
### D-53: State persistence across sessions
- **Date:** 2026-04-22
- **Amendment (2026-06-11):** Per [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), this state moves from in-repo `.clide/settings.yaml` to user-scope storage keyed by workspace-path hash. The `.clide/settings.yaml` references below are retained for history.
- **Decision:** The following layout state is persisted across app restarts: collapse state of left and right panels, active left section (tickets/decisions/files/git/pr), active right context type, pql pane expanded/collapsed, editor split ratio when open, fuzzy find recent picks. Stored via `SettingsStore` in project-scoped settings (`.clide/settings.yaml`).
- **Rationale:** Users expect their workspace layout to survive restarts. Without persistence, every launch starts at the default layout preset, which is disorienting when the user has customised their column widths and panel states.
- **Cost:** Adds write-on-change to several layout operations. Must handle migration if the setting keys evolve. `.clide/settings.yaml` is already gitignored, so personal layout state stays personal.
@@ -469,4 +471,67 @@ Core, rendering, IPC, kernel, panel manager.
- **Relationship:** Narrows [Q-4](../questions/architecture.md#q-4-canvas-schema-compatibility-with-obsidian) — clide's canvas is its own HTML-canvas-inspired JSON; Obsidian `.canvas` is an *import* format via conversion, not the native schema. Consumes the stdin/`--file` JSON input plumbing (T-315). Subsumes the standalone icon card (T-313) and image-annotation work (T-316) as templates of this card. **Merges the former Tier-5 "canvas and graph view" epic (T-7) into one canvas epic (T-317):** the Tier-5 canvas *pane* (T-322, interactive/editable — distinct from the display-only conversation card) and graph *view* (T-323) consume the same shared renderer; T-7 is cancelled as superseded. The conversation drawing card stays display-only per [D-78]; the canvas pane is a full interactive pane. (D-17 "panels are extension-shaped" is unaffected and still governs the panes.)
- **Raised by:** 2026-06-10 — user, while refining the icon-preview card (T-313): "make it all into one drawing card that receives a json input and selects based on the context inside the json what to draw … pull the entire thing closer to a dynamic canvas than a bunch of one-off renderers." Clarified the model is HTML `<canvas>` (not Obsidian's), templates-over-primitives, per-object label/description, and reuse as the `.canvas` renderer; before/after comparisons, SVGs, icons, and graphs all become things you send into the card.
### D-93: clide writes no directories of its own into the workspace
- **Date:** 2026-06-11
- **Decision:** clide-the-IDE contributes **zero** directories to a workspace. The only tool-owned directories physically written into a repo are `.git/` (git's, brought by the user) and `.pql/` (pql's repo data — index + planning changelog). All IDE-local per-workspace state — panel collapse, active sections, split ratios, project theme, recent picks ([D-53](#d-53-state-persistence-across-sessions)), and any future per-repo extension DB — moves to **user scope**, stored outside the repo and keyed by a hash of the workspace path, the same convention the IPC socket already uses ([D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)). If clide ever needs shared, *committed* per-repo config, it lives as clide-owned keys in `.pql/config.yaml` (the existing `ignore_files:` precedent, [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)/[D-4](#d-4-ignore-file-strategy)) — never a new directory.
- **Context:** clide previously wrote project-scoped settings to an in-repo `.clide/` directory ([D-53](#d-53-state-persistence-across-sessions)). Even gitignored, that put an IDE scratch dir physically inside the user's repo. "Written in the repo" — not "checked in" — is the thing being minimized.
- **Rationale:** One tool dir in the repo (`.pql/`), and it earns its place because it holds data *about* the repo. Personal IDE state is not repo data, so it belongs in user scope — exactly where [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic) and [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) already keep per-workspace runtime state. Nothing shared is lost: `.clide/settings.yaml` was already gitignored, so it was never committed anyway.
- **Cost:** A one-time migration of any existing in-repo `.clide/settings.yaml` to user scope, then dropping the dir. Per-workspace state inherits [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)'s trade-off: moving or renaming a repo re-keys it and resets personal layout.
- **Amends [D-4](#d-4-ignore-file-strategy):** D-4's clause "`.clide/`) is added to `.gitignore` at install time" is moot — clide no longer writes `.clide/` into the repo. Only `.pql/` is added to `.gitignore` at install time.
- **Amends [D-53](#d-53-state-persistence-across-sessions):** persisted layout state moves from in-repo `.clide/settings.yaml` to user-scope storage keyed by workspace-path hash.
- **Cross-reference:** [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-4](#d-4-ignore-file-strategy), [D-53](#d-53-state-persistence-across-sessions), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed).
- **Raised by:** 2026-06-11 — user: "I am not a fan of IDEs tossing in multiple dirs … only the pql dir which contains repo data gets [written] in repo."
### D-94: Workspace mode is a first-class, extensible declared capability
- **Date:** 2026-06-11
- **Decision:** A clide workspace runs in exactly one **mode** at a time, drawn from an open, extensible vocabulary — initially `edit` (full local read/write; the default) and `read` (read-only; no writable `.pql/`), with `remote`, `ssh`, and `webui` reserved as future values. Every extension declares the modes it supports in its manifest (`modes: [edit, read]`); an extension with no declaration is assumed `edit`-only. The extension host activates an extension only when the active workspace mode is in its declared set — unsupported extensions stay dormant. New modes are added as new vocabulary values **without schema changes**; the open SSH-remote question ([Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace)) is expected to resolve *into* a mode value, not a parallel mechanism.
- **Context:** Read-mode degrade ([D-95](#d-95-workspace-validity-and-onboarding-flow)) needs to know which extensions remain functional without pql and without write access. A boolean `read_mode_safe` would answer only today's question and would not compose with the `remote`/`ssh`/`webui` modes already on the horizon.
- **Rationale:** Modelling capability as a declared mode set is uniform and future-proof — one mechanism the host gates on, one place extensions opt in, and third-party extensions participate by declaring. Reserving the future values now means remote/ssh/webui work plugs into an existing seam instead of inventing its own.
- **Cost:** Every builtin extension must declare its modes (a one-time classification pass); the host gains mode-gating logic; the vocabulary is open-ended and must stay coherent as values accrue. Defaulting an undeclared extension to `edit`-only is conservative but may surprise authors.
- **Cross-reference:** [D-17](extensions.md#d-17-panels-are-extension-shaped-from-day-one), [D-95](#d-95-workspace-validity-and-onboarding-flow), [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace).
- **Raised by:** 2026-06-11 — user: "read_mode_safe: true is not leaving space for further modes (ssh mode, remote mode, webui mode, read mode, edit mode). Prepare it for that."
### D-95: Workspace validity and onboarding flow
- **Date:** 2026-06-11
- **Decision:** A clide workspace is valid only when it is a git repo with an initialized `.pql/`. Two consequences. **(1) Git is a precondition the user owns.** clide never auto-runs `git init`; opening a non-git folder *offers* initialization (**default no**, with a guard that warns when a parent `.git` would make this a nested repo) or lets the user pick another folder. **(2) pql is clide-provisioned.** Because pql now ships bundled ([D-92](tooling.md#d-92-ship-pql-bundled-with-clide)), an uninitialized repo triggers a **required, idempotent** prep flow that reconciles state (virgin / pql-user / partially-init / fully-init / previously-declined) and **discloses exactly what it writes**`.gitignore` entries for `.pql/`, pql's config, and (only on opt-in) git hooks. The mandatory floor is pql **config + index** (the files/query/ignore engine); the **planning layer** (decisions/tickets + the changelog hooks of [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code), which alter the user's git workflow) is a **contextual opt-in** offered when the user first opens the Decisions or Tickets surface — never forced at onboarding. A writable repo with no `.pql/` is *invalid-until-initialized*; a repo clide **cannot** write (read-only mount, no permission) degrades to **read mode** ([D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability)) — file tree, editor, and the pure-Dart content search ([D-79](#d-79-workspace-content-search-is-a-pure-dart-in-process-engine-outside-pql)) stay live; pql-backed surfaces go dark behind a clear banner. A decline is remembered in user scope, keyed by repo path; clide does not re-nag, and an explicit "initialize workspace" command is always available.
- **Context:** [D-4](#d-4-ignore-file-strategy) already specified that `.pql/` is "added to `.gitignore` at install time" — presuming an install-time event that never had a trigger. Bundling pql ([D-92](tooling.md#d-92-ship-pql-bundled-with-clide)) is what makes "pql required" honest: clide can always provide the means to create `.pql/`. This record is that missing trigger.
- **Rationale:** pql is clide's core query/ignore engine, not just the ticket board, so a repo without it is degraded for *core editing*, not only planning — gating on `.pql/` is truthful. Git, by contrast, is a foundational, identity-level user decision (and `git init` in the wrong place is a footgun), so clide offers but never imposes it. Splitting the mandatory config+index from the opt-in planning hooks keeps the invasive git-workflow change consensual and contextual. Read-mode degrade keeps clide usable as an editor on repos it cannot write — consistent with [D-80](#d-80-filesread-allows-trusted-claude-config-roots-beyond-the-workspace)'s read appetite — instead of refusing them outright.
- **Cost:** An onboarding/state-reconciliation flow with a disclosing modal. The installer must handle the known hooks gotcha (`pql init` writes to `.git/hooks` and ignores an existing `core.hooksPath`) — it must not silently clobber a repo that sets `core.hooksPath`. The read-mode path gates extensions by their declared modes ([D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability)) and must provide graceful fallbacks where pql surfaces go dark.
- **Cross-reference:** [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-4](#d-4-ignore-file-strategy), [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code), [D-79](#d-79-workspace-content-search-is-a-pure-dart-in-process-engine-outside-pql), [D-80](#d-80-filesread-allows-trusted-claude-config-roots-beyond-the-workspace), [D-92](tooling.md#d-92-ship-pql-bundled-with-clide), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability).
- **Raised by:** 2026-06-11 — user, this planning session: a repo without `.pql/` is "invalid for clide"; non-git folder → "offer default no"; planning hooks contextual; unwritable repos degrade.
### D-96: Remote-execution footprint — no-install ssh-exec
- **Date:** 2026-06-12
- **Decision:** SSH-remote workspaces (T-329, shape A of [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace)) use **stock OpenSSH only — nothing clide-specific is ever installed on the remote.** Interactive surfaces (terminal panes, the Claude process) run over `ssh -tt` PTY channels; command-style subsystems (git, pql, file ops, search) run as exec channels multiplexed over a persistent **ControlMaster** connection; file watching degrades to **polling** (debounced mtime/git-status sweep, `inotifywait` used opportunistically when present) that emits the same FileChange events, so the UI layer is unaware of the difference. Subsystems reach the remote through a `RemoteExecutionContext` seam instead of bare `Process.run`/`File`/`Directory`. The rejected alternative — an auto-pushed self-managed remote agent (VS Code Remote model) — would have bought native inotify and a stateful remote backend at the price of deploying and version-managing clide components on the remote.
- **Rationale:** The user's standing constraint is decisive: no clide components to install, update, GC, or version-reconcile on remote machines. Zero-footprint also dissolves the agent model's open sub-questions (placement, multi-client sharing, version skew, cleanup) — they simply don't arise. The costs (per-command round-trip, polling watcher) are bounded and amortizable (ControlMaster reuses one authenticated connection); the agent model's costs are operational and permanent.
- **D-56 reconciliation:** [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server)'s "single process" rule is *strengthened*, not bent: with no-install there is no second clide process anywhere — the local Flutter app remains the only clide process, and the remote side is plain sshd + the tools already on the box. [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) is likewise untouched — ssh is an external tool clide shells out to, not a second core language or runtime.
- **Cost:** Every remote command pays an SSH round-trip (ControlMaster removes handshake cost, not latency). Watching is polling-grade — change events arrive on the sweep cadence, not instantly. The execution-context seam must be threaded through each subsystem that touches the filesystem or spawns processes; that sweep is the bulk of T-336. No stateful remote backend means event streams are synthesized locally from command results.
- **Cross-reference:** Resolves [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace). [D-97](#d-97-ssh-workspace-uri--system-ssh-auth) (naming + auth), [D-98](#d-98-remote-tool-contract--connect-preflight) (what must exist remotely), [D-99](#d-99-remote-session-identity-keyed-on-host-workspace) (identity), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability) (remote workspaces surface as a workspace mode — the reserved `ssh` value — so extensions gate on it declaratively). Implemented by the T-329 epic; execution layer is T-336.
- **Raised by:** 2026-06-12 — user, resolving the T-330 footprint spike: "go with the no-install ssh-exec model."
### D-97: ssh:// workspace URI + system-ssh auth
- **Date:** 2026-06-12
- **Decision:** A remote workspace is named by the URI `ssh://[user@]host[:port]/abs/remote/path`. `host` may be a `~/.ssh/config` alias; user/port are optional and, when absent, resolve through ssh's own config machinery. **Auth delegates entirely to system ssh** — agent, keys, `~/.ssh/config`, ProxyJump, all of it; clide never stores credentials or implements an auth flow of its own. v1 connections run ssh in **BatchMode** (non-interactive): when auth would prompt, the connect fails with an actionable message ("set up key auth / ssh-agent for <host>") instead of clide hosting a password dialog. Windows (no standard ssh config surface) is an acknowledged v1 gap. The `WorkspaceRef` value type (T-332) is the canonical carrier — parse/round-trip of this URI, `host:path` display form, bare-path = local.
- **Rationale:** Matches the epic's locked auth posture and pql's "wrap, don't duplicate" instinct applied to OpenSSH: the user's existing ssh config is the source of truth, and anything clide reimplements (agents, prompts, jump hosts) would be a worse, second implementation of it. BatchMode keeps the failure mode crisp instead of wedging a TTY prompt inside a GUI flow.
- **Cost:** First-run UX depends on the user's ssh hygiene — no in-app password fallback. Host-alias resolution means the same workspace can be reachable under two names (`buildbox` vs `buildbox.lan`) and be keyed as two identities ([D-99](#d-99-remote-session-identity-keyed-on-host-workspace) keys on the *given* host string; aliasing dedupe is deliberately not attempted).
- **Cross-reference:** [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec), [D-99](#d-99-remote-session-identity-keyed-on-host-workspace). Implemented by T-332 (WorkspaceRef landed 2026-06-12; open-flow pending T-336).
- **Raised by:** 2026-06-12 — T-330 spike artifacts, URI shape locked at epic planning (2026-06-10).
### D-98: Remote-tool contract + connect preflight
- **Date:** 2026-06-12
- **Decision:** What must exist on the remote, and what merely degrades. **Required:** a POSIX shell and `git` — without them the workspace cannot open (workspace validity, [D-95](#d-95-workspace-validity-and-onboarding-flow), requires a git repo). **Optional, degrading:** `pql` — absent, the planning/query surfaces (tickets, decisions, vault queries) go dark behind a banner, mirroring [D-95](#d-95-workspace-validity-and-onboarding-flow)'s read-mode degrade; clide cannot provision pql remotely under [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec)'s no-install rule, so the banner tells the user what to install where. **Optional, degrading:** `claude` — absent, the Claude pane is disabled with a notice; terminal/editor/git stay fully live. On connect, a single batched preflight command probes all of these (one round-trip: `command -v` + version for each) and the result drives the degrade set; a missing *required* tool fails the open with the probe output.
- **Rationale:** The contract keeps "remote" honest without smuggling an installer in: clide states what it found, works with what's there, and never mutates the remote toolset. One batched probe respects the per-command latency cost [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec) accepts. Folding pql-absence into the existing degrade vocabulary (D-94 modes / D-95 banner) reuses a shipped pattern instead of inventing a remote-special one.
- **Cost:** A degraded-but-open remote workspace is a new partial state to keep coherent (which surfaces dark, which live). Version *skew* (remote pql older than the bundled local one) is real and detected by the preflight but only surfaced, not reconciled, in v1.
- **Cross-reference:** [D-92](tooling.md#d-92-ship-pql-bundled-with-clide) (bundling is local-only under no-install), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability), [D-95](#d-95-workspace-validity-and-onboarding-flow), [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec).
- **Raised by:** 2026-06-12 — T-330 spike artifacts ("decide the remote-tool contract: what must exist remotely, whether pql is hard-required or degrades, and how a preflight surfaces what is missing").
### D-99: Remote session identity keyed on (host, workspace)
- **Date:** 2026-06-12
- **Decision:** Workspace-keyed identity generalizes from *path* to *(host, path)* — local workspaces are `(null, path)`, so nothing changes for them. Consequences: Claude session identity ([D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)'s one-primary-per-repo, [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer)'s stream-json sessions) re-keys on (host, repo) — the same repo path on two hosts (or local + remote) is two distinct sessions, never one; Claude's `--resume` transcripts live on the host where claude runs (the remote's `~/.claude/…`), which falls out naturally because claude is spawned remotely. Per-workspace user-scope state ([D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace)/[D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)'s path-hash keying) hashes the WorkspaceRef canonical URI instead of the bare path — same generalization, same machinery. The host string is taken as given (alias ≠ FQDN; no dedupe, per [D-97](#d-97-ssh-workspace-uri--system-ssh-auth)).
- **Rationale:** Path-only keying would silently fuse two different machines' checkouts of the same repo path into one session/layout/socket identity — wrong in every case. Hashing the canonical URI is the smallest amendment that fixes this everywhere at once, because every consumer already keys off one derived string.
- **Amends [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) / [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer):** "per repo" reads as "per (host, repo)" throughout; local keeps its existing identity (null host hashes identically to the pre-amendment bare path — no migration).
- **Cost:** Renaming a host alias re-keys its sessions and layout state (accepted; same trade-off D-70 already made for moved repos).
- **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer), [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec), [D-97](#d-97-ssh-workspace-uri--system-ssh-auth). Implemented across T-332 (identity carrier) and T-333 (session re-key).
- **Raised by:** 2026-06-12 — T-330 spike artifacts ("session identity keyed on (host, repo) amending D-41/D-77").
---
+4 -4
View File
@@ -18,10 +18,10 @@ Test pyramid, drivers, client-side constraint.
- **Cost:** Goldens have zero real text; layouts rely on widget tests. Acceptable.
- **Raised by:** 2026-04-21 planning.
### D-25: Mocks — mocktail at IO, hand-rolled fakes for ChangeNotifiers
- **Date:** 2026-04-21
- **Decision:** `mocktail 1.0.4` mocks IO boundaries (sockets, processes, `dart:io` File/Directory). `ChangeNotifier` facades get hand-rolled fakes — tiny classes that extend `ChangeNotifier` with test-controlled setters. No `mocktail` for notifiers.
- **Rationale:** Mocking a `ChangeNotifier` with a generated mock hides subscription bugs — `notifyListeners` becomes a mock call instead of actually firing. Hand-rolled fakes exercise the real subscription machinery.
### D-25: Mocks — hand-rolled fakes throughout; mocktail dropped
- **Date:** 2026-04-21 (amended 2026-06-12)
- **Decision:** Test doubles are hand-rolled fakes — tiny classes that extend the real base (`ChangeNotifier` facades, `StreamJsonProcess`, `DaemonClient`) with test-controlled setters. **Amendment (2026-06-12, T-385):** `mocktail` was originally pinned for IO boundaries, but after the T-91 coverage drive it had zero imports — every IO seam ended up with an injected hand-rolled fake (`FakeDaemonClient`, fake process factories, recording event sinks) instead. The unused dep is dropped; the no-mocks-for-notifiers rule stands and in practice covers IO seams too.
- **Rationale:** Mocking a `ChangeNotifier` with a generated mock hides subscription bugs — `notifyListeners` becomes a mock call instead of actually firing. Hand-rolled fakes exercise the real subscription machinery. The same held at IO seams: constructor-injected fakes kept tests on real control flow.
- **Cost:** Roughly 20 lines per fake. Rounds out to less code than configuring a mocktail whenCall chain.
- **Raised by:** 2026-04-21 planning.
+23 -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.
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
### D-32: CI — Gitea primary, Linux-only runners, not yet activated
- **Date:** 2026-04-21
- **Decision:** CI config lives at `.gitea/workflows/test.yml` (Gitea Actions consumes GitHub-Actions syntax). Runners are Linux only; macOS is tested locally. The workflow is ready but Gitea Actions is not yet activated on the instance — the file is a staged pipeline for review. If the repo moves to GitHub, the file copies to `.github/workflows/test.yml` verbatim.
- **Rationale:** We want the CI story defined before we turn CI on — lower blast radius on early red builds. GitHub portability is free because the syntax is shared.
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
### D-32: CI — GitHub Actions, Linux + Windows runners, active
- **Date:** 2026-04-21 (amended 2026-06-15)
- **Decision:** CI runs on **GitHub Actions** under `.github/workflows/`: `test.yml` (Linux — analyze + format + unit/widget/golden + coverage gate, with `pql` installed and `pql.db` rebuilt from the changelog), `windows.yml` + `windows-soak.yml` (ConPTY tests + the orphan-leak soak), and `release.yml` (version-tagged builds, the CHANGELOG section as release notes). macOS is tested locally (no macOS runner). The web-WASM Playwright e2e job is withheld pending the `dart:ffi` fence (D-100 / Q-50). Every job goes through `make` targets.
- **Rationale:** The repo moved to GitHub (origin `postmeridiem/clide`); GitHub Actions consumes the same workflow syntax the staged Gitea pipeline used, so the move was near-verbatim. Defining the CI story before flipping it on kept early red builds low-blast-radius.
- **Cost:** macOS coverage is local-only; the browser/e2e surface stays dark until D-100's fence lands.
- **Amended (2026-06-15):** Superseded the original "Gitea primary, not yet activated" posture — the Gitea staging pipeline was never activated and is gone (only `legacy/.gitea/`, the frozen Python tree, remains); CI is live on GitHub Actions (commits `8e0f33b` Linux, `45aa2d9` Windows/release). Reconciled while closing out T-384.
- **Raised by:** 2026-04-21 planning.
### D-42: Dependencies documented in `licenses.yaml`
@@ -91,4 +92,21 @@ Toolchain, supply chain, CI, ignore strategy.
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-61](#d-61-dependency-vetting-checklist), `POLICY.md`.
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
### D-92: Ship pql bundled with clide
- **Date:** 2026-06-11
- **Decision:** clide ships `pql` as a vendored, version-pinned native binary — the same model used for git via dugite ([D-59](#d-59-bundled-git-via-dugite-native)). The pinned binary lives under `native/<platform>/` with a `BUILD.md` provenance record ([D-63](#d-63-vendored-binary-rebuild-process)) and an `assets/licenses.yaml` entry ([D-42](#d-42-dependencies-documented-in-licensesyaml), [D-65](#d-65-license-compatibility-matrix)). Resolution order is: `CLIDE_PQL_BIN` env override (dev escape hatch — e.g. pointing at a pql built side-by-side) → bundled binary resolved against the **install directory** (next to the executable, never workspace-relative) → system `pql` on PATH. The bundled copy never self-updates — the pin is the contract, so `pql self-update` is inert for it. A version floor is enforced *softly*: if the resolved pql (override or PATH) is older than the pinned floor, the Problems panel surfaces it rather than clide silently mis-driving an incompatible binary. CI runs the in-tree binary instead of provisioning pql on the runner.
- **Context:** pql is clide's files/query/ignore engine **and** its planning engine ([D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)), yet it was an unmanaged external dependency: users installed and updated it themselves, with no version pin. Beyond the "update the binary, then install it" friction, an old PATH pql replaying the changelog ([D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code)) is a latent *corruption* risk, not merely a missing-feature one. This is a distribution gap, not an architecture one.
- **Rationale:** Bundling makes a fresh clone/install work with zero separate pql setup, pins the version clide was tested against (closing the changelog-schema-skew risk), and reuses the proven dugite pattern and its supply-chain gates ([D-60](#d-60-no-network-on-default-launch-path)/[D-61](#d-61-dependency-vetting-checklist)/[D-63](#d-63-vendored-binary-rebuild-process)). It is **additive, not a fork**: pql stays a standalone tool, clide still wraps and never reimplements ([D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)), and pql's universality for terminal/VS Code users is untouched. pql being a pure-Go, no-CGo static binary makes per-platform bundling cheap.
- **Cost:** A pinned binary per shipped platform (linux-x64 now; macOS arm64/x64 when those builds land), each carried through the [D-63](#d-63-vendored-binary-rebuild-process) rebuild ritual on every pql release — the same bump cadence as dugite and tree-sitter. Resolving the bundled binary against the install dir and **never** a workspace-relative path is mandatory: a repo could otherwise plant `native/pql` and gain code execution (the T-98 dugite lesson).
- **Cross-reference:** [D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-59](#d-59-bundled-git-via-dugite-native), [D-60](#d-60-no-network-on-default-launch-path), [D-61](#d-61-dependency-vetting-checklist), [D-63](#d-63-vendored-binary-rebuild-process), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-65](#d-65-license-compatibility-matrix), [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code).
- **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."
+21 -2
View File
@@ -60,9 +60,15 @@ ticket persistence.
- **Source:** 2026-04-21 planning.
### Q-23: SSH-remote development — run clide against a remote workspace
- **Status:** Open
- **Status:** Resolved → [D-96](../decisions/architecture.md#d-96-remote-execution-footprint--no-install-ssh-exec), [D-97](../decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth), [D-98](../decisions/architecture.md#d-98-remote-tool-contract--connect-preflight), [D-99](../decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace)
- **Resolved (2026-06-12):** Shape (A) — execution remote, UI local — with the **no-install ssh-exec** footprint (user pick): stock OpenSSH only, `ssh -tt` PTYs + ControlMaster exec channels, polling watcher, zero clide components on the remote (D-96). Naming/auth via `ssh://` URI + system ssh in BatchMode (D-97); remote-tool contract with batched connect preflight (D-98); session + state identity re-keyed on (host, repo), amending D-41/D-77 (D-99). Implementation: T-329 epic, execution layer T-336.
- **Question:** Clide today assumes the workspace, the daemon, and the Flutter UI all run on the same machine. A growing class of users edits on remote systems (build servers, GPU boxes, cloud dev environments). What's the architecture for "open repo on host-B from UI on host-A"? Two shapes: (A) daemon-on-remote — clide's Dart daemon runs on the remote; the app talks to it over an SSH-tunnelled unix socket or a dedicated TCP socket (mTLS?), pty/process/filesystem work stays server-side; local app is pure UI. (B) filesystem-mounted — remote mounted via sshfs/9p/rclone, daemon runs locally against the mount; simpler but every fs op + git call crosses the network, and PTYs get complicated (local shell on remote filesystem? ssh-exec per command?). (A) matches VS Code Remote / JetBrains Gateway; (B) matches nothing load-bearing. Sub-questions either way: auth (ssh-agent? per-project keys? OIDC?), tmux / Claude session persistence semantics (does primary-per-repo re-key on host + repo?), multi-host identity in `.pql/pql.db`, latency tolerance for the event stream, re-sync on disconnect.
- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-5` and `D-6`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind.
- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-5` and `D-6`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind. The 2026-06-11 Fable review (fable-ous.md Part IV, Tier 3 #14) reframed the differentiator and ranked T-329 as undersold: the agent runs on the buildbox while permission prompts render natively local — VS Code Remote moves the *editor*; nobody remotes the *agent control channel*. That framing favours shape (A).
- **Triage (2026-06-12):** Shape (A) is effectively settled (T-329 epic locked: execution remote, UI + clipboard local, system ssh auth for v1). The model-independent backbone is proceeding: Phase 1 (T-331, `DaemonTransport` seam) landed. What remains open is the **footprint model** — the user decision T-330 gates on:
1. **No-install ssh-exec** — zero remote footprint; stock `ssh -tt` PTYs + ControlMaster command channels; watching degrades to polling; heavier subsystem surface locally.
2. **Auto-pushed self-managed agent** (VS Code Remote model) — clide deploys/version-checks a headless agent binary on connect; native inotify + stateful backend; but installs clide components on the remote, which the user has said they don't want to manage.
If (2), the agent sub-questions need answers before T-336 expands: placement (per-host `~/.clide/agent` likely), GC on disconnect/version-bump/repo-removal, multi-client sharing (IpcServer is already multi-connection, D-72), version skew (version-named binaries coexist). Either way the **remote-tool contract** needs a D-record: what must exist remotely (git/shell at minimum), whether pql is hard-required or degrades, and how a connect-preflight surfaces gaps. Evidence gap: the ControlMaster per-command latency probe (T-330) needs a reachable sshd — none in the dev environment; run it against a real remote before deciding if latency is the deciding factor.
- **Source:** 2026-04-22 planning (user-raised).
### Q-22: Ticket persistence strategy
@@ -153,4 +159,17 @@ ticket persistence.
- **Context:** The only unshipped piece of the otherwise-complete native-Claude epic (T-132). Blocked on data availability, not on clide work — hence a question (when/how to revisit) rather than active scope. Option (c) interacts with D-75's "version-pinned coupling to CC internals" posture. Resolved by T-158 when a viable path lands.
- **Source:** 2026-06-09 — split out of T-132 / T-158 (was "blocked on upstream"); project memory `claude-usage-budget-not-exposed`, GitHub anthropics/claude-code#44328.
### Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?
- **Status:** Resolved → [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling)
- **Resolved (2026-06-15):** **Fence** (option a). Every `dart:ffi` importer moves behind a conditional-import facade with a web stub, and a `flutter build web --wasm` compile gate is added so it can't rot. The maintainer wants the web/WASM build kept alive as a hopeful future target; desktop fidelity is untouched (stubs exist only on the web target). Functional web parity is explicitly not promised — only that the tree compiles to wasm and the e2e/Playwright harness can run again. Implementation tracked in T-438. See [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling).
- **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish.
- **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked.
- **Source:** T-384 / 2026-06-11 Fable review (epic T-359).
### 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.
---
+100
View File
@@ -0,0 +1,100 @@
# Open Questions — Design
Feature proposals from the 2026-06-11 Fable review (fable-ous.md Part IV,
epic [T-359]). Each asks the same question — are we going to implement this
feature? — so the answer can resolve into a D-record (and an initiative
ticket) or an R-record. Effort tags `[S/M/L/XL]` come from the review.
---
### Q-35: Agent Blame + Session Flight Recorder — implement?
- **Status:** Open
- **Question:** Are we going to implement agent blame — a gutter action on any line answering *which session, which turn, which prompt, which permission grant, what it cost*, opening the native conversation at the exact `tool_use` — plus a timeline scrubber to replay a session? `[L]`
- **Context:** Two ideation lenses independently invented this. The transcript pipeline (`transcript_reader.dart`, `session_index.dart`) already parses everything needed. Cursor/Copilot cannot ship it: their logs live server-side by business design — local-and-committed transcripts are a structural moat.
- **Source:** fable-ous.md Part IV, Tier 1 #1 (2026-06-11 Fable review).
### Q-36: Context X-ray — implement?
- **Status:** Open
- **Question:** Are we going to implement per-card token attribution ("this 40KB Bash tail is 12% of your window") plus a real compaction indicator? `[M]`
- **Context:** `stream_json_session.dart` already parses usage and contextWindow per event; the renderer owns the cards. Would fix T-244 (invisible compaction) as a side effect. Context is the scarcest resource in agent pairing and every tool renders it as one opaque percentage.
- **Source:** fable-ous.md Part IV, Tier 1 #2 (2026-06-11 Fable review).
### Q-37: Trust Ledger + decision-aware permission prompts — implement?
- **Status:** Open
- **Question:** Are we going to implement a permission-rule ledger with provenance (which prompt, which session, which ticket; ticket-scoped expiry), and decision-aware prompts that chip the relevant D-record onto a `can_use_tool` card (edit touching pubspec.yaml → [D-31](../decisions/tooling.md#d-31-prefer-zero-deps-exact-pin), one keystroke to deny *with the decision cited*)? `[M]`
- **Context:** Governance stops being documentation and becomes live agent policy. No competitor has a queryable in-repo decision system to attempt this. Builds on the D-78 interaction-zone prompt surface.
- **Source:** fable-ous.md Part IV, Tier 1 #3 (2026-06-11 Fable review).
### Q-38: Agent Activity HUD — implement?
- **Status:** Open
- **Question:** Are we going to build the `OperationsRegistry` (T-59) once and feed it git/pql progress (T-59), sidebar badges (T-58), compaction state (T-244), and the status strip (T-274), with [Q-34](architecture.md#q-34-how--when-to-surface-the-accountteam-token-budget-given-upstream-doesnt-expose-it)'s budget slot reserved? `[M]`
- **Context:** Four backlog tickets and one open question are secretly one feature. Prerequisite: fix the T-274 plumbing bug first or the HUD inherits blank-slot syndrome (root cause is on T-274; the ValueStream retrofit is T-386).
- **Source:** fable-ous.md Part IV, Tier 1 #4 (2026-06-11 Fable review).
### Q-39: Active Ticket Context — implement?
- **Status:** Open
- **Question:** Are we going to bind picking up a ticket to the session — a "working on T-NNN" chip, auto `in_progress` flip, `(T-NNN)` pre-suggested in commit messages, changelog reminder on done? `[S]`
- **Context:** Cheapest win in the review's whole feature list. Makes kanban ambient instead of homework, and makes trail/ledger features (Q-35, Q-43) reliable by giving every turn a ticket anchor.
- **Source:** fable-ous.md Part IV, Tier 1 #5 (2026-06-11 Fable review).
### Q-40: Twin-timeline rewind — implement?
- **Status:** Open
- **Question:** Are we going to snapshot the worktree as hidden git refs (`git write-tree``refs/clide/checkpoints`) at every turn boundary, keyed to turn uuid, so every user-message card gains "restore files to before this"? `[L]`
- **Context:** Claude's `/rewind` only restores what Claude itself edited; clide owns both timelines. Needs a retention/GC policy for the checkpoint refs.
- **Source:** fable-ous.md Part IV, Tier 2 #6 (2026-06-11 Fable review).
### Q-41: Visual Dialog — one bidirectional scene schema — implement?
- **Status:** Open
- **Question:** Are we going to define one bidirectional scene schema where Claude draws ([D-91](../decisions/architecture.md#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer) canvas cards), the user annotates in the interaction zone (T-260), and annotations return as *structured geometry + flattened PNG*, not prose?
- **Context:** Two lenses converged here. The T-317/T-318 and T-260 specs should merge into one protocol *before* they become two dialects — this question is urgent in ordering even if the build is later. `[L]`
- **Source:** fable-ous.md Part IV, Tier 2 #7 (2026-06-11 Fable review).
### Q-42: Immortal terminals — implement?
- **Status:** Open
- **Question:** Are we going to fuse T-258 (terminal as editor-mode peer) with T-325 live-tails so any long process — user shell *or* agent-spawned build — promotes to a full tmux-backed surface that survives restart? `[M]`
- **Context:** "The build that never dies" is structural for clide, a plugin fantasy for Electron competitors. Resolving [Q-27](architecture.md#q-27-two-editor-split) (swap vs split) is part of it, as T-258 already notes.
- **Source:** fable-ous.md Part IV, Tier 2 #8 (2026-06-11 Fable review).
### Q-43: Local cost ledger — implement?
- **Status:** Open
- **Question:** Are we going to persist per-turn cost/tokens against the active pql ticket so the board shows what each feature actually cost? (Tokens primary, dollars advisory — subscription auth reports notional costs.) `[M]`
- **Context:** Flat-subscription opacity is the competitors' business model; turn-level local cost data is clide's birthright. Depends on Q-39 (active ticket binding) for reliable attribution.
- **Source:** fable-ous.md Part IV, Tier 2 #9 (2026-06-11 Fable review).
### Q-44: Label-routed work queues / ticket dispatch — implement?
- **Status:** Open
- **Question:** Are we going to turn the board into an agent control surface — T-277 labels + the shipped pick-up path routing work queues, with an XL extension dispatching a ticket to a teammate session in an isolated git worktree (`SpawnSpec.cwd` already exists)? `[M→XL]`
- **Context:** Local, no-telemetry background agents with the work item, isolation, review surface, and audit trail all in-repo.
- **Source:** fable-ous.md Part IV, Tier 2 #10 (2026-06-11 Fable review).
### Q-45: Semantic terminal — implement?
- **Status:** Open
- **Question:** Are we going to inject OSC 133 markers (clide spawns the shell, injection is trivial) to lift scrollback into foldable command regions, with recognizers for test runners and stack traces, and real widgets between rows? `[XL]`
- **Context:** xterm.js structurally cannot do widgets between rows. Hard prerequisite: the scrollback-unreachable bug (in T-378) — a semantic scrollback you can't scroll is a koan.
- **Source:** fable-ous.md Part IV, Tier 3 #11 (2026-06-11 Fable review).
### Q-46: Living codebase map — implement?
- **Status:** Open
- **Question:** Are we going to render tree-sitter imports + pql links + git churn on the owned canvas, with live agent heat from `tool_use` events — watching Claude move through the codebase in real time? `[XL]`
- **Context:** Needs the canvas surface (T-317 family) and the tree-sitter FFI work to be solid first.
- **Source:** fable-ous.md Part IV, Tier 3 #12 (2026-06-11 Fable review).
### Q-47: Live mixed documents — implement?
- **Status:** Open
- **Question:** Are we going to make fenced blocks in the owned markdown renderer live embeds — canvas scenes, pql query results, decision cards, confirm-gated command buttons? Notebook-grade, zero webview. `[XL]`
- **Context:** The `ClideMarkdownHooks` seam already exists.
- **Source:** fable-ous.md Part IV, Tier 3 #13 (2026-06-11 Fable review).
### Q-48: Sealed-workspace mode — implement?
- **Status:** Open
- **Question:** Are we going to build an egress-audit proxy around the whole agent stack, operationalizing [D-60](../decisions/tooling.md#d-60-no-network-on-default-launch-path)/[D-64](../decisions/architecture.md#d-64-no-telemetry--architectural-commitment) into a provable property? `[XL]`
- **Context:** The one feature in the review's list competitors cannot copy without breaking their own products.
- **Source:** fable-ous.md Part IV, Tier 3 #15 (2026-06-11 Fable review).
### Q-49: Review honorable mentions — which, if any, get promoted?
- **Status:** Open
- **Question:** Which of the review's honorable mentions, if any, do we promote to tickets: Plan-to-Board bridge (ExitPlanMode approval files the plan as a ticket tree), Release Cockpit (renders `[Unreleased]` + one-action release cut — would also unstall the release-loop story T-393), Daily Helm (since-you-were-last-here pulse on the welcome screen), Total-recall conversation search (D-79 grep over the transcript corpus + fork-from-here), Shared Gaze (attach editor selection/scroll context to outgoing turns), Speakable Layouts (`clide layout apply review.yaml`), Agent fleet tray (per-workspace sockets — requires T-247's stale-socket fix first), Governance Graph (the D/Q/R/T web as a navigable map)?
- **Context:** Kept as one record to avoid fifteen low-signal questions; promote individually as appetite appears.
- **Source:** fable-ous.md Part IV, honorable mentions (2026-06-11 Fable review).
---
+3 -13
View File
@@ -33,23 +33,13 @@ void main() {
tester.view.resetDevicePixelRatio();
});
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_intg_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.ipc-status',
'builtin.theme-picker',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.theme-picker', 'builtin.default-layout'],
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
+3 -12
View File
@@ -25,22 +25,13 @@ void main() {
tester.view.resetDevicePixelRatio();
});
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_lc_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.ipc-status',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.default-layout'],
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
+3 -12
View File
@@ -16,22 +16,13 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async {
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_theme_intg_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.theme-picker',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
preloadNamespaces: const ['builtin.welcome', 'builtin.theme-picker', 'builtin.default-layout'],
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
+12 -1184
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -7,7 +7,7 @@
/// 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
/// 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.
library;
@@ -74,6 +74,14 @@ final class EditRun extends RenderGroup {
/// Tools whose result is a diff the user wants to keep first-class at L1/L2.
bool isDiffTool(String name) => const {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Update'}.contains(name);
/// Tool names that spawn a sub-agent (sidechain): Claude Code emits `Task`,
/// the Agent SDK surface uses `Agent`. An agent spawn is ALWAYS its own
/// first-class collapsing card — it breaks the Activity cluster so a fan-out
/// of N agents reads as N cards, never one merged "Activity / N steps" card
/// (T-342). Each card carries its own folded prompt (T-263) + nested run
/// (T-264); the fold mechanics are unchanged, only the grouping boundary.
bool isAgentTool(String name) => name == 'Task' || name == 'Agent';
/// The file an edit tool-use targets, or null if [it] isn't a same-file edit
/// (used to group consecutive edits, T-296).
String? editFilePath(ConversationItem it) {
@@ -159,6 +167,13 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
case AssistantThinkingMessage():
return level != FoldLevel.tools;
case AssistantToolUse(:final name):
// An Agent/Task spawn is always its own first-class card (T-342) — it
// breaks the cluster at every level, including L3, so parallel agents
// never merge into one Activity card.
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.
if (level == FoldLevel.everything) return true;
return !isDiffTool(name);
+18 -54
View File
@@ -21,11 +21,15 @@
/// IO wrapper the orchestrator calls. Flutter-free by design.
library;
import 'dart:ffi' show Abi;
import 'dart:io';
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
// Web fence (T-438, D-100): `Abi.current()` (dart:ffi) is desktop-only; the web
// build gets a default dir name with no FFI introspection.
import 'native_abi_stub.dart' if (dart.library.ffi) 'native_abi_io.dart';
/// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a
/// hosted session (T-217), so the agent isn't prompted on every IDE call.
/// Claude Code's settings/flag syntax for a command-scoped Bash rule is
@@ -42,7 +46,8 @@ const List<String> clideAllowedToolsArgs = ['--allowedTools', clideBashAllowRule
/// the orient-snapshot (`clide status`) and live pane/editor reflection
/// arrive with Epic C (T-218..T-221) and are deliberately left out so the
/// note never points the agent at a command that returns nothing yet.
String clideContextNote(String workspaceRoot) => 'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE '
String clideContextNote(String workspaceRoot) =>
'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE '
'surface as a `clide` command on your PATH; drive it with `clide <subsystem> <verb>`. '
'Subsystems that respond today: `files` (workspace tree — `clide files root`, `files list`), '
'`editor` (`clide editor open <path>`, `editor active`), `git` (`clide git status`), '
@@ -61,16 +66,8 @@ String clideContextNote(String workspaceRoot) => 'You are running inside clide,
/// * `CLIDE_WORKSPACE` — the workspace root.
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
/// is not already resolvable), otherwise left untouched.
Map<String, String> agentEnvDelta({
required String workspaceRoot,
required String socketPath,
required String? currentPath,
required String? clideCliDir,
}) {
final delta = <String, String>{
'CLIDE_SOCK': socketPath,
'CLIDE_WORKSPACE': workspaceRoot,
};
Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': workspaceRoot};
if (clideCliDir != null && clideCliDir.isNotEmpty) {
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath';
}
@@ -85,11 +82,7 @@ Map<String, String> agentEnvDelta({
///
/// [candidateDirs] is an ordered fallback list; [isExecutableFile] probes
/// `<dir>/clide`. Both are injected so the resolver is pure and testable.
String? resolveClideCliDir({
required String? currentPath,
required List<String> candidateDirs,
required bool Function(String path) isExecutableFile,
}) {
String? resolveClideCliDir({required String? currentPath, required List<String> candidateDirs, required bool Function(String path) isExecutableFile}) {
if (currentPath != null) {
for (final dir in currentPath.split(':')) {
if (dir.isNotEmpty && isExecutableFile('$dir/clide')) return null;
@@ -101,26 +94,6 @@ String? resolveClideCliDir({
return null;
}
/// The `native/<os>-<arch>/` directory name the Makefile builds the C client
/// into (e.g. `linux-x64`, `macos-arm64`) — used to find the dev-tree binary
/// when clide runs un-installed (dogfooding clide-on-clide).
String nativeClideDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macos today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
/// The result of [agentBootstrap]: the env delta to overlay and the extra
/// spawn args (context note + allow rule) to prepend to a session's argv.
class AgentBootstrap {
@@ -136,27 +109,18 @@ class AgentBootstrap {
/// orchestrator merges both into one `--append-system-prompt`.
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
final home = Platform.environment['HOME'];
final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH'];
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
// shells out to — find user-installed components on a desktop launch, not just
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
final currentPath = resolvedToolPath();
final candidates = <String>[
if (home != null && home.isNotEmpty) '$home/.local/bin',
'$workspaceRoot/native/${nativeClideDirName()}',
'$workspaceRoot/native/${currentNativeDirName()}',
File(Platform.resolvedExecutable).parent.path,
];
final cliDir = resolveClideCliDir(
currentPath: currentPath,
candidateDirs: candidates,
isExecutableFile: _isExecutableFile,
);
final delta = agentEnvDelta(
workspaceRoot: workspaceRoot,
socketPath: workspaceSocketPath(workspaceRoot),
currentPath: currentPath,
clideCliDir: cliDir,
);
return AgentBootstrap(
envDelta: {...?base, ...delta},
extraArgs: ['--allowedTools', clideBashAllowRule],
);
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
}
bool _isExecutableFile(String path) {
@@ -0,0 +1,119 @@
/// Detect the file a Bash command follows, for the live-tail sub-card (T-325).
///
/// Claude Code runs every Bash tool itself and clide only sees the final
/// `tool_result` block — we never tap the running command's stdout. So instead
/// of mirroring the process, we detect a *file-backed source* the command
/// reads/follows and open our own read-only follower on the same file.
///
/// Deliberately conservative (the ticket's "small, explicit allowlist"): only
/// the read/follow verbs below, only a single file argument, and only paths
/// that resolve INSIDE the workspace. Anything else — a pipe into `tail`, a
/// redirect, two files, a path outside the repo — returns null so the caller
/// shows a "nothing to follow" affordance instead of following the wrong thing.
library;
import 'dart:io';
import 'package:clide/src/files/path_safety.dart';
/// Verbs whose single file argument clide can independently follow read-only.
const Set<String> _followVerbs = {'tail', 'cat', 'less'};
/// The file [command] reads/follows that clide can mirror read-only, as an
/// absolute path inside [workspaceRoot] — or null when there is no single,
/// safe, file-backed source. See the library doc for the policy.
String? detectBashTailSource(String command, {required Directory workspaceRoot}) {
String? found;
for (final segment in _commandSegments(command)) {
final tokens = _tokenize(segment);
if (tokens.isEmpty || !_followVerbs.contains(tokens.first)) continue;
final files = _fileArgs(tokens.first, tokens.sublist(1));
if (files.length != 1) continue; // 0 → reads stdin (a pipe); >1 → ambiguous
final String resolved;
try {
resolved = resolveUnderRoot(workspaceRoot, files.single);
} on PathOutsideRoot {
continue; // outside the workspace → don't follow (v1 policy)
}
if (found != null && found != resolved) return null; // two distinct sources
found = resolved;
}
return found;
}
/// Whether [command] expresses an intent to FOLLOW a file — used to decide
/// when to surface the live-tail segment at all, so ordinary commands (`ls`,
/// `git status`, a plain `cat`) get no segment, but a `tail …` with no
/// followable file still shows the "nothing to follow" note. v1 triggers on
/// `tail` or a follow flag (`-f`/`-F`/`--follow`); `cat`/`less` are detectable
/// sources but don't trigger the UI on their own (T-325).
bool bashHasTailIntent(String command) {
for (final segment in _commandSegments(command)) {
final tokens = _tokenize(segment);
if (tokens.isEmpty) continue;
if (tokens.first == 'tail') return true;
if (tokens.any((t) => t == '-f' || t == '-F' || t == '--follow')) return true;
}
return false;
}
/// Split a command line into command/pipeline segments on `|`, `;`, `&`. The
/// doubled forms (`&&`, `||`) fall out as empty middles and are dropped.
Iterable<String> _commandSegments(String command) => command.split(RegExp(r'[|;&]')).where((s) => s.trim().isNotEmpty);
/// Positional (non-flag) file arguments for [verb]. Skips flags, consumes the
/// value of `tail -n N` / `-c N`, honours `--` (end of options), and stops at a
/// redirect (`>` / `<`) — everything after a redirect targets a fd, not the
/// command's input.
List<String> _fileArgs(String verb, List<String> args) {
final files = <String>[];
for (var i = 0; i < args.length; i++) {
final a = args[i];
if (a == '--') {
files.addAll(args.sublist(i + 1).where((t) => !t.contains('>') && !t.contains('<')));
break;
}
if (a.contains('>') || a.contains('<')) break; // a redirect ends positional args
if (a.startsWith('-')) {
if (verb == 'tail' && (a == '-n' || a == '-c') && i + 1 < args.length) i++; // -n N / -c N
continue;
}
files.add(a);
}
return files;
}
/// Minimal shell tokeniser: splits on whitespace, honours single/double quotes
/// (no escape or expansion handling — enough to recover file arguments).
List<String> _tokenize(String s) {
final out = <String>[];
final buf = StringBuffer();
String? quote;
var has = false;
for (var i = 0; i < s.length; i++) {
final ch = s[i];
if (quote != null) {
if (ch == quote) {
quote = null;
} else {
buf.write(ch);
}
has = true;
} else if (ch == '"' || ch == "'") {
quote = ch;
has = true;
} else if (ch == ' ' || ch == '\t') {
if (has) {
out.add(buf.toString());
buf.clear();
has = false;
}
} else {
buf.write(ch);
has = true;
}
}
if (has) out.add(buf.toString());
return out;
}
+2 -10
View File
@@ -35,20 +35,12 @@ class ClaudeBanner extends StatelessWidget {
children: [
const ClideSvgView.asset('assets/logo/logo.svg', width: 60, height: 60),
const SizedBox(height: 18),
ClideText(
'Claude',
fontSize: clideFontDialogTitle,
color: claudeAccent,
fontWeight: FontWeight.w500,
),
ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
const SizedBox(height: 2),
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
const SizedBox(height: 16),
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
if (statusLine != null) ...[
const SizedBox(height: 2),
ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
],
if (statusLine != null) ...[const SizedBox(height: 2), ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily)],
const SizedBox(height: 16),
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true),
],
+11 -27
View File
@@ -352,7 +352,12 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
}
}
void _applyHistory(String text) => _applyValue(TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)));
void _applyHistory(String text) => _applyValue(
TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
),
);
/// Set the controller without it being treated as a user edit (so the
/// preview doesn't overwrite the persisted draft or exit navigation).
@@ -368,10 +373,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
final tokens = _attachments.map((a) => a.pathToken);
if (text.trim().isEmpty && _attachments.isEmpty) return;
// Typed text first, then the attachment @path references.
final message = [
if (text.trim().isNotEmpty) text,
...tokens,
].join(' ');
final message = [if (text.trim().isNotEmpty) text, ...tokens].join(' ');
widget.onSubmit(message);
_controller.clear();
setState(() => _attachments.clear());
@@ -454,11 +456,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
if (_attachments.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [for (final a in _attachments) _chip(theme, a)],
),
child: Wrap(spacing: 6, runSpacing: 6, children: [for (final a in _attachments) _chip(theme, a)]),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
@@ -489,13 +487,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
},
child: Stack(
children: [
if (!hasText)
Positioned(
left: 0,
top: 0,
right: 0,
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
),
if (!hasText) Positioned(left: 0, top: 0, right: 0, child: ClideText(widget.hint, muted: true, fontSize: clideFontBody)),
EditableText(
controller: _controller,
focusNode: _focus,
@@ -516,10 +508,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: PermissionModeControl(
mode: widget.permissionMode!,
onSelect: widget.onSetPermissionMode!,
),
child: PermissionModeControl(mode: widget.permissionMode!, onSelect: widget.onSetPermissionMode!),
),
],
],
@@ -548,12 +537,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
_chipLeading(theme, a),
const SizedBox(width: 6),
Flexible(
child: ClideText(
a.fileName,
fontSize: clideFontSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
child: ClideText(a.fileName, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 4),
Semantics(
+45 -85
View File
@@ -94,13 +94,7 @@ class ClaudePermissions {
/// + plugin + MCP), the skill names, the default model and permission mode.
@immutable
class ClaudeProbe {
const ClaudeProbe({
required this.version,
required this.slashCommands,
required this.skills,
this.model,
this.permissionMode,
});
const ClaudeProbe({required this.version, required this.slashCommands, required this.skills, this.model, this.permissionMode});
final String version;
final List<String> slashCommands;
@@ -109,12 +103,12 @@ class ClaudeProbe {
final String? permissionMode;
Map<String, Object?> toJson() => {
'version': version,
'slash_commands': slashCommands,
'skills': skills,
if (model != null) 'model': model,
if (permissionMode != null) 'permission_mode': permissionMode,
};
'version': version,
'slash_commands': slashCommands,
'skills': skills,
if (model != null) 'model': model,
if (permissionMode != null) 'permission_mode': permissionMode,
};
/// Build from a stream-json `init` event object. Returns null if it doesn't
/// look like an init event (no version field).
@@ -130,12 +124,12 @@ class ClaudeProbe {
}
static ClaudeProbe fromCache(Map<String, Object?> j) => ClaudeProbe(
version: (j['version'] as String?) ?? '',
slashCommands: _stringList(j['slash_commands']),
skills: _stringList(j['skills']),
model: j['model'] as String?,
permissionMode: j['permission_mode'] as String?,
);
version: (j['version'] as String?) ?? '',
slashCommands: _stringList(j['slash_commands']),
skills: _stringList(j['skills']),
model: j['model'] as String?,
permissionMode: j['permission_mode'] as String?,
);
}
List<String> _stringList(Object? v) => v is List ? v.whereType<String>().toList(growable: false) : const [];
@@ -151,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
/// Returns a change stream for [dir] (fires on any file event under it).
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
/// Modest version-agnostic fallback used when the probe is unavailable, so
/// the typeahead still offers the common built-ins.
const List<String> kFallbackSlashCommands = [
'add-dir',
'agents',
'clear',
'compact',
'config',
'context',
'cost',
'doctor',
'exit',
'help',
'init',
'mcp',
'memory',
'model',
'permissions',
'resume',
'review',
'status',
'usage',
];
/// Fallback used when the probe is unavailable. Mirrors the builtins a real
/// CLI advertises in its stream-json `initialize` handshake (probed against
/// 2.1.175) — i.e. the ones that genuinely work headless. It deliberately
/// does NOT list TUI-only commands (config, permissions, status, doctor, …):
/// this list doubles as the router's "advertised" set (T-411), and a TUI-only
/// token here would be forwarded to the CLI and error. The composer unions
/// [kClideOwnedCommands] on top for the typeahead (T-162).
const List<String> kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage'];
class ClaudeConfig extends ChangeNotifier {
ClaudeConfig({
@@ -184,13 +163,13 @@ class ClaudeConfig extends ChangeNotifier {
ClaudeInitProbe? initProbe,
ClaudeConfigWatch? watch,
Duration debounce = const Duration(milliseconds: 150),
}) : _globalDir = globalDir,
_cacheDir = cacheDir,
_projectDir = projectDir,
_versionRunner = versionRunner ?? _defaultVersionRunner,
_initProbe = initProbe ?? _defaultInitProbe,
_watch = watch,
_debounceFor = debounce;
}) : _globalDir = globalDir,
_cacheDir = cacheDir,
_projectDir = projectDir,
_versionRunner = versionRunner ?? _defaultVersionRunner,
_initProbe = initProbe ?? _defaultInitProbe,
_watch = watch,
_debounceFor = debounce;
final Directory _globalDir;
final Directory _cacheDir;
@@ -259,6 +238,7 @@ class ClaudeConfig extends ChangeNotifier {
_version = _parseVersion(await _guard(_versionRunner));
await _readProbeCache();
await _loadDiskConfig();
if (_disposed) return; // activation fired-and-forgot; teardown won
_startWatchers();
notifyListeners();
}
@@ -277,6 +257,7 @@ class ClaudeConfig extends ChangeNotifier {
if (probe == null) return; // stay on the static fallback
_probe = probe;
await _writeProbeCache(probe);
if (_disposed) return; // a slow probe racing a teardown mustn't notify a disposed notifier
notifyListeners();
} finally {
_probing = false;
@@ -288,6 +269,7 @@ class ClaudeConfig extends ChangeNotifier {
/// not re-resolved (the binary doesn't change under us at runtime).
Future<void> refresh() async {
await _loadDiskConfig();
if (_disposed) return; // a watcher-driven refresh racing a teardown mustn't notify a disposed notifier
notifyListeners();
}
@@ -298,12 +280,19 @@ class ClaudeConfig extends ChangeNotifier {
_stopWatching();
_projectDir = dir;
await _loadDiskConfig();
if (_disposed) return; // a project switch racing a teardown mustn't notify a disposed notifier
_startWatchers();
notifyListeners();
}
/// Set when [dispose] runs. The fire-and-forget [load] from extension
/// activation checks this so a teardown racing an in-flight load can't
/// notify (or start watchers on) a disposed notifier.
bool _disposed = false;
@override
void dispose() {
_disposed = true;
_stopWatching();
super.dispose();
}
@@ -400,10 +389,7 @@ class ClaudeConfig extends ChangeNotifier {
/// Global first so that local entries, added later, win on collisions.
List<(ConfigScope, Directory)> _scopeDirs() {
final pd = _projectDir;
return [
(ConfigScope.global, _globalDir),
if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude')),
];
return [(ConfigScope.global, _globalDir), if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude'))];
}
Future<List<ClaudeSkill>> _loadSkills(Directory scopeDir, ConfigScope scope) async {
@@ -415,12 +401,7 @@ class ClaudeConfig extends ChangeNotifier {
final manifest = File('${entry.path}/SKILL.md');
if (!await manifest.exists()) continue;
final fm = _parseFrontmatter(await manifest.readAsString());
out.add(ClaudeSkill(
name: fm.name ?? _basename(entry.path),
description: fm.description,
scope: scope,
path: manifest.path,
));
out.add(ClaudeSkill(name: fm.name ?? _basename(entry.path), description: fm.description, scope: scope, path: manifest.path));
}
return out;
}
@@ -432,11 +413,7 @@ class ClaudeConfig extends ChangeNotifier {
await for (final entry in dir.list()) {
if (entry is! File || !entry.path.endsWith('.md')) continue;
final base = _basename(entry.path);
out.add(ClaudeCommand(
name: base.substring(0, base.length - 3),
scope: scope,
path: entry.path,
));
out.add(ClaudeCommand(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
}
return out;
}
@@ -449,11 +426,7 @@ class ClaudeConfig extends ChangeNotifier {
await for (final entry in dir.list()) {
if (entry is! File || !entry.path.endsWith('.md')) continue;
final base = _basename(entry.path);
out.add(ClaudeAgent(
name: base.substring(0, base.length - 3),
scope: scope,
path: entry.path,
));
out.add(ClaudeAgent(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
}
return out;
}
@@ -472,11 +445,7 @@ class ClaudeConfig extends ChangeNotifier {
ClaudePermissions _permissionsOf(Map<String, Object?> settings) {
final p = settings['permissions'];
if (p is! Map) return const ClaudePermissions();
return ClaudePermissions(
allow: _stringList(p['allow']),
deny: _stringList(p['deny']),
ask: _stringList(p['ask']),
);
return ClaudePermissions(allow: _stringList(p['allow']), deny: _stringList(p['deny']), ask: _stringList(p['ask']));
}
// ---- Watching -----------------------------------------------------------
@@ -573,9 +542,7 @@ class ClaudeConfig extends ChangeNotifier {
/// Claude's `mcpServers` is a `Map<String, {...config}>` keyed on server name.
static List<ClaudeMcpServer> _parseMcpServers(Object? raw) {
if (raw is! Map) return const [];
return [
for (final key in raw.keys) ClaudeMcpServer(name: '$key'),
];
return [for (final key in raw.keys) ClaudeMcpServer(name: '$key')];
}
static List<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) {
@@ -608,14 +575,7 @@ Future<String?> _defaultVersionRunner() async {
Future<String?> _defaultInitProbe() async {
try {
final r = await Process.run('claude', [
'-p',
'.',
'--no-session-persistence',
'--output-format',
'stream-json',
'--verbose',
]);
final r = await Process.run('claude', ['-p', '.', '--no-session-persistence', '--output-format', 'stream-json', '--verbose']);
return r.stdout as String?;
} catch (_) {
return null;
File diff suppressed because it is too large Load Diff
+319 -69
View File
@@ -14,6 +14,7 @@ import 'clipboard_paste.dart';
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
import 'conversation_controller.dart';
import 'conversation_view.dart';
import 'model_picker_card.dart';
import 'permission_mode_control.dart';
import 'prompt_card.dart';
import 'session_index.dart';
@@ -24,6 +25,7 @@ import 'slash_commands.dart';
import 'stream_json_session.dart';
import 'task_list.dart';
import 'transcript_reader.dart';
import 'workflow_run.dart';
/// The Claude conversation pane. Drives `claude` over the stream-json control
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
@@ -71,7 +73,11 @@ class ClaudePane extends StatefulWidget {
class _ClaudePaneState extends State<ClaudePane> {
StreamSubscription<SessionStatus>? _statusSub;
StreamSubscription<SessionEnd>? _endSub;
StreamSubscription<ProjectOpened>? _projectSub;
StreamSubscription<Message>? _commandSub;
StreamSubscription<String>? _modelErrorSub;
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
ConversationController? _conversation;
StreamJsonSession? _session;
SessionStatus _status = const SessionStatus();
@@ -80,6 +86,21 @@ class _ClaudePaneState extends State<ClaudePane> {
String? _error;
String _statusLine = 'starting…';
/// One-shot fork source: seeds the first bind, then cleared so /clear,
/// /resume, and respawns operate on this pane's own session (T-375).
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;
/// Per-session composer draft (text + caret), held here so an unsent
@@ -141,6 +162,10 @@ class _ClaudePaneState extends State<ClaudePane> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Cache the kernel for dispose() — ancestor lookups there are illegal,
// and the old lookup-and-swallow leaked the settings listener on every
// disposed pane (T-366).
_kernel = ClideKernel.of(context);
// Spawn once, after the kernel is available.
if (!_spawned) {
_spawned = true;
@@ -155,6 +180,18 @@ class _ClaudePaneState extends State<ClaudePane> {
// GlobalKey and spawns once, so without this it would keep the previous
// repo's session after a switch (T-269).
_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
// (claude.activity.fold-level command, T-235).
ClideKernel.of(context).settings.addListener(_onSettingsChanged);
@@ -168,11 +205,18 @@ class _ClaudePaneState extends State<ClaudePane> {
@override
void dispose() {
activeClaudeConfig?.removeListener(_onConfigChanged);
_kernel()?.settings.removeListener(_onSettingsChanged);
_kernel?.settings.removeListener(_onSettingsChanged);
_projectSub?.cancel();
_commandSub?.cancel();
_projectSub = null;
_statusSub?.cancel();
_statusSub = null;
_endSub?.cancel();
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
// 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).
// A secondary tab being *closed* is a real teardown, so close its session;
@@ -197,9 +241,12 @@ class _ClaudePaneState extends State<ClaudePane> {
sub.cancel();
if (!c.isCompleted) c.complete();
});
await c.future.timeout(const Duration(seconds: 10), onTimeout: () {
sub.cancel();
});
await c.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
sub.cancel();
},
);
if (!mounted) return;
}
return _spawn();
@@ -221,6 +268,15 @@ class _ClaudePaneState extends State<ClaudePane> {
Future<void> _rebindToActiveProject() async {
_statusSub?.cancel();
_statusSub = null;
_endSub?.cancel();
_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
_conversation = null;
_session = null;
@@ -260,24 +316,31 @@ class _ClaudePaneState extends State<ClaudePane> {
}
final ManagedSession managed;
final forkSource = widget.forkSourceId;
final forkSource = _forkSource;
if (forkSource != null) {
// Fork pane: branch source session into a new clide-managed session.
// The clide-internal id is a fresh UUID; the real claude session id is
// assigned by `--fork-session` and arrives in the init event (T-172).
_sessionId ??= freshSessionId();
try {
managed = await orch.spawn(SpawnSpec(
id: _orchId,
role: 'fork ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
forkSourceSessionId: forkSource,
));
managed = await orch.spawn(
SpawnSpec(
id: _orchId,
role: 'fork ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
forkSourceSessionId: forkSource,
effort: _effort,
),
);
} catch (e) {
if (mounted) setState(() => _error = 'Could not start fork: $e');
return;
}
// One-shot: the fork source seeds only the FIRST bind. Leaving it set
// made /clear re-fork the original conversation instead of clearing —
// every later respawn must operate on this pane's own session (T-375).
_forkSource = null;
if (!mounted) return;
setState(() => _statusLine = 'fork of $forkSource');
} else {
@@ -291,14 +354,17 @@ class _ClaudePaneState extends State<ClaudePane> {
final resume = await File(transcriptFile).exists();
try {
managed = await orch.spawn(SpawnSpec(
id: _orchId,
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
resume: resume,
transcriptPath: resume ? transcriptFile : null,
));
managed = await orch.spawn(
SpawnSpec(
id: _orchId,
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
resume: resume,
transcriptPath: resume ? transcriptFile : null,
effort: _effort,
),
);
} catch (e) {
if (mounted) setState(() => _error = 'Could not start claude: $e');
return;
@@ -309,19 +375,55 @@ class _ClaudePaneState extends State<ClaudePane> {
_session = managed.session;
_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 —
// a fresh spawn vs connecting to existing on-disk history (the seed read
// from the transcript/sidecar). Surfaces the resume path in `make run`.
final seeded = _conversation?.items.length ?? 0;
_kernel()?.log.info(
'claude',
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot'
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
);
_kernel?.log.info(
'claude',
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot'
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
);
_statusSub = managed.session.statusStream.listen((s) {
if (!mounted) return;
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):
// late binders read the replayed end; live sessions stream it.
final alreadyEnded = managed.session.end;
if (alreadyEnded != null) {
_onSessionEnd(alreadyEnded);
} else {
_endSub = managed.session.endedStream.listen(_onSessionEnd);
}
}
/// The claude process exited under this pane's live session. Stop looking
/// busy, say so in the status line, and log the drained stderr tail —
/// the diagnostics that used to vanish (T-361).
void _onSessionEnd(SessionEnd end) {
if (!mounted) return;
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
// 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
@@ -341,10 +443,152 @@ class _ClaudePaneState extends State<ClaudePane> {
case 'fork':
_forkSession();
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);
}
/// 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),
/// de-duping immediate repeats. Empty/whitespace prompts are skipped.
void _appendHistory(String text) {
@@ -369,7 +613,7 @@ class _ClaudePaneState extends State<ClaudePane> {
/// background tap must never pull focus from (or resurrect) the composer
/// over an open prompt.
void _focusComposerOnTap() {
if (_session?.pendingPrompt != null) return;
if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen || _permissionPickerOpen) return;
_composerFocus.requestFocus();
}
@@ -421,18 +665,12 @@ class _ClaudePaneState extends State<ClaudePane> {
/// re-bind the pane to it.
Future<void> _resumeFlow() async {
final root = _repoRoot;
final dialog = _kernel()?.dialog;
final dialog = _kernel?.dialog;
if (root == null || dialog == null) return;
final dir = Directory(claudeProjectDir(root));
final sessions = await listSessions(dir);
if (!mounted) return;
final picked = await dialog.show<String>(
(ctx, dismiss) => SessionPickerDialog(
sessions: sessions,
onPick: (id) => dismiss(id),
onCancel: dismiss,
),
);
final picked = await dialog.show<String>((ctx, dismiss) => SessionPickerDialog(sessions: sessions, onPick: (id) => dismiss(id), onCancel: dismiss));
if (picked == null || !mounted) return;
setState(() => _statusLine = 'resuming…');
await _respawnWithSession(picked);
@@ -445,6 +683,15 @@ class _ClaudePaneState extends State<ClaudePane> {
Future<void> _respawnWithSession(String sessionId, {bool clearTranscript = false}) async {
_statusSub?.cancel();
_statusSub = null;
_endSub?.cancel();
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old session
// Erase only after the process is dead, so claude isn't mid-write.
final root = _repoRoot;
@@ -460,15 +707,10 @@ class _ClaudePaneState extends State<ClaudePane> {
// -- helpers --------------------------------------------------------------
DaemonClient? _ipc() => _kernel()?.ipc;
DaemonClient? _ipc() => _kernel?.ipc;
KernelServices? _kernel() {
try {
return ClideKernel.of(context);
} catch (_) {
return null;
}
}
/// Cached in didChangeDependencies (T-366); see note there.
KernelServices? _kernel;
// -- build ----------------------------------------------------------------
@@ -479,10 +721,7 @@ class _ClaudePaneState extends State<ClaudePane> {
final Widget body;
if (_error != null) {
body = Padding(
padding: const EdgeInsets.all(16),
child: ClideText(_error!, muted: true),
);
body = Padding(padding: const EdgeInsets.all(16), child: ClideText(_error!, muted: true));
} else if (_conversation != null) {
// Rebuild conversation + composer zone together on each prompt change so
// the view hides a prompted tool-use card the moment its prompt appears
@@ -504,10 +743,11 @@ class _ClaudePaneState extends State<ClaudePane> {
onTap: _focusComposerOnTap,
child: ConversationView(
controller: _conversation!,
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
foldLevel: foldLevelFromName(_kernel?.settings.get<String>(kActivityFoldLevelKey)),
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
@@ -520,13 +760,40 @@ class _ClaudePaneState extends State<ClaudePane> {
// with the conversation; renders nothing when there are no tasks.
ListenableBuilder(
listenable: _conversation!,
builder: (_, __) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
builder: (_, _) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
),
// An open prompt takes the composer's space and hides the text
// 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)
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
StreamBuilder<bool>(
stream: _session?.busyStream,
@@ -557,22 +824,11 @@ class _ClaudePaneState extends State<ClaudePane> {
body = const Center(child: ClideText('starting…', muted: true));
}
final content = widget.showChrome
? ClidePaneChrome(
title: title,
subtitle: _error ?? _statusLine,
child: body,
)
: body;
final content = widget.showChrome ? ClidePaneChrome(title: title, subtitle: _error ?? _statusLine, child: body) : body;
// Surface this pane's status to the bottom status-bar slot while it's
// the focused pane (T-150).
return ClidePane(
contributionId: widget.contributionId,
active: widget.active,
statusWidget: _statusWidget(tokens),
child: content,
);
return ClidePane(contributionId: widget.contributionId, active: widget.active, statusWidget: _statusWidget(tokens), child: content);
}
}
@@ -590,13 +846,7 @@ class _ModeBadge extends StatelessWidget {
return Semantics(
label: 'permission mode: ${permissionModeLabel(mode)}',
excludeSemantics: true,
child: ClideText(
permissionModeLabel(mode),
fontSize: clideFontSmall,
fontFamily: clideMonoFamily,
color: permissionModeColor(mode, tokens),
maxLines: 1,
),
child: ClideText(permissionModeLabel(mode), fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: permissionModeColor(mode, tokens), maxLines: 1),
);
}
}
+14 -10
View File
@@ -85,11 +85,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
/// Public entry point used by the `claude.new-secondary` command.
void addSecondary() {
final index = _nextSecondary++;
_controller.add(MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'session $index',
payload: _Session(isPrimary: false, secondaryIndex: index),
));
_controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'session $index',
payload: _Session(isPrimary: false, secondaryIndex: index),
),
);
}
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
@@ -99,11 +101,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
/// without touching the original.
void addFork(String sourceClaudeSessionId) {
final index = _nextSecondary++;
_controller.add(MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'fork $index',
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
));
_controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'fork $index',
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
),
);
}
@override
+9 -12
View File
@@ -8,12 +8,7 @@ library;
import 'dart:convert';
class DailyActivity {
const DailyActivity({
required this.date,
required this.messageCount,
required this.sessionCount,
required this.toolCallCount,
});
const DailyActivity({required this.date, required this.messageCount, required this.sessionCount, required this.toolCallCount});
final String date; // "YYYY-MM-DD" (sorts chronologically as a string)
final int messageCount;
@@ -54,12 +49,14 @@ ClaudeStats parseClaudeStats(String jsonStr) {
if (da is List) {
for (final e in da) {
if (e is! Map) continue;
daily.add(DailyActivity(
date: '${e['date']}',
messageCount: _int(e['messageCount']),
sessionCount: _int(e['sessionCount']),
toolCallCount: _int(e['toolCallCount']),
));
daily.add(
DailyActivity(
date: '${e['date']}',
messageCount: _int(e['messageCount']),
sessionCount: _int(e['sessionCount']),
toolCallCount: _int(e['toolCallCount']),
),
);
}
}
return ClaudeStats(lastComputed: j['lastComputedDate'] as String?, daily: daily);
+41 -6
View File
@@ -53,8 +53,8 @@ String nextSafePermissionMode(String current) {
}
/// 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]
/// is the model; [trailing] joins context / cost / rate-limit. Either may be
/// render the mode as an interactive control between them (T-226). `leading`
/// is the model; `trailing` joins context / cost / rate-limit. Either may be
/// null when there's nothing to show.
({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) {
final trailing = [
@@ -62,10 +62,7 @@ String nextSafePermissionMode(String current) {
if (s.cost != null) '\$${s.cost!.toStringAsFixed(2)}',
if (s.rateLimitInfo != null) s.rateLimitInfo!,
].join(' · ');
return (
leading: s.model != null ? shortModelLabel(s.model!) : null,
trailing: trailing.isEmpty ? null : trailing,
);
return (leading: s.model != null ? shortModelLabel(s.model!) : null, trailing: trailing.isEmpty ? null : trailing);
}
/// Friendly label for Claude's permission modes.
@@ -95,3 +92,41 @@ String formatTokenCount(int n) {
if (n >= 1000) return '${(n / 1000).round()}k';
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;
}
+22 -24
View File
@@ -62,10 +62,7 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
ClideDivider(),
Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [for (final t in tasks) _taskRow(tokens, t)],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [for (final t in tasks) _taskRow(tokens, t)]),
),
],
],
@@ -76,20 +73,22 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
}
Widget _summaryRow(SurfaceTokens tokens, String summary, String? current) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8),
ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
if (!_expanded && current != null) ...[
const SizedBox(width: 10),
Expanded(child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis)),
] else
const Spacer(),
],
),
);
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8),
ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
if (!_expanded && current != null) ...[
const SizedBox(width: 10),
Expanded(
child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis),
),
] else
const Spacer(),
],
),
);
Widget _taskRow(SurfaceTokens tokens, TaskItem t) {
final (String glyph, Color color, String word) = switch (t.status) {
@@ -106,14 +105,13 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 1), child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color)),
Padding(
padding: const EdgeInsets.only(top: 1),
child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color),
),
const SizedBox(width: 8),
Expanded(
child: ClideText(
t.text,
fontSize: clideFontCaption,
color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground,
),
child: ClideText(t.text, fontSize: clideFontCaption, color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground),
),
],
),
+2 -8
View File
@@ -99,16 +99,10 @@ String pasteCacheDir() {
/// written to [tempDir] (default [pasteCacheDir]) and attached by its
/// path. Returns an empty list when the clipboard holds neither, so the
/// composer pastes text instead.
Future<List<ComposerAttachment>> resolveClipboardAttachment(
ClipboardSource source, {
Directory? tempDir,
DateTime Function() now = DateTime.now,
}) async {
Future<List<ComposerAttachment>> resolveClipboardAttachment(ClipboardSource source, {Directory? tempDir, DateTime Function() now = DateTime.now}) async {
final files = await source.readFiles();
if (files.isNotEmpty) {
return [
for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p)),
];
return [for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p))];
}
final image = await source.readImage();
+13 -29
View File
@@ -183,20 +183,13 @@ class _ConversationCardState extends State<ConversationCard> {
if (!_collapsed) ...[
const SizedBox(height: 4),
widget.body,
for (final seg in widget.extraSegments) ...[
_segmentLabel(tokens, seg.label),
seg.child,
],
for (final seg in widget.extraSegments) ...[_segmentLabel(tokens, seg.label), seg.child],
],
],
);
return Padding(
padding: widget.margin,
child: MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: _frame(tokens, content),
),
child: MouseRegion(onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: _frame(tokens, content)),
);
}
@@ -289,11 +282,7 @@ class _ConversationCardState extends State<ConversationCard> {
onTap: () => setState(() => _collapsed = !_collapsed),
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(right: 6),
child: ClideIcon(
_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'),
size: 12,
color: tokens.globalTextMuted,
),
child: ClideIcon(_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'), size: 12, color: tokens.globalTextMuted),
),
),
);
@@ -330,15 +319,15 @@ class _ConversationCardState extends State<ConversationCard> {
/// A muted sub-label + hairline divider introducing an [CardSegment] below
/// the primary body (T-262), so CALL/PROMPT/RESULT read as distinct parts.
Widget _segmentLabel(SurfaceTokens tokens, String label) => Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8),
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
],
),
);
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8),
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
],
),
);
List<Widget> _actions(SurfaceTokens tokens) {
final items = <_ActionItem>[];
@@ -360,12 +349,7 @@ class _ConversationCardState extends State<ConversationCard> {
onTap: items[i].onTap,
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(left: 10),
child: ClideText(
items[i].label,
fontSize: clideFontMeta,
color: tokens.globalTextMuted,
fontFamily: clideMonoFamily,
),
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
),
),
),
@@ -21,26 +21,20 @@ class ConversationController extends ChangeNotifier {
/// over stream-json so the pane would otherwise start empty. [onDispose]
/// is invoked from [dispose] — wire it to the reader's `dispose` so
/// cancelling the view tears down the underlying tail.
ConversationController({
required Stream<ConversationItem> stream,
Iterable<ConversationItem>? seed,
Future<void> Function()? onDispose,
}) : _onDispose = onDispose {
ConversationController({required Stream<ConversationItem> stream, Iterable<ConversationItem>? seed, Future<void> Function()? onDispose})
: _onDispose = onDispose {
if (seed != null) _items.addAll(seed);
_sub = stream.listen(_onItem);
}
/// Build a controller fed from the kernel [MessageBus] — it consumes
/// the [ConversationItem]s a [TranscriptPublisher] writes onto
/// [publisher]/[channel]. Decouples the view from the reader so several
/// the [ConversationItem]s a `TranscriptPublisher` writes onto
/// `publisher`/[channel]. Decouples the view from the reader so several
/// panels can render the same conversation (team work, T-139/T-140).
factory ConversationController.fromBus({
required MessageBus messages,
String channel = ClaudeConversation.leadChannel,
Future<void> Function()? onDispose,
}) {
final stream =
messages.subscribe(publisher: ClaudeConversation.publisher, channel: channel).map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
final stream = messages
.subscribe(publisher: ClaudeConversation.publisher, channel: channel)
.map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
return ConversationController(stream: stream, onDispose: onDispose);
}
+377 -136
View File
@@ -14,15 +14,22 @@ import 'dart:convert';
import 'dart:io';
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/claude_status.dart' show shortModelLabel;
import 'package:clide/builtin/claude/src/conversation_card.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/image_thumbnail.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/workflow_run.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/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/src/terminal/terminal.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
@@ -35,11 +42,18 @@ class ConversationView extends StatefulWidget {
this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{},
this.workflows = const <String, WorkflowRun>{},
this.foldLevel = FoldLevel.tools,
});
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)
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
final FoldLevel foldLevel;
@@ -181,11 +195,9 @@ class _ConversationViewState extends State<ConversationView> {
/// - [runByToolUseId]: the rest of the run — prose / thinking / tool cards —
/// nested in a holder UNDER the Agent card (T-264), with a successful
/// sidechain tool result left out (it folds into its own tool card).
({
Set<String> ownedSidechainUuids,
Map<String, List<UserMessage>> promptsByToolUseId,
Map<String, List<ConversationItem>> runByToolUseId,
}) _sidechainFold(List<ConversationItem> items) {
({Set<String> ownedSidechainUuids, Map<String, List<UserMessage>> promptsByToolUseId, Map<String, List<ConversationItem>> runByToolUseId}) _sidechainFold(
List<ConversationItem> items,
) {
final agentByMsgUuid = <String, AssistantToolUse>{
for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
@@ -207,6 +219,15 @@ class _ConversationViewState extends State<ConversationView> {
if (it is AssistantToolUse) toolUseIds.add(it.toolUseId);
}
// With more than one agent in the turn (a parallel fan-out), the
// "nearest preceding agent" fallback is unsafe: an item with no
// parent_tool_use_id and no rooted parentUuid chain would mis-file into
// whichever agent was emitted last — landing in a SIBLING agent's card.
// Drop the fallback in that case so an unattributable item orphans
// (rendered inline) rather than cross-attributed (T-342). A single agent
// has only one possible owner, so the fallback stays safe there.
final multipleAgents = agentByToolUseId.length > 1;
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
// Direct route: stream-json hands us the spawning Agent's tool-use id on
// the item itself (T-338) — no chain to walk.
@@ -225,7 +246,7 @@ class _ConversationViewState extends State<ConversationView> {
if (sidechainByUuid[parent] != true) break; // left the run's chain
cur = parent;
}
return nearest;
return multipleAgents ? null : nearest;
}
final owned = <String>{};
@@ -258,9 +279,13 @@ class _ConversationViewState extends State<ConversationView> {
void _onChanged() {
if (!mounted) return;
setState(() {});
// Follow the tail — jump to the bottom after the new item lays out.
// Follow the tail — but only when already pinned to it. New items arrive
// on every streamed token; jumping unconditionally yanks a reader who
// scrolled up back to the bottom for the whole reply (T-368, twin of the
// T-297 resize gate).
if (!_atBottom) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
if (_scroll.hasClients && _atBottom) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
}
});
@@ -308,39 +333,41 @@ class _ConversationViewState extends State<ConversationView> {
// visible list don't reattach State to the wrong card (T-285).
return switch (g) {
StickyItem(:final item) => _ConversationTurn(
key: ValueKey('turn.${item.uuid}'),
item: item,
tokens: tokens,
collapseTools: true,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
key: ValueKey('turn.${item.uuid}'),
item: item,
tokens: tokens,
collapseTools: true,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
),
FoldedCluster(:final items) => _ActivityCard(
key: ValueKey('cluster.${items.first.uuid}'),
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
key: ValueKey('cluster.${items.first.uuid}'),
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
),
EditRun(:final edits) => _EditRunCard(
key: ValueKey('edits.${edits.first.uuid}'),
edits: edits,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
key: ValueKey('edits.${edits.first.uuid}'),
edits: edits,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
};
},
),
@@ -362,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
return list;
},
);
return ColoredBox(
final body = ColoredBox(
color: tokens.panelBackground,
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;
}
}
@@ -373,9 +438,9 @@ class _ConversationViewState extends State<ConversationView> {
/// used for the "claude" message card's stripe + label.
const claudeAccent = Color(0xFFD97757);
/// The tool names that launch a sub-agent (sidechain). Claude Code emits
/// `Task`; the Agent SDK surface uses `Agent` — accept both (T-263).
bool _isAgentTool(String name) => name == 'Task' || name == 'Agent';
/// The tool names that launch a sub-agent (sidechain) — shared with the
/// grouping pass so "is this an agent spawn?" has one definition (T-342).
bool _isAgentTool(String name) => isAgentTool(name);
/// Open a governance/ticket record clicked in the conversation (T-279) in its
/// context-pane reader, reusing the existing `selection` MessageBus addressing
@@ -413,7 +478,67 @@ String? resolveWorkspaceFilePath(String? root, String raw) {
/// Open a clicked workspace file reference in the editor, jumping to [line]
/// when present — the Dart-side twin of `clide editor open <path>` (T-300, D-6).
void _openFile(BuildContext context, String path, int? line) {
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, if (line != null) 'line': line}));
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, 'line': ?line}));
}
/// A live, read-only tail of the file a Bash command follows (T-325).
///
/// Mounts when the Bash card is EXPANDED — the collapser builds its children
/// lazily (clide_collapser_card.dart), so initialising here and tearing down in
/// [dispose] gives the "connect on expand, disconnect on collapse" lifecycle
/// for free. Resolves the followed file from the command against the open
/// workspace; when there's no independent file-backed source (a pipe into
/// `tail`, a path outside the repo) it shows a muted note instead of an empty
/// terminal.
class _BashLiveTail extends StatefulWidget {
const _BashLiveTail({required this.command});
final String command;
@override
State<_BashLiveTail> createState() => _BashLiveTailState();
}
class _BashLiveTailState extends State<_BashLiveTail> {
Terminal? _terminal;
FileTailFollower? _follower;
bool _resolved = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_resolved) return; // resolve once — InheritedWidget access needs context
_resolved = true;
final root = ClideKernel.of(context).project.current;
final source = root == null ? null : detectBashTailSource(widget.command, workspaceRoot: root);
if (source == null) return; // no file-backed source → muted note in build
final term = Terminal(maxLines: 1000);
_terminal = term;
// writeBytes: the follower's chunk boundaries are arbitrary (it can even
// start mid-rune by construction) — keep decode state across reads (T-373).
_follower = FileTailFollower(source, onData: term.writeBytes);
unawaited(_follower!.start());
}
@override
void dispose() {
_follower?.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final term = _terminal;
if (term == null) {
return ClideText('no independent source to follow', muted: true, fontSize: clideFontMeta);
}
return SizedBox(
height: 160,
child: ClipRect(
child: ClidePtyView(terminal: term, label: 'live tail', fontSize: clideFontMeta),
),
);
}
}
/// One conversation item, rendered by kind.
@@ -429,6 +554,7 @@ class _ConversationTurn extends StatelessWidget {
this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{},
this.runByToolUseId = const <String, List<ConversationItem>>{},
this.workflows = const <String, WorkflowRun>{},
});
final ConversationItem item;
@@ -464,6 +590,9 @@ class _ConversationTurn extends StatelessWidget {
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
final Map<String, List<ConversationItem>> runByToolUseId;
/// Live Workflow runs keyed by launching tool-use id (T-416).
final Map<String, WorkflowRun> workflows;
@override
Widget build(BuildContext context) {
final i = item;
@@ -474,63 +603,74 @@ class _ConversationTurn extends StatelessWidget {
// sidechain prompt here is an orphan one (its Agent card couldn't be
// resolved) — folded prompts are suppressed upstream (T-263).
UserMessage() when i.injected || i.isSidechain => ConversationCard(
// Framed like every other card (T-306) — just muted + collapsed, not
// the blue "you" accent (D-78); bare read as unfinished next to the
// carded tool calls.
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent prompt' : 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
margin: _childMargin,
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
// Framed like every other card (T-306) — just muted + collapsed, not
// the blue "you" accent (D-78); bare read as unfinished next to the
// carded tool calls.
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent prompt' : 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
margin: _childMargin,
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
UserMessage() => ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
margin: _childMargin,
// Pasted-image @path tokens render as inline thumbnails that open the
// lightbox (T-236/T-254); copyText keeps the original text verbatim.
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
margin: _childMargin,
// Pasted-image @path tokens render as inline thumbnails that open the
// lightbox (T-236/T-254); copyText keeps the original text verbatim.
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
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
// agent with a muted accent, never the coral "claude" brand (T-265). The
// coral claudeAccent is reserved for the real main-thread Claude.
AssistantTextMessage() => ConversationCard(
accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent,
label: i.isSidechain ? 'agent' : 'claude',
copyText: i.text,
margin: _childMargin,
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent,
label: i.isSidechain ? 'agent' : 'claude',
copyText: i.text,
margin: _childMargin,
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
),
AssistantThinkingMessage() => ConversationCard(
// Framed + muted like the context card (T-306).
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent thinking' : 'thinking',
copyText: i.thinking,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.thinking),
margin: _childMargin,
body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
),
// Framed + muted like the context card (T-306).
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent thinking' : 'thinking',
copyText: i.thinking,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.thinking),
margin: _childMargin,
body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
),
AssistantToolUse() => collapseTools ? _toolUseCollapser(i) : _toolContentCard(i),
ToolResultMessage() => _toolResult(i),
ImageMessage() => _image(context, i),
@@ -566,16 +706,13 @@ class _ConversationTurn extends StatelessWidget {
image: ClideFileImage(m.path),
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
errorBuilder: (_, _, _) => _imagePlaceholder(m.path),
),
),
),
),
),
if (caption != null && caption.isNotEmpty) ...[
const SizedBox(height: 4),
ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted),
],
if (caption != null && caption.isNotEmpty) ...[const SizedBox(height: 4), ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted)],
],
),
);
@@ -583,34 +720,30 @@ class _ConversationTurn extends StatelessWidget {
void _openLightbox(BuildContext context, String path) {
ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
),
),
);
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _imagePlaceholder(path)),
),
);
}
Widget _imagePlaceholder(String path) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Flexible(
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Flexible(
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
),
],
),
);
],
),
);
/// A standalone tool use (T-305): every tool use is a collapser over a
/// one-item list. The collapser carries the echoed last line, the count, and
@@ -619,6 +752,13 @@ class _ConversationTurn extends StatelessWidget {
/// and its own per-item mark. An Agent/Task call also nests its visible
/// sub-agent run in a second collapser below (T-264).
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 color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
final collapser = ClideCollapserCard(
@@ -662,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
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
/// collapse caret — the enclosing collapser owns collapse. Used both as a
@@ -685,8 +918,23 @@ class _ConversationTurn extends StatelessWidget {
// (note E): call input (body) → prompt → returned result.
final segments = <CardSegment>[
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)),
if (succeeded && !(isAgent && hasRun)) CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t))),
CardSegment(
label: 'prompt',
child: ClideText(p.text, muted: true, fontSize: clideFontMeta),
),
if (succeeded && !(isAgent && hasRun))
CardSegment(
label: 'result',
child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)),
),
// T-325: a Bash card that follows a file (`tail -f …`) gets a live,
// scrolling tail of that file below the result — connected lazily, only
// while the card is expanded (the collapser builds segments on expand).
if (t.name == 'Bash' && t.input['command'] is String && bashHasTailIntent(t.input['command'] as String))
CardSegment(
label: 'live tail',
child: _BashLiveTail(command: t.input['command'] as String),
),
];
// A resolved permission-prompted call is tinted green if approved / red if
@@ -757,12 +1005,7 @@ class _ConversationTurn extends StatelessWidget {
collapsible: quiet || multiline,
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
body: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: quiet ? tokens.globalTextMuted : tokens.statusError,
),
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: quiet ? tokens.globalTextMuted : tokens.statusError),
);
}
@@ -783,12 +1026,7 @@ class _ConversationTurn extends StatelessWidget {
collapsedSummary: multiline ? _firstLine(t.content) : null,
body: isOutputTool
? ClideCodeBlock(source: t.content, language: 'text')
: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalForeground,
),
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
);
}
@@ -824,6 +1062,7 @@ class _ActivityCard extends StatelessWidget {
required this.resultByToolUseId,
required this.promptsByToolUseId,
required this.runByToolUseId,
this.workflows = const <String, WorkflowRun>{},
});
final List<ConversationItem> items;
@@ -834,6 +1073,7 @@ class _ActivityCard extends StatelessWidget {
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
final Map<String, List<ConversationItem>> runByToolUseId;
final Map<String, WorkflowRun> workflows;
@override
Widget build(BuildContext context) {
@@ -855,6 +1095,7 @@ class _ActivityCard extends StatelessWidget {
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
runByToolUseId: runByToolUseId,
workflows: workflows,
),
],
);
+283 -274
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/claude_config.dart';
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/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/pane_context_status.dart';
@@ -21,6 +22,18 @@ import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// D-6 contract (T-391): a failed command returns an ERROR envelope (non-zero
/// CLI exit), never `ok` with an `error` field a script can't detect.
IpcResponse _userErr(String msg, {String? hint}) => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: msg, hint: hint),
);
IpcResponse _notFound(String msg) => IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: msg),
);
class ClaudeExtension extends ClideExtension {
@override
String get id => 'builtin.claude';
@@ -48,270 +61,266 @@ class ClaudeExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'claude.primary',
slot: Slots.workspace,
title: 'Claude',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 90,
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
),
CommandContribution(
id: 'claude.new-secondary',
command: 'claude.new-secondary',
title: 'Claude: open a secondary session',
run: (_) async {
_hostKey.currentState?.addSecondary();
return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
},
),
CommandContribution(
id: 'claude.kill-all-sessions',
command: 'claude.kill-all-sessions',
title: 'Claude: kill all sessions for this repo',
run: _killAllSessions,
),
CommandContribution(
id: 'claude.session-storage',
command: 'claude.session-storage',
title: 'Claude: session storage (disk usage + cleanup)',
run: _manageStorage,
),
// T-235: cycle how aggressively the activity card folds meta steps
// (none → tools → thinking → everything), persisted app-wide. The panes
// read kActivityFoldLevelKey and rebuild via the settings notifier.
CommandContribution(
id: 'claude.activity.fold-level',
command: 'claude.activity.fold-level',
title: 'Claude: cycle activity fold level',
run: _cycleFoldLevel,
),
// T-171: agent roster controls (D-6 CLI/UI parity).
// Usage: clide claude.agent.show <sessionId>
CommandContribution(
id: 'claude.agent.show',
command: 'claude.agent.show',
title: 'Claude: show an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.show(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
},
),
CommandContribution(
id: 'claude.agent.hide',
command: 'claude.agent.hide',
title: 'Claude: hide an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.hide(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
},
),
CommandContribution(
id: 'claude.agent.close',
command: 'claude.agent.close',
title: 'Claude: close (kill) an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
await _orchestrator?.close(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
},
),
CommandContribution(
id: 'claude.agent.mute',
command: 'claude.agent.mute',
title: 'Claude: mute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.mute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
},
),
CommandContribution(
id: 'claude.agent.unmute',
command: 'claude.agent.unmute',
title: 'Claude: unmute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.unmute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
},
),
// Usage: clide claude.agent.inject-message <sessionId> <text...>
CommandContribution(
id: 'claude.agent.inject-message',
command: 'claude.agent.inject-message',
title: 'Claude: inject a text turn into an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
final text = args.skip(1).join(' ');
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
_orchestrator?.injectMessage(id, text);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
},
),
// T-181: set permission mode for an agent session (D-6 CLI/UI parity).
// Usage: clide claude.agent.set-permission-mode <sessionId> <mode>
// <mode> must be one of: default, acceptEdits, plan, bypassPermissions.
// Note: bypassPermissions is accepted via CLI — the footgun guard is the
// UI's confirm dialog; the CLI caller is responsible for their own safety.
CommandContribution(
id: 'claude.agent.set-permission-mode',
command: 'claude.agent.set-permission-mode',
title: 'Claude: set permission mode for an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
final mode = args.length >= 2 ? args[1] : null;
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
if (!valid.contains(mode)) {
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
}
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
},
),
// T-226: cycle the primary session's permission mode through the safe
// trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M.
CommandContribution(
id: 'claude.mode.cycle',
command: 'claude.mode.cycle',
title: 'Claude: Cycle permission mode',
run: (_) async {
final managed = _orchestrator?.byId('primary');
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
managed.session.setPermissionMode(next);
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
},
),
// Usage: clide claude.task.reassign <taskId> <toSessionId>
CommandContribution(
id: 'claude.task.reassign',
command: 'claude.task.reassign',
title: 'Claude: reassign a shared task to an agent',
run: (args) async {
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
final taskId = args[0];
final toId = args[1];
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
},
),
// T-180: full team chat pane opened as a workspace tab.
// Shares the TeamChatModel with the sidebar widget.
TabContribution(
id: 'claude.team-chat',
slot: Slots.workspace,
title: 'Team Chat',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 85,
build: (_) {
final orch = _orchestrator;
if (orch == null) return const SizedBox.shrink();
return TeamChatPane(model: orch.chatModel, broker: orch.broker);
},
),
// CLI parity: open the team chat pane from the shell.
// Usage: clide claude.team-chat.open
CommandContribution(
id: 'claude.team-chat.open',
command: 'claude.team-chat.open',
title: 'Claude: open the team chat pane',
run: (args) async {
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
return IpcResponse.ok(id: '', data: const {'status': 'opened'});
},
),
// Usage: clide claude.team-chat.post [@name] <text...>
// Posts a message into the broker channel as the user.
// Leading @name tag selects the recipient; omit for broadcast.
CommandContribution(
id: 'claude.team-chat.post',
command: 'claude.team-chat.post',
title: 'Claude: post a message into the team channel as the user',
run: (args) async {
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
final raw = args.join(' ');
String? recipient;
String body = raw;
if (raw.startsWith('@')) {
final ws = raw.indexOf(RegExp(r'\s'));
if (ws > 0) {
final tag = raw.substring(1, ws);
recipient = (tag == 'team' || tag.isEmpty) ? null : tag;
body = raw.substring(ws).trim();
}
}
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
return IpcResponse.ok(id: '', data: {'status': 'posted', if (recipient != null) 'to': recipient});
},
),
// claude.agent.fork: branch a managed session into a new fork session
// (T-172, D-6 CLI/UI parity for the roster fork button).
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
// <sourceSessionId>: the clide-internal id of the session to fork.
// <cwd>: optional working directory; defaults to the source session's cwd.
CommandContribution(
id: 'claude.agent.fork',
command: 'claude.agent.fork',
title: 'Claude: fork a managed session into a new branch session',
run: (args) async {
final sourceId = args.firstOrNull;
if (sourceId == null) {
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
}
final orch = _orchestrator;
if (orch == null) {
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
}
final source = orch.byId(sourceId);
if (source == null) {
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
}
final cwd = args.length >= 2 ? args[1] : source.cwd;
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
await orch.spawn(SpawnSpec(
id: forkId,
role: 'fork of $sourceId',
sessionId: forkId,
cwd: cwd,
forkSourceSessionId: source.sessionId,
));
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
},
),
// Always-pickable left-panel tab: Claude activity (from
// stats-cache.json) + the team roster when a team is running (T-141).
TabContribution(
id: 'claude.meta',
slot: Slots.sidebar,
title: 'Activity',
icon: PhosphorIcons.byName('robot'),
priority: 60,
build: (_) => const ClaudeMetaSidebar(),
),
// In-pane status slot (T-145): the active Claude pane publishes
// its model · permission-mode · context line here.
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
// yields width under pressure and ClideMarquee scrolls (T-160).
StatusItemContribution(
id: 'claude.status-context',
priority: 50,
flex: 1,
build: (_) => const PaneContextStatusItem(),
),
];
TabContribution(
id: 'claude.primary',
slot: Slots.workspace,
title: 'Claude',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 90,
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
),
CommandContribution(
id: 'claude.new-secondary',
command: 'claude.new-secondary',
title: 'Claude: open a secondary session',
run: (_) async {
_hostKey.currentState?.addSecondary();
return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
},
),
CommandContribution(
id: 'claude.kill-all-sessions',
command: 'claude.kill-all-sessions',
title: 'Claude: kill all sessions for this repo',
run: _killAllSessions,
),
CommandContribution(
id: 'claude.session-storage',
command: 'claude.session-storage',
title: 'Claude: session storage (disk usage + cleanup)',
run: _manageStorage,
),
// T-235: cycle how aggressively the activity card folds meta steps
// (none → tools → thinking → everything), persisted app-wide. The panes
// read kActivityFoldLevelKey and rebuild via the settings notifier.
CommandContribution(
id: 'claude.activity.fold-level',
command: 'claude.activity.fold-level',
title: 'Claude: cycle activity fold level',
run: _cycleFoldLevel,
),
// T-171: agent roster controls (D-6 CLI/UI parity).
// Usage: clide claude.agent.show <sessionId>
CommandContribution(
id: 'claude.agent.show',
command: 'claude.agent.show',
title: 'Claude: show an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return _userErr('missing session id');
_orchestrator?.show(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
},
),
CommandContribution(
id: 'claude.agent.hide',
command: 'claude.agent.hide',
title: 'Claude: hide an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return _userErr('missing session id');
_orchestrator?.hide(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
},
),
CommandContribution(
id: 'claude.agent.close',
command: 'claude.agent.close',
title: 'Claude: close (kill) an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return _userErr('missing session id');
await _orchestrator?.close(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
},
),
CommandContribution(
id: 'claude.agent.mute',
command: 'claude.agent.mute',
title: 'Claude: mute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return _userErr('missing session id');
_orchestrator?.mute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
},
),
CommandContribution(
id: 'claude.agent.unmute',
command: 'claude.agent.unmute',
title: 'Claude: unmute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return _userErr('missing session id');
_orchestrator?.unmute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
},
),
// Usage: clide claude.agent.inject-message <sessionId> <text...>
CommandContribution(
id: 'claude.agent.inject-message',
command: 'claude.agent.inject-message',
title: 'Claude: inject a text turn into an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return _userErr('missing session id');
final text = args.skip(1).join(' ');
if (text.isEmpty) return _userErr('missing message text');
_orchestrator?.injectMessage(id, text);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
},
),
// T-181: set permission mode for an agent session (D-6 CLI/UI parity).
// Usage: clide claude.agent.set-permission-mode <sessionId> <mode>
// <mode> must be one of: default, acceptEdits, plan, bypassPermissions.
// Note: bypassPermissions is accepted via CLI — the footgun guard is the
// UI's confirm dialog; the CLI caller is responsible for their own safety.
CommandContribution(
id: 'claude.agent.set-permission-mode',
command: 'claude.agent.set-permission-mode',
title: 'Claude: set permission mode for an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return _userErr('missing session id');
final mode = args.length >= 2 ? args[1] : null;
if (mode == null) return _userErr('missing mode (default|acceptEdits|plan|bypassPermissions)');
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
if (!valid.contains(mode)) {
return _userErr('unknown mode "$mode"; use one of: ${valid.join(', ')}');
}
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
},
),
// T-226: cycle the primary session's permission mode through the safe
// trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M.
CommandContribution(
id: 'claude.mode.cycle',
command: 'claude.mode.cycle',
title: 'Claude: Cycle permission mode',
run: (_) async {
final managed = _orchestrator?.byId('primary');
if (managed == null) return _notFound('no primary session');
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
managed.session.setPermissionMode(next);
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
},
),
// Usage: clide claude.task.reassign <taskId> <toSessionId>
CommandContribution(
id: 'claude.task.reassign',
command: 'claude.task.reassign',
title: 'Claude: reassign a shared task to an agent',
run: (args) async {
if (args.length < 2) return _userErr('usage: <taskId> <sessionId>');
final taskId = args[0];
final toId = args[1];
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
if (!ok) return _notFound('could not reassign task "$taskId" to "$toId"');
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': true});
},
),
// T-180: full team chat pane opened as a workspace tab.
// Shares the TeamChatModel with the sidebar widget.
TabContribution(
id: 'claude.team-chat',
slot: Slots.workspace,
title: 'Team Chat',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 85,
build: (_) {
final orch = _orchestrator;
if (orch == null) return const SizedBox.shrink();
return TeamChatPane(model: orch.chatModel, broker: orch.broker);
},
),
// CLI parity: open the team chat pane from the shell.
// Usage: clide claude.team-chat.open
CommandContribution(
id: 'claude.team-chat.open',
command: 'claude.team-chat.open',
title: 'Claude: open the team chat pane',
run: (args) async {
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
return IpcResponse.ok(id: '', data: const {'status': 'opened'});
},
),
// Usage: clide claude.team-chat.post [@name] <text...>
// Posts a message into the broker channel as the user.
// Leading @name tag selects the recipient; omit for broadcast.
CommandContribution(
id: 'claude.team-chat.post',
command: 'claude.team-chat.post',
title: 'Claude: post a message into the team channel as the user',
run: (args) async {
if (args.isEmpty) return _userErr('usage: [@name] <text>');
final raw = args.join(' ');
String? recipient;
String body = raw;
if (raw.startsWith('@')) {
final ws = raw.indexOf(RegExp(r'\s'));
if (ws > 0) {
final tag = raw.substring(1, ws);
recipient = (tag == 'team' || tag.isEmpty) ? null : tag;
body = raw.substring(ws).trim();
}
}
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
return IpcResponse.ok(id: '', data: {'status': 'posted', 'to': ?recipient});
},
),
// claude.agent.fork: branch a managed session into a new fork session
// (T-172, D-6 CLI/UI parity for the roster fork button).
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
// <sourceSessionId>: the clide-internal id of the session to fork.
// <cwd>: optional working directory; defaults to the source session's cwd.
CommandContribution(
id: 'claude.agent.fork',
command: 'claude.agent.fork',
title: 'Claude: fork a managed session into a new branch session',
run: (args) async {
final sourceId = args.firstOrNull;
if (sourceId == null) {
return _userErr('usage: claude.agent.fork <sourceSessionId> [<cwd>]');
}
final orch = _orchestrator;
if (orch == null) {
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'orchestrator unavailable'),
);
}
final source = orch.byId(sourceId);
if (source == null) {
return _notFound('unknown session "$sourceId"');
}
final cwd = args.length >= 2 ? args[1] : source.cwd;
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
await orch.spawn(SpawnSpec(id: forkId, role: 'fork of $sourceId', sessionId: forkId, cwd: cwd, forkSourceSessionId: source.sessionId));
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
},
),
// Always-pickable left-panel tab: Claude activity (from
// stats-cache.json) + the team roster when a team is running (T-141).
TabContribution(
id: 'claude.meta',
slot: Slots.sidebar,
title: 'Activity',
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,
build: (_) => const ClaudeMetaSidebar(),
),
// In-pane status slot (T-145): the active Claude pane publishes
// its model · permission-mode · context line here.
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
// yields width under pressure and ClideMarquee scrolls (T-160).
StatusItemContribution(id: 'claude.status-context', priority: 50, flex: 1, build: (_) => const PaneContextStatusItem()),
];
@override
Future<void> activate(ClideExtensionContext ctx) async {
@@ -399,13 +408,15 @@ class ClaudeExtension extends ClideExtension {
}
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
if (target == null) return;
target.conversation.inject(ImageMessage(
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(),
isSidechain: false,
path: path,
caption: m.data['caption'] as String?,
));
target.conversation.inject(
ImageMessage(
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(),
isSidechain: false,
path: path,
caption: m.data['caption'] as String?,
),
);
}
@override
@@ -466,9 +477,7 @@ class ClaudeExtension extends ClideExtension {
if (root == null || home == null) return IpcResponse.ok(id: '', data: const {});
final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}');
final sessions = await listSessions(dir);
await ctx.dialog.show<Object>(
(c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss),
);
await ctx.dialog.show<Object>((c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss));
return IpcResponse.ok(id: '', data: const {'status': 'shown'});
}
}
@@ -0,0 +1,80 @@
/// Read-only file follower for the Bash live-tail sub-card (T-325).
///
/// clide can't see a running Bash command's stdout (Claude Code owns the
/// 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
/// touches Claude's command — it just reads the file as it grows, like
/// `tail -f`, and hands new bytes to `onData`.
///
/// 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
/// truncation/rotation (size shrinking → re-read from the top).
library;
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
class FileTailFollower {
FileTailFollower(this.path, {required this.onData, this.tailBytes = 16384, this.interval = const Duration(milliseconds: 300)});
/// Absolute path of the file to follow.
final String path;
/// New bytes appended since the last read (or the initial tail window).
final void Function(Uint8List bytes) onData;
/// On first read, start this many bytes from the end (a `tail -c` window)
/// rather than dumping the whole file.
final int tailBytes;
final Duration interval;
int _pos = 0;
bool _primed = false;
bool _stopped = false;
Timer? _timer;
/// Begin following: emit the initial tail window, then poll for growth.
Future<void> start() async {
await pollOnce();
if (_stopped) return;
_timer = Timer.periodic(interval, (_) => pollOnce());
}
/// One read cycle. Public so tests can drive it deterministically without
/// waiting on the timer. Reads any bytes appended since the last position
/// (or, on the first call, the trailing [tailBytes]); resets to the top if
/// the file shrank (truncated/rotated).
Future<void> pollOnce() async {
if (_stopped) return;
final file = File(path);
if (!await file.exists()) return; // not created yet — keep waiting
final length = await file.length();
if (!_primed) {
_pos = length > tailBytes ? length - tailBytes : 0;
_primed = true;
} else if (length < _pos) {
_pos = 0; // truncated / rotated → re-read from the top
}
if (length <= _pos) return;
final raf = await file.open();
try {
await raf.setPosition(_pos);
final bytes = await raf.read(length - _pos);
_pos = length;
if (!_stopped && bytes.isNotEmpty) onData(Uint8List.fromList(bytes));
} finally {
await raf.close();
}
}
/// Stop following and release the timer. Idempotent.
void stop() {
_stopped = true;
_timer?.cancel();
_timer = null;
}
}
+6 -16
View File
@@ -17,15 +17,11 @@ import 'package:flutter/widgets.dart';
/// Open [path] full-size in the lightbox via the kernel dialog router.
void openImageLightbox(BuildContext context, String path) {
ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (ctx, _, __) => _placeholder(ctx, 48),
),
),
);
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (ctx, _, _) => _placeholder(ctx, 48)),
),
);
}
Widget _placeholder(BuildContext context, double size) {
@@ -64,13 +60,7 @@ class ImageThumbnail extends StatelessWidget {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Image(
image: ClideFileImage(path),
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (ctx, _, __) => _placeholder(ctx, size),
),
child: Image(image: ClideFileImage(path), width: size, height: size, fit: BoxFit.cover, errorBuilder: (ctx, _, _) => _placeholder(ctx, size)),
),
),
),
@@ -0,0 +1,141 @@
/// The Activity tab: session controls, usage, stats (stats-cache.json), and
/// the primary session's live runtime row. Split out of
/// claude_meta_sidebar.dart (T-395); session controls + the usage block are
/// the power-panel additions (T-415).
library;
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_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
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/workflow_run.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ActivityTabView extends StatelessWidget {
const ActivityTabView({
super.key,
required this.stats,
required this.primaryStatus,
required this.config,
this.usage,
this.workflows = const <String, WorkflowRun>{},
});
final ClaudeStats stats;
final SessionStatus? primaryStatus;
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
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final latest = stats.latest;
final u = usage;
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)
MetaSection('TODAY', [
MetaRow('messages', '${latest.messageCount}'),
MetaRow('sessions', '${latest.sessionCount}'),
MetaRow('tool calls', '${latest.toolCallCount}'),
]),
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
..._runtimeSection(tokens),
];
return ListView(
padding: const EdgeInsets.all(12),
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) {
final st = primaryStatus;
final skills = config?.skills.length;
final rows = <MetaRow>[
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?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
if (skills != null) MetaRow('skills', '$skills'),
];
return rows.isEmpty ? const [] : [MetaSection('RUNTIME · primary', rows)];
}
}
@@ -0,0 +1,360 @@
/// The Config tab (T-183): the settings table over [ClaudeConfig] plus the
/// skills/agents/commands/hooks/permissions/MCP accordion. Split out of
/// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in
/// the parent (it survives tab switches) and arrives as a prop + toggle
/// 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;
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/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/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ConfigTabView extends StatelessWidget {
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection, this.status, this.models});
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.
final Set<ConfigSection> expanded;
final void Function(ConfigSection section) onToggleSection;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final cfg = config;
if (cfg == null) {
return metaPlaceholder('Claude environment not loaded.');
}
final settings = cfg.settings;
final model = status?.model ?? cfg.probe?.model ?? settings['model']?.toString() ?? 'default';
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
final mode = status?.permissionMode ?? cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
final children = <Widget>[
// Pinned SETTINGS control panel — not collapsible.
Padding(
padding: const EdgeInsets.only(bottom: 8),
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, 'output style', outputStyle),
_configRow(tokens, 'source', '~/.claude + .claude'),
// ---- Accordion sections ----
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section),
// Footer hint.
Padding(
padding: const EdgeInsets.only(top: 12),
child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
),
];
return ListView(padding: const EdgeInsets.all(12), children: children);
}
/// One read-only key→value row in the pinned SETTINGS table.
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: kMetaLabelColumnWidth,
child: ClideText(label, muted: true, fontSize: kMetaFont),
),
Expanded(
child: ClideText(value, fontSize: kMetaFont, color: valueColor ?? tokens.globalForeground),
),
],
),
);
}
String _sectionLabel(ConfigSection section) => switch (section) {
ConfigSection.skills => 'SKILLS',
ConfigSection.agents => 'AGENTS',
ConfigSection.commands => 'COMMANDS',
ConfigSection.hooks => 'HOOKS',
ConfigSection.permissions => 'PERMISSIONS',
ConfigSection.mcpServers => 'MCP SERVERS',
};
int _sectionCount(ClaudeConfig config, ConfigSection section) => switch (section) {
ConfigSection.skills => config.skills.length,
ConfigSection.agents => config.agents.length,
ConfigSection.commands => config.commands.length,
ConfigSection.hooks => config.hooks.length,
ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
ConfigSection.mcpServers => config.mcpServers.length,
};
Widget _accordion(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
final isExpanded = expanded.contains(section);
final children = isExpanded ? _sectionChildren(context, tokens, config, section) : const <Widget>[];
return ClideAccordion(
label: _sectionLabel(section),
count: _sectionCount(config, section),
expanded: isExpanded,
onToggle: () => onToggleSection(section),
children: children,
);
}
List<Widget> _sectionChildren(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
switch (section) {
case ConfigSection.skills:
return [for (final skill in config.skills) _fileRow(context, tokens, skill.name, skill.path)];
case ConfigSection.agents:
return [for (final agent in config.agents) _fileRow(context, tokens, agent.name, agent.path)];
case ConfigSection.commands:
return [for (final cmd in config.commands) _fileRow(context, tokens, cmd.name, cmd.path)];
case ConfigSection.hooks:
return [
for (final hook in config.hooks)
Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(hook.event, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
for (final cmd in hook.commands)
Padding(
padding: const EdgeInsets.only(left: 8, top: 1),
child: ClideText(cmd, fontSize: clideFontSmall, muted: true),
),
],
),
),
];
case ConfigSection.permissions:
return _permissionRows(tokens, config.permissions);
case ConfigSection.mcpServers:
return [
for (final srv in config.mcpServers)
Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(srv.name, fontSize: clideFontSmall, color: tokens.globalForeground),
),
];
}
}
/// A tappable row for file-backed items (skills, agents, commands).
/// All config items are .md files — opens in the markdown reader panel
/// via the kernel MessageBus (D-6, T-183).
Widget _fileRow(BuildContext context, SurfaceTokens tokens, String name, String? path) {
final row = Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
);
if (path == null) return row;
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
return Semantics(
button: true,
label: name,
excludeSemantics: true,
onTap: openMarkdown,
child: ClideTappable(
tooltip: path,
onTap: openMarkdown,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
),
),
);
}
/// Renders grouped allow/ask/deny permission rows, colour-coded by kind.
List<Widget> _permissionRows(SurfaceTokens tokens, ClaudePermissions perms) {
// allow → statusSuccess, ask → statusWarning, deny → statusError
Color kindColor(ConfigPermKind k) => switch (k) {
ConfigPermKind.allow => tokens.statusSuccess,
ConfigPermKind.ask => tokens.statusWarning,
ConfigPermKind.deny => tokens.statusError,
};
String kindLabel(ConfigPermKind k) => switch (k) {
ConfigPermKind.allow => 'allow',
ConfigPermKind.ask => 'ask',
ConfigPermKind.deny => 'deny',
};
final groups = [(ConfigPermKind.allow, perms.allow), (ConfigPermKind.ask, perms.ask), (ConfigPermKind.deny, perms.deny)];
final rows = <Widget>[];
for (final (kind, rules) in groups) {
if (rules.isEmpty) continue;
final color = kindColor(kind);
rows.add(
Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 36,
child: ClideText(kindLabel(kind), fontSize: clideFontSmall, color: color),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final rule in rules)
Padding(
padding: const EdgeInsets.only(bottom: 1),
child: ClideText(rule, fontSize: clideFontSmall, color: tokens.globalForeground),
),
],
),
),
],
),
),
);
}
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),
],
),
),
),
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,37 @@
/// A single icon-button used by the roster row controls + task rows.
/// Split out of claude_meta_sidebar.dart (T-395). Promote to
/// lib/widgets/ only when a second consumer appears.
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class MetaIconButton extends StatelessWidget {
const MetaIconButton({super.key, required this.painter, required this.tooltip, required this.color, required this.onTap});
final ClideIconPainter painter;
final String tooltip;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// Icon-only button: expose the tooltip text as the Semantics button label
// so AT (and widget tests) can find and activate it by name.
return Semantics(
button: true,
label: tooltip,
excludeSemantics: true,
onTap: onTap,
child: ClideTappable(
tooltip: tooltip,
onTap: onTap,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: ClideIcon(painter, size: 12, color: hovered ? ClideTheme.of(ctx).surface.globalForeground : color),
),
),
);
}
}
@@ -0,0 +1,37 @@
/// Inline text input for injecting a message into a session (T-171).
/// Submits on Enter; Cancel is handled by the parent's icon button.
/// Split out of claude_meta_sidebar.dart (T-395).
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class InjectTextField extends StatelessWidget {
const InjectTextField({super.key, required this.controller, required this.tokens, required this.onSubmit});
final TextEditingController controller;
final SurfaceTokens tokens;
final void Function(String text) onSubmit;
@override
Widget build(BuildContext context) {
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(3),
),
child: EditableText(
controller: controller,
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: onSubmit,
),
);
}
}
@@ -0,0 +1,83 @@
/// Shared models + table geometry for the Claude meta sidebar's tabs.
/// Split out of claude_meta_sidebar.dart (T-395).
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// 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.
const double kMetaLabelColumnWidth = 110;
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.
enum SidebarTab { activity, team, config }
// T-183: accordion sections for the Config tab.
enum ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
/// Permission kind for colour-coding in the Config tab (T-183).
enum ConfigPermKind { allow, ask, deny }
class MetaSection {
const MetaSection(this.header, this.rows);
final String header;
final List<MetaRow> rows;
}
class MetaRow {
const MetaRow(this.label, this.value, {this.valueColor});
final String label;
final String value;
final Color? valueColor;
}
/// The muted empty-state body shared by every tab.
Widget metaPlaceholder(String text) => Padding(
padding: const EdgeInsets.all(12),
child: ClideText(text, muted: true, fontSize: kMetaFont),
);
/// Key→value sections on the shared table geometry (Activity + Config).
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>[];
for (var i = 0; i < sections.length; i++) {
final s = sections[i];
children.add(
Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
),
);
for (final r in s.rows) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: kMetaLabelColumnWidth,
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
),
Expanded(
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
),
],
),
),
);
}
}
return children;
}
@@ -0,0 +1,84 @@
/// Clickable permission-mode badge shown in each roster row (T-181).
/// Split out of claude_meta_sidebar.dart (T-395).
library;
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart' show HardwareKeyboard;
import 'package:flutter/widgets.dart';
/// Maps a permission-mode string to a single-letter badge label.
String permissionModeBadgeLabel(String mode) => switch (mode) {
'acceptEdits' => 'A',
'plan' => 'P',
'bypassPermissions' => 'B',
_ => 'D', // default
};
/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default.
/// - Shift-click → shows the bypass confirm inline in the parent row.
///
/// The badge reflects the LIVE mode from `SessionStatus.permissionMode`
/// (T-157). It is a custom painted label (no Material), consistent with the
/// rendering stack rules (D-7, CLAUDE.md guardrails).
class PermissionModeBadge extends StatelessWidget {
const PermissionModeBadge({super.key, required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
final String mode;
final SurfaceTokens tokens;
/// Called on a plain click — the parent cycles to the next safe mode.
final VoidCallback onCycle;
/// Called on a shift-click — the parent shows the bypass confirm.
final VoidCallback onBypass;
@override
Widget build(BuildContext context) {
final label = permissionModeBadgeLabel(mode);
final isBypass = mode == 'bypassPermissions';
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
final tooltip =
'Permission mode: ${permissionModeLabel(mode)}. '
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
return Padding(
padding: const EdgeInsets.only(top: 3),
child: Semantics(
button: true,
label: 'Permission mode: $label',
excludeSemantics: true,
onTap: () {
if (HardwareKeyboard.instance.isShiftPressed) {
onBypass();
} else {
onCycle();
}
},
child: ClideTappable(
tooltip: tooltip,
onTap: () {
if (HardwareKeyboard.instance.isShiftPressed) {
onBypass();
} else {
onCycle();
}
},
builder: (ctx, hovered, _) => Container(
width: 16,
height: 14,
alignment: Alignment.center,
decoration: BoxDecoration(
color: badgeColor.withAlpha(hovered ? 51 : 26),
borderRadius: BorderRadius.circular(2),
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
),
child: ClideText(label, fontSize: 9, color: badgeColor),
),
),
),
);
}
}
@@ -0,0 +1,273 @@
/// A single agent roster row: color dot + name + status sub-text +
/// controls (T-171). Split out of claude_meta_sidebar.dart (T-395).
///
/// Controls (trailing region):
/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click
/// reaches bypassPermissions behind a confirm
/// - eye / eye-slash — show / hide the session pane
/// - speaker / speaker-slash — mute / unmute broker delivery
/// - inject (chat icon) — expand the inline message input
/// - fork (git-branch icon) — open a new pane branching from this session (T-172)
/// - close (×) — kill the session
library;
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
import 'package:clide/builtin/claude/src/meta_sidebar/inject_field.dart';
import 'package:clide/builtin/claude/src/meta_sidebar/permission_badge.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class AgentRosterRow extends StatefulWidget {
const AgentRosterRow({
super.key,
required this.member,
required this.status,
required this.orchestrator,
required this.injectingAgentId,
required this.injectController,
required this.onToggleInject,
required this.onInjectSubmit,
required this.onClose,
required this.onSetPermissionMode,
required this.onFork,
});
final TeamMemberJoined member;
final SessionStatus? status;
final ClaudeSessionOrchestrator? orchestrator;
/// The member name currently in inject mode (null = none).
final String? injectingAgentId;
/// Shared text controller for the inject field (cleared on submit/cancel).
final TextEditingController injectController;
final void Function(String memberName) onToggleInject;
final void Function(String memberName, String text) onInjectSubmit;
final void Function(String memberName) onClose;
/// Called when the badge cycles to a new `mode` string for this member.
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
final void Function(String memberName, String mode) onSetPermissionMode;
/// Called when the fork button is tapped (T-172). The session id of the
/// member's managed session is passed so the host can open a fork pane.
final void Function(String memberName) onFork;
@override
State<AgentRosterRow> createState() => _AgentRosterRowState();
}
class _AgentRosterRowState extends State<AgentRosterRow> {
/// Whether the bypass-confirm inline prompt is showing.
bool _confirmingBypass = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final managed = widget.orchestrator?.byMemberName(widget.member.name);
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
final st = widget.status;
final model = st?.model ?? widget.member.model;
final sub = [
widget.member.agentType,
if (model != null) shortModelLabel(model),
if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!),
if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx',
].join(' · ');
final isVisible = managed?.visible ?? true;
final isMuted = managed?.muted ?? false;
final isInjecting = widget.injectingAgentId == widget.member.name;
final currentMode = st?.permissionMode ?? 'default';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Color dot
Padding(
padding: const EdgeInsets.only(top: 3),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
),
const SizedBox(width: 8),
// Name + status
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
// T-181: permission-mode badge (inline below the status sub-text).
if (managed != null)
PermissionModeBadge(
mode: currentMode,
tokens: tokens,
onCycle: () {
final next = _nextSafeMode(currentMode);
widget.onSetPermissionMode(widget.member.name, next);
},
onBypass: () => setState(() => _confirmingBypass = true),
),
],
),
),
const SizedBox(width: 4),
// Trailing controls (T-171).
// T-172 seam: append a fork icon button to this row.
if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting),
],
),
// Bypass confirm: replaces inject field area when active.
if (_confirmingBypass) _buildBypassConfirm(tokens),
// Inline inject-message field — visible only when toggled.
if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens),
],
),
);
}
/// Safe-mode cycle: default → acceptEdits → plan → default (T-181).
static String _nextSafeMode(String current) {
const cycle = ['default', 'acceptEdits', 'plan'];
final idx = cycle.indexOf(current);
return cycle[(idx + 1) % cycle.length];
}
Widget _buildBypassConfirm(SurfaceTokens tokens) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Row(
children: [
Expanded(
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
),
const SizedBox(width: 4),
// Confirm
Semantics(
button: true,
label: 'Confirm bypass',
excludeSemantics: true,
onTap: () {
setState(() => _confirmingBypass = false);
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
},
child: ClideTappable(
tooltip: 'Confirm',
onTap: () {
setState(() => _confirmingBypass = false);
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
},
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
),
),
),
const SizedBox(width: 4),
// Cancel
Semantics(
button: true,
label: 'Cancel bypass',
excludeSemantics: true,
onTap: () => setState(() => _confirmingBypass = false),
child: ClideTappable(
tooltip: 'Cancel',
onTap: () => setState(() => _confirmingBypass = false),
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
),
],
),
);
}
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Show / hide
MetaIconButton(
painter: isVisible ? PhosphorIcons.byName('eye') : PhosphorIcons.byName('eye-slash'),
tooltip: isVisible ? 'Hide pane' : 'Show pane',
color: tokens.globalTextMuted,
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
),
// Mute / unmute
MetaIconButton(
painter: isMuted ? PhosphorIcons.byName('eye-slash') : PhosphorIcons.byName('eye'),
// NOTE: We use eye/eyeSlash as stand-ins until a dedicated speaker
// icon is added to PhosphorIcons (no speaker codepoint yet).
// The semantic tooltip still says mute/unmute so AT users are clear.
tooltip: isMuted ? 'Unmute messages' : 'Mute messages',
color: isMuted ? tokens.globalFocus : tokens.globalTextMuted,
onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id),
),
// Inject message
MetaIconButton(
painter: PhosphorIcons.byName('chat-circle'),
tooltip: 'Inject message',
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
onTap: () => widget.onToggleInject(widget.member.name),
),
// Fork session (T-172): branch into a new pane without touching the original.
MetaIconButton(
painter: PhosphorIcons.byName('git-branch'),
tooltip: 'Fork session',
color: tokens.globalTextMuted,
onTap: () => widget.onFork(widget.member.name),
),
// Close session
MetaIconButton(
painter: PhosphorIcons.byName('x'),
tooltip: 'Close session',
color: tokens.globalTextMuted,
onTap: () => widget.onClose(widget.member.name),
),
],
);
}
Widget _buildInjectField(BuildContext context, SurfaceTokens tokens) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Row(
children: [
Expanded(
child: InjectTextField(
controller: widget.injectController,
tokens: tokens,
onSubmit: (text) {
if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim());
},
),
),
const SizedBox(width: 4),
MetaIconButton(
painter: PhosphorIcons.byName('x'),
tooltip: 'Cancel',
color: tokens.globalTextMuted,
onTap: () => widget.onToggleInject(widget.member.name),
),
],
),
);
}
}
@@ -0,0 +1,58 @@
/// The Activity / Team / Config sub-tab strip — same interaction as the pql
/// panel's view tabs, with an underline under the active tab. Split out of
/// claude_meta_sidebar.dart (T-395).
library;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class SidebarTabStrip extends StatelessWidget {
const SidebarTabStrip({super.key, required this.current, required this.memberCount, required this.onPick});
final SidebarTab current;
final int memberCount;
final ValueChanged<SidebarTab> onPick;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
),
child: Row(
children: [
for (final t in SidebarTab.values)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Semantics(
button: true,
selected: t == current,
label: _label(t),
excludeSemantics: true,
onTap: () => onPick(t),
child: ClideTappable(
onTap: () => onPick(t),
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.only(bottom: 3),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
),
child: ClideText(_label(t), fontSize: clideFontSmall, color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
),
),
],
),
);
}
String _label(SidebarTab t) => switch (t) {
SidebarTab.activity => 'Activity',
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
SidebarTab.config => 'Config',
};
}
@@ -0,0 +1,76 @@
/// One row in the Team tab's TASKS section: status marker + title +
/// owner + reassign control (T-171). Split out of
/// claude_meta_sidebar.dart (T-395).
library;
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class TaskRow extends StatelessWidget {
const TaskRow({super.key, required this.task, required this.members, required this.broker});
final TeamTask task;
final List<TeamMemberJoined> members;
final TeamBroker? broker;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final marker = switch (task.status) {
'done' => '',
'claimed' => '',
_ => '',
};
final markerColor = switch (task.status) {
'done' => tokens.globalTextMuted,
'claimed' => tokens.globalFocus,
_ => tokens.globalForeground,
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
ClideText(marker, fontSize: clideFontSmall, color: markerColor),
const SizedBox(width: 6),
Expanded(
child: ClideText(
task.title,
fontSize: clideFontSmall,
color: task.status == 'done' ? tokens.globalTextMuted : tokens.globalForeground,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (task.owner != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: ClideText(task.owner!, fontSize: clideFontSmall, color: tokens.globalFocus),
),
// Reassign: cycle to the next roster member.
if (broker != null && broker!.members.length > 1)
MetaIconButton(
painter: PhosphorIcons.byName('arrow-clockwise'),
tooltip: 'Reassign task',
color: tokens.globalTextMuted,
onTap: () => _reassign(context),
),
],
),
);
}
void _reassign(BuildContext context) {
final b = broker;
if (b == null || members.isEmpty) return;
final brokerMembers = b.members;
if (brokerMembers.isEmpty) return;
// Cycle to the next member after the current owner.
final currentIndex = brokerMembers.indexWhere((m) => m.name == task.owner);
final nextIndex = (currentIndex + 1) % brokerMembers.length;
b.reassignTask(task.id, brokerMembers[nextIndex].id);
}
}
@@ -0,0 +1,98 @@
/// The Team tab: the roster cockpit (T-171) — per-member rows with
/// controls, the TASKS section, and the MESSAGES chat feed (T-180).
/// Stateless and props-driven; the parent owns the member list, inject
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
/// (T-395).
library;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart';
import 'package:clide/builtin/claude/src/meta_sidebar/task_row.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamTask;
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class TeamTabView extends StatelessWidget {
const TeamTabView({
super.key,
required this.members,
required this.memberStatus,
required this.orchestrator,
required this.tasks,
required this.injectingAgentId,
required this.injectController,
required this.onToggleInject,
required this.onInjectSubmit,
required this.onClose,
required this.onSetPermissionMode,
required this.onFork,
required this.onOpenChatPane,
});
final List<TeamMemberJoined> members;
final Map<String, SessionStatus> memberStatus;
final ClaudeSessionOrchestrator? orchestrator;
final List<TeamTask> tasks;
final String? injectingAgentId;
final TextEditingController injectController;
final void Function(String memberName) onToggleInject;
final void Function(String memberName, String text) onInjectSubmit;
final void Function(String memberName) onClose;
final void Function(String memberName, String mode) onSetPermissionMode;
final void Function(String memberName) onFork;
final VoidCallback onOpenChatPane;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
if (members.isEmpty) {
return metaPlaceholder('No team active.');
}
final children = <Widget>[
for (final m in members)
AgentRosterRow(
key: ValueKey(m.agentId),
member: m,
status: memberStatus[m.agentId],
orchestrator: orchestrator,
injectingAgentId: injectingAgentId,
injectController: injectController,
onToggleInject: onToggleInject,
onInjectSubmit: onInjectSubmit,
onClose: onClose,
onSetPermissionMode: onSetPermissionMode,
onFork: onFork,
),
];
if (tasks.isNotEmpty) {
children.add(const SizedBox(height: 12));
children.add(_taskSection(tokens));
}
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
final chatModel = orchestrator?.chatModel;
final broker = orchestrator?.broker;
if (chatModel != null && broker != null) {
children.add(const SizedBox(height: 12));
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: onOpenChatPane));
}
return ListView(padding: const EdgeInsets.all(12), children: children);
}
Widget _taskSection(SurfaceTokens tokens) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('TASKS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
const SizedBox(height: 4),
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
],
);
}
}
@@ -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';
@@ -67,24 +67,24 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
}
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
for (final m in kSafePermissionCycle)
ClideMenuItem(
leading: permissionModeIcon(m),
color: permissionModeColor(m, tokens),
label: permissionModeLabel(m),
active: m == widget.mode,
onSelect: () => widget.onSelect(m),
),
const ClideMenuSeparator(),
ClideMenuItem(
leading: permissionModeIcon('bypassPermissions'),
color: permissionModeColor('bypassPermissions', tokens),
label: permissionModeLabel('bypassPermissions'),
enabled: false,
active: widget.mode == 'bypassPermissions',
onSelect: () {},
),
];
for (final m in kSafePermissionCycle)
ClideMenuItem(
leading: permissionModeIcon(m),
color: permissionModeColor(m, tokens),
label: permissionModeLabel(m),
active: m == widget.mode,
onSelect: () => widget.onSelect(m),
),
const ClideMenuSeparator(),
ClideMenuItem(
leading: permissionModeIcon('bypassPermissions'),
color: permissionModeColor('bypassPermissions', tokens),
label: permissionModeLabel('bypassPermissions'),
enabled: false,
active: widget.mode == 'bypassPermissions',
onSelect: () {},
),
];
@override
Widget build(BuildContext context) {
@@ -94,11 +94,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
side: ClideAnchorSide.above,
align: ClideAnchorAlign.end,
offset: const Offset(0, -6),
overlayBuilder: (ctx, ctrl) => ClideMenu(
onClose: ctrl.close,
minWidth: 180,
entries: _entries(ClideTheme.of(ctx).surface),
),
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
anchor: ListenableBuilder(
listenable: _overlay,
builder: (ctx, _) {
@@ -116,9 +112,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
alignment: Alignment.center,
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
border: Border.all(
color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder),
),
border: Border.all(color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder)),
borderRadius: BorderRadius.circular(4),
),
child: ClideIcon(permissionModeIcon(widget.mode), size: 16, color: permissionModeColor(widget.mode, tokens)),
+53 -59
View File
@@ -5,7 +5,7 @@
/// Rendered in the composer zone (not inline in the conversation) so
/// 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
/// [onResolve]; the pane then removes the card.
/// `onResolve`; the pane then removes the card.
///
/// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no
/// hover-revealed chrome that would fight the buttons.
@@ -27,7 +27,8 @@ const _kOther = '\u0000other';
/// Preformatted note for the "Deny & simplify" permission option (T-311): deny
/// THIS action and ask Claude to reformulate it more simply, explicitly without
/// touching the permission surface (memories / settings).
const _kDenySimplifyNote = 'Denied — this action is too complex for the permission system to approve cleanly. '
const _kDenySimplifyNote =
'Denied — this action is too complex for the permission system to approve cleanly. '
'Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) '
'to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not '
'change permission settings — just reformulate and try again. Do not narrate or explain the change; '
@@ -295,11 +296,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
[
if (_q.isNotEmpty) _questionBody(tokens, 0),
const SizedBox(height: 6),
Row(children: [
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
const Spacer(),
_chatInstead(tokens),
]),
Row(
children: [
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
const Spacer(),
_chatInstead(tokens),
],
),
],
);
}
@@ -312,11 +315,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
const SizedBox(height: 8),
for (var i = 0; i < _q.length; i++) _reviewRow(tokens, i),
const SizedBox(height: 6),
Row(children: [
ClideButton(label: ' Back', onPressed: () => setState(() => _step = _q.length - 1)),
const SizedBox(width: 8),
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
]),
Row(
children: [
ClideButton(label: ' Back', onPressed: () => setState(() => _step = _q.length - 1)),
const SizedBox(width: 8),
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
],
),
],
);
}
@@ -330,19 +335,14 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
const SizedBox(height: 10),
_questionBody(tokens, _step),
const SizedBox(height: 6),
Row(children: [
if (_step > 0) ...[
ClideButton(label: ' Back', onPressed: () => setState(() => _step--)),
const SizedBox(width: 8),
Row(
children: [
if (_step > 0) ...[ClideButton(label: ' Back', onPressed: () => setState(() => _step--)), const SizedBox(width: 8)],
ClideButton(label: last ? 'Review ' : 'Next ', variant: ClideButtonVariant.primary, onPressed: answered ? () => setState(() => _step++) : null),
const Spacer(),
_chatInstead(tokens),
],
ClideButton(
label: last ? 'Review ' : 'Next ',
variant: ClideButtonVariant.primary,
onPressed: answered ? () => setState(() => _step++) : null,
),
const Spacer(),
_chatInstead(tokens),
]),
),
],
);
}
@@ -354,12 +354,17 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
final done = _answer(i).isNotEmpty;
final text = '${i + 1} · $head${done ? '' : ''}';
if (i == _step) {
chips.add(Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: tokens.globalFocus.withValues(alpha: 0.18), border: Border.all(color: tokens.statusInfo), borderRadius: BorderRadius.circular(4)),
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
));
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: tokens.globalFocus.withValues(alpha: 0.18),
border: Border.all(color: tokens.statusInfo),
borderRadius: BorderRadius.circular(4),
),
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
),
);
} else {
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
}
@@ -376,8 +381,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 110, child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted)),
Expanded(child: ClideText('${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground)),
SizedBox(
width: 110,
child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted),
),
Expanded(
child: ClideText('${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground),
),
],
),
);
@@ -403,10 +413,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
_optButton(qi, _kOther, 'Other…', q.multiSelect, '', q.options.length + 1),
],
),
if (hasOther) ...[
const SizedBox(height: 8),
_NoteField(controller: _other[qi], placeholder: 'type your answer…'),
],
if (hasOther) ...[const SizedBox(height: 8), _NoteField(controller: _other[qi], placeholder: 'type your answer…')],
const SizedBox(height: 8),
_NoteField(controller: _qnote[qi], placeholder: '+ note (optional)'),
],
@@ -499,10 +506,7 @@ Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic>
/// / timeout annotations.
Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final cmd = (input['command'] as String? ?? '').trimRight();
final notes = <String>[
if (input['run_in_background'] == true) 'background',
if (input['timeout'] is num) 'timeout ${input['timeout']}ms',
];
final notes = <String>[if (input['run_in_background'] == true) 'background', if (input['timeout'] is num) 'timeout ${input['timeout']}ms'];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@@ -564,19 +568,14 @@ Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynam
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
}
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
return ClideText(
label.isNotEmpty ? label : toolName,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalForeground,
);
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground);
}
/// A muted file path line, shared across tool bodies.
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
);
padding: const EdgeInsets.only(bottom: 6),
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
);
// -- shared note / free-text field -------------------------------------------
@@ -613,7 +612,7 @@ class _NoteFieldState extends State<_NoteField> {
children: [
ValueListenableBuilder<TextEditingValue>(
valueListenable: widget.controller,
builder: (_, v, __) => v.text.isEmpty ? ClideText(widget.placeholder, muted: true, fontSize: clideFontSmall) : const SizedBox.shrink(),
builder: (_, v, _) => v.text.isEmpty ? ClideText(widget.placeholder, muted: true, fontSize: clideFontSmall) : const SizedBox.shrink(),
),
EditableText(
controller: widget.controller,
@@ -652,14 +651,9 @@ List<_Question> _parseQuestions(Map<String, dynamic> input) {
return [
for (final q in raw)
if (q is Map)
_Question(
q['question'] as String? ?? '',
q['header'] as String? ?? '',
q['multiSelect'] as bool? ?? false,
[
for (final o in (q['options'] as List? ?? const []))
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
],
),
_Question(q['question'] as String? ?? '', q['header'] as String? ?? '', q['multiSelect'] as bool? ?? false, [
for (final o in (q['options'] as List? ?? const []))
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
]),
];
}
+11 -19
View File
@@ -13,13 +13,7 @@ import 'dart:io';
/// One session in the workspace, summarised for the picker.
class SessionSummary {
const SessionSummary({
required this.id,
required this.modified,
this.firstUser,
this.lastUser,
this.sizeBytes = 0,
});
const SessionSummary({required this.id, required this.modified, this.firstUser, this.lastUser, this.sizeBytes = 0});
/// The session id (the `<uuid>` of `<uuid>.jsonl`).
final String id;
@@ -94,11 +88,7 @@ String? _userTextOf(String line) {
/// Sessions in [dir] (the munged project dir), most-recently-modified first,
/// capped at [max]. Each is summarised by bookend user prompts read from a
/// bounded [window] at each end of its transcript.
Future<List<SessionSummary>> listSessions(
Directory dir, {
int max = 20,
int window = 128 * 1024,
}) async {
Future<List<SessionSummary>> listSessions(Directory dir, {int max = 20, int window = 128 * 1024}) async {
if (!await dir.exists()) return const [];
final files = <File>[];
await for (final e in dir.list(followLinks: false)) {
@@ -109,13 +99,15 @@ Future<List<SessionSummary>> listSessions(
final stat = await f.stat();
final bookends = await _bookends(f, window);
final id = _sessionId(f.path);
summaries.add(SessionSummary(
id: id,
modified: stat.modified,
firstUser: bookends.first,
lastUser: bookends.last,
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
));
summaries.add(
SessionSummary(
id: id,
modified: stat.modified,
firstUser: bookends.first,
lastUser: bookends.last,
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
),
);
}
summaries.sort((a, b) => b.modified.compareTo(a.modified));
return summaries.length > max ? summaries.sublist(0, max) : summaries;
+18 -6
View File
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent
/// `claude --session-id <sessionId>` re-creates it empty — the in-place
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl`
/// and the sidecar `<id>/` directory claude keeps beside it. Best-effort:
/// missing entries are not an error. The caller MUST have killed the session's
/// process first, so claude is not mid-write.
/// `/clear` path for the primary pane (T-268). Removes the `<id>.jsonl`, plus
/// a per-session `<id>/` sidecar dir if one exists (best-effort; missing
/// entries are not an error). Note the shared per-project `memory/` dir that
/// claude 2.1.x keeps beside transcripts is deliberately left alone — it is
/// not per-session.
///
/// The caller MUST have AWAITED the session's process death first (T-437): a
/// still-live claude re-flushes its transcript and keeps the id registered, so
/// the respawn's `--session-id` is rejected as "already in use" (exit 1).
/// [ClaudeSessionOrchestrator.close] now awaits that death before this runs.
Future<void> clearSessionTranscript(String projectDir, String sessionId) async {
final file = File('$projectDir/$sessionId.jsonl');
if (await file.exists()) await file.delete();
@@ -124,12 +130,18 @@ String freshSessionId() {
/// same id). Expands an FNV-1a stream into 16 bytes.
String _deterministicUuid(String seed) {
final bytes = <int>[];
var h = 0xcbf29ce484222325;
// FNV-1a 64-bit offset basis, split into two 32-bit halves so the dart2js
// web fallback accepts it (a full 64-bit literal "can't be represented
// exactly in JavaScript" — T-438). Correct on the VM/wasm; web never derives
// a session id (no claude process there).
var h = (0xcbf29ce4 << 32) | 0x84222325;
const prime = 0x100000001b3;
for (var i = 0; i < 16; i++) {
for (final c in utf8.encode('$seed:$i')) {
h ^= c;
h = (h * prime) & 0xFFFFFFFFFFFFFFFF;
// 64-bit modular wrap is implicit on the VM/wasm; the explicit
// `& 0xFFFFFFFFFFFFFFFF` was a no-op and a dart2js-incompatible literal.
h = h * prime;
}
bytes.add(h & 0xff);
}
@@ -2,8 +2,8 @@
///
/// 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
/// lifecycle from any pane: [spawn] starts and registers it, [show]/[hide]
/// toggle visibility WITHOUT tearing the process down, and [close] kills it.
/// lifecycle from any pane: `spawn` starts and registers it, `show`/`hide`
/// toggle visibility WITHOUT tearing the process down, and `close` kills it.
/// This is the one primitive behind teammate / secondary tab / forked branch
/// (Phase 2): they are all just managed sessions shown as panes.
///
@@ -32,11 +32,7 @@ const _resumeTailBytes = 256 * 1024;
/// Creates the subprocess for a session — production uses
/// [ClaudeStreamJsonProcess.start]; tests inject a fake.
typedef ProcessFactory = Future<StreamJsonProcess> Function({
required List<String> sessionArgs,
required String cwd,
Map<String, String>? env,
});
typedef ProcessFactory = Future<StreamJsonProcess> Function({required List<String> sessionArgs, required String cwd, Map<String, String>? env});
/// What to spawn. [id] is the orchestrator's stable key (e.g. `primary`,
/// `teammate:tyre`); [sessionId] is claude's `--session-id`.
@@ -53,6 +49,7 @@ class SpawnSpec {
this.team = false,
this.memberName,
this.forkSourceSessionId,
this.effort,
});
final String id;
@@ -85,6 +82,12 @@ class SpawnSpec {
/// Takes precedence over [resume]/[sessionId] for arg selection.
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.
bool get isFork => forkSourceSessionId != null;
}
@@ -150,10 +153,7 @@ ClaudeSessionOrchestrator? activeSessionOrchestrator;
class ClaudeSessionOrchestrator extends ChangeNotifier {
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
_chatModel = TeamChatModel(
broker: broker,
sessionResolver: (name) => byMemberName(name)?.session,
);
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
}
final ProcessFactory _factory;
@@ -181,9 +181,9 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
/// Just the sessions a pane should currently render.
List<ManagedSession> get visibleSessions => [
for (final m in _sessions.values)
if (m.visible) m,
];
for (final m in _sessions.values)
if (m.visible) m,
];
ManagedSession? byId(String id) => _sessions[id];
@@ -195,7 +195,28 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
/// (T-269): the existing session belongs to the old repo, so it is torn down
/// and a fresh one spawned for the new repo — a pane must never inherit
/// another workspace's conversation.
Future<ManagedSession> spawn(SpawnSpec spec) async {
Future<ManagedSession> spawn(SpawnSpec spec) {
// Serialize concurrent spawns per id (T-374): the body check-then-acts
// on _sessions across two awaits, so two racing callers would both
// pass the check and the loser's live claude process would be orphaned.
// The first caller installs the future synchronously; the rest await
// it. (A racing different-cwd spawn for the same id also coalesces —
// the workspace-switch flow is sequential, so that pair never races.)
final inFlight = _spawning[spec.id];
if (inFlight != null) return inFlight;
final f = _spawn(spec);
_spawning[spec.id] = f;
unawaited(
f.then<void>((_) {}, onError: (Object _) {}).whenComplete(() {
if (identical(_spawning[spec.id], f)) _spawning.remove(spec.id);
}),
);
return f;
}
final Map<String, Future<ManagedSession>> _spawning = {};
Future<ManagedSession> _spawn(SpawnSpec spec) async {
final existing = _sessions[spec.id];
if (existing != null) {
if (existing.cwd == spec.cwd) return existing;
@@ -228,14 +249,11 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
'--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 seed = spec.resume && spec.transcriptPath != null ? await _readTranscriptTail(spec.transcriptPath!) : null;
final conversation = ConversationController(stream: session.items, seed: seed, onDispose: session.dispose);
@@ -295,12 +313,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
}
/// Kill and forget a session (the real teardown). The conversation's
/// onDispose kills the process + closes its streams.
/// onDispose kills the process + closes its streams; we then AWAIT the
/// session's teardown so the `claude` process is genuinely dead before we
/// return (T-437). Callers respawn the primary on the same deterministic
/// `--session-id` right after /clear — if the old process were still alive,
/// claude 2.1.177 would reject the id as "already in use" and the respawn
/// would exit 1.
Future<void> close(String id) async {
final m = _sessions.remove(id);
if (m == null) return;
broker.removeMember(id);
m.conversation.dispose();
m.conversation.dispose(); // cancels the item subscription; kicks off session teardown
await m.session.dispose(); // idempotent — awaits the real process exit
notifyListeners();
}
@@ -362,7 +386,8 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
}
/// The team-awareness preamble injected via `--append-system-prompt` (T-170).
static String _teamSystemPrompt(String name, String role) => 'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
static String _teamSystemPrompt(String name, String role) =>
'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
'Coordinate with teammates using the clide-team MCP tools: '
'send_message(to, text) to message one teammate by name, broadcast(text) to message all, '
'list_teammates() to see the roster, inbox() to read messages sent to you, and '
+3 -18
View File
@@ -12,12 +12,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class SessionPickerDialog extends StatefulWidget {
const SessionPickerDialog({
super.key,
required this.sessions,
required this.onPick,
required this.onCancel,
});
const SessionPickerDialog({super.key, required this.sessions, required this.onPick, required this.onCancel});
final List<SessionSummary> sessions;
final void Function(String id) onPick;
@@ -92,11 +87,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.sessions.length,
itemBuilder: (ctx, i) => _row(theme, i),
),
child: ListView.builder(shrinkWrap: true, itemCount: widget.sessions.length, itemBuilder: (ctx, i) => _row(theme, i)),
),
],
),
@@ -117,13 +108,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
s.label,
fontSize: clideFontSmall,
color: theme.globalForeground,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
ClideText(s.label, fontSize: clideFontSmall, color: theme.globalForeground, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
ClideText(relativeTime(s.modified), muted: true, fontSize: clideFontSmall),
],
+4 -22
View File
@@ -17,13 +17,7 @@ import 'package:flutter/widgets.dart';
typedef SessionDeleter = Future<void> Function(Directory dir, String id);
class SessionStorageDialog extends StatefulWidget {
const SessionStorageDialog({
super.key,
required this.dir,
required this.sessions,
required this.onClose,
this.deleter = deleteSession,
});
const SessionStorageDialog({super.key, required this.dir, required this.sessions, required this.onClose, this.deleter = deleteSession});
final Directory dir;
final List<SessionSummary> sessions;
@@ -79,19 +73,11 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
child: ClideText(
'Session storage · ${formatBytes(_total)} total',
fontSize: clideFontBody,
color: theme.globalForeground,
),
child: ClideText('Session storage · ${formatBytes(_total)} total', fontSize: clideFontBody, color: theme.globalForeground),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: ClideText(
'Deleting a session you are currently using will break that pane.',
muted: true,
fontSize: clideFontSmall,
),
child: ClideText('Deleting a session you are currently using will break that pane.', muted: true, fontSize: clideFontSmall),
),
if (_sessions.isEmpty)
Padding(
@@ -100,11 +86,7 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _sessions.length,
itemBuilder: (ctx, i) => _row(theme, _sessions[i]),
),
child: ListView.builder(shrinkWrap: true, itemCount: _sessions.length, itemBuilder: (ctx, i) => _row(theme, _sessions[i])),
),
],
),
+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:
/// 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).
/// `/fork` branches the current session into a new pane (T-172).
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'};
/// `/fork` branches the current session into a new pane (T-172). `/model`
/// 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
/// [kClideOwnedCommands]), or null.
@@ -40,6 +61,93 @@ String? clideOwnedCommand(String text) {
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';
/// An in-progress slash query at the cursor — the `/` position and the word
+375 -69
View File
@@ -19,8 +19,12 @@ import 'dart:convert';
import 'dart:io';
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';
/// The claude subprocess, abstracted so tests drive it without spawning.
/// Fakes `extend` this and override what they drive; the defaults below
/// describe a process with no real child behind it.
abstract class StreamJsonProcess {
/// stdout, one JSON event per line.
Stream<String> get lines;
@@ -30,21 +34,47 @@ abstract class StreamJsonProcess {
/// Terminate the process.
Future<void> kill();
/// The last lines of the child's stderr, drained continuously so the pipe
/// can never fill and block the child mid-turn (T-361). Default: none.
List<String> get stderrTail => const [];
/// Completes with the child's exit code, or null when there is no real
/// process to watch (fakes that never "exit").
Future<int>? get exitCode => null;
}
/// A bounded FIFO of the most recent lines — the stderr tail kept for
/// post-mortem diagnostics while the stream itself is drained and dropped.
class BoundedLineBuffer {
BoundedLineBuffer({this.cap = 100});
final int cap;
final List<String> _lines = [];
void add(String line) {
_lines.add(line);
if (_lines.length > cap) _lines.removeAt(0);
}
List<String> get lines => List.unmodifiable(_lines);
}
/// Production [StreamJsonProcess] backed by a real `claude` process.
class ClaudeStreamJsonProcess implements StreamJsonProcess {
ClaudeStreamJsonProcess._(this._proc);
class ClaudeStreamJsonProcess extends StreamJsonProcess {
ClaudeStreamJsonProcess._(this._proc) {
// Drain stderr from the moment the process exists — with --verbose the
// CLI chats on stderr, and an undrained 64KB pipe blocks the child
// mid-turn with zero diagnostics (T-361). Keep a tail for post-mortems.
_proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(_stderr.add, onError: (Object _) {});
}
final Process _proc;
final BoundedLineBuffer _stderr = BoundedLineBuffer();
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
static Future<ClaudeStreamJsonProcess> start({
required List<String> sessionArgs,
required String cwd,
Map<String, String>? env,
}) async {
static Future<ClaudeStreamJsonProcess> start({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) async {
final proc = await Process.start(
'claude',
[
@@ -77,8 +107,27 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
@override
Future<void> kill() async {
// Await the process's ACTUAL death, not just the signal (T-437). clide
// respawns the primary on the SAME deterministic --session-id right after
// /clear; if the old process is still alive (or still flushing its
// transcript) when the new one starts, claude 2.1.177 rejects the id with
// "Session ID … is already in use" and the respawn exits 1. SIGTERM first
// (claude cleans its session registry on it), escalate to SIGKILL if it
// doesn't go, and only return once exitCode has resolved.
_proc.kill();
try {
await _proc.exitCode.timeout(const Duration(seconds: 2));
} on TimeoutException {
_proc.kill(ProcessSignal.sigkill);
await _proc.exitCode;
}
}
@override
List<String> get stderrTail => _stderr.lines;
@override
Future<int> get exitCode => _proc.exitCode;
}
/// An in-process MCP server clide hosts for a session, entirely over the
@@ -112,6 +161,53 @@ abstract class McpServer {
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
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
/// an `AskUserQuestion`. Pure data; the decision goes back via
@@ -176,10 +272,10 @@ final class AllowTool extends ToolDecision {
final String? followUpNote;
@override
Map<String, dynamic> toJson() => {
'behavior': 'allow',
'updatedInput': updatedInput,
if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions,
};
'behavior': 'allow',
'updatedInput': updatedInput,
if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions,
};
}
/// Deny the tool with a user-facing [message] (required by the protocol).
@@ -198,6 +294,29 @@ final class DenyTool extends ToolDecision {
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
/// answers control-channel prompts, and sends user messages.
/// Terminal session end: the claude process exited (crash or otherwise).
/// Carries the exit code and the drained stderr tail for diagnostics.
class SessionEnd {
const SessionEnd({required this.exitCode, required this.stderrTail});
final int exitCode;
final List<String> stderrTail;
/// The most recent non-empty stderr line — the CLI's own error message when
/// it dies (e.g. "Session ID … is already in use") — for surfacing in the
/// pane so a non-zero exit is never an opaque "code 1" (T-437). Empty when
/// stderr was silent; capped so a stray long line can't blow out the status
/// line.
String get reason {
for (final line in stderrTail.reversed) {
final t = line.trim();
if (t.isEmpty) continue;
return t.length > 200 ? '${t.substring(0, 200)}' : t;
}
return '';
}
}
class StreamJsonSession {
StreamJsonSession(this._proc, {List<McpServer> mcpServers = const []}) : _mcpServers = mcpServers;
@@ -208,13 +327,36 @@ class StreamJsonSession {
/// round-trips are answered by [_handleMcpMessage].
final List<McpServer> _mcpServers;
final _items = StreamController<ConversationItem>.broadcast();
final _statusCtl = StreamController<SessionStatus>.broadcast();
// State, not events — replay-latest so a subscriber that binds after the
// init event still sees the current status (T-386; root cause of T-274).
final _statusCtl = ValueStream<SessionStatus>();
final _sessionIdCtl = StreamController<String>.broadcast();
StreamSubscription<String>? _sub;
SessionStatus _status = const SessionStatus();
String? _claudeSessionId;
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).
///
/// With `--include-partial-messages`, claude emits the in-progress reply as
@@ -241,7 +383,7 @@ class StreamJsonSession {
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
/// is the one currently shown in the composer zone.
final _queue = <ToolPrompt>[];
final _pendingCtl = StreamController<ToolPrompt?>.broadcast();
final _pendingCtl = ValueStream<ToolPrompt?>.seeded(null);
/// tool_use_ids that surfaced as a prompt — the view hides their raw
/// tool-use card while pending (it shows as a prompt) but keeps the result.
@@ -263,10 +405,24 @@ class StreamJsonSession {
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
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
/// the composer's Stop affordance.
bool _busy = false;
final _busyCtl = StreamController<bool>.broadcast();
final _busyCtl = ValueStream<bool>.seeded(false);
bool get busy => _busy;
Stream<bool> get busyStream => _busyCtl.stream;
@@ -303,23 +459,41 @@ class StreamJsonSession {
/// The latest known status — the current value [statusStream] last emitted.
SessionStatus get status => _status;
/// Non-null once the claude process has exited (T-361). Late binders read
/// this; live listeners get [endedStream]. Never set by a deliberate
/// [dispose] — only by the process dying underneath a live session.
SessionEnd? get end => _end;
SessionEnd? _end;
final _endCtl = StreamController<SessionEnd>.broadcast();
bool _disposed = false;
/// Fires once when the process exits while the session is still live —
/// a crashed/dead session must not just look thoughtful (T-361).
Stream<SessionEnd> get endedStream => _endCtl.stream;
/// Begin consuming the process's event stream.
void start() {
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
// Declaring our in-process MCP servers in the `initialize` handshake is what
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
// when we actually host a server, so a plain session is unchanged.
if (_mcpServers.isNotEmpty) {
_proc.writeLine(jsonEncode({
// Watch the process itself: stdout EOF alone is ambiguous, the exit
// code is not (T-361).
final exit = _proc.exitCode;
if (exit != null) unawaited(exit.then(_onExit));
// The `initialize` handshake is side-effect-free (verified in the protocol
// spike) and does double duty: declaring our in-process MCP servers is what
// makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
// response's `models[]` feeds the /model picker (T-408).
_initRequestId = 'init-${_localSeq++}';
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': 'init-${_localSeq++}',
'request_id': _initRequestId,
'request': {
'subtype': 'initialize',
'hooks': <String, dynamic>{},
'sdkMcpServers': [for (final s in _mcpServers) s.name],
},
}));
}
}),
);
}
void _onLine(String line) {
@@ -347,6 +521,12 @@ class StreamJsonSession {
_onControlRequest(ev);
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
// streaming state so the next turn is fresh.
if (ev['type'] == 'result') {
@@ -363,6 +543,15 @@ class StreamJsonSession {
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
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
// so the controller replaces the placeholder in place rather than appending
@@ -436,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]
/// item the UI resolves; every other subtype is answered with an error so
/// the turn never hangs waiting on us (D-78).
@@ -448,15 +646,17 @@ class StreamJsonSession {
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
final tuid = request['tool_use_id'] as String? ?? '';
if (tuid.isNotEmpty) _promptedToolUses.add(tuid);
_queue.add(ToolPrompt(
promptId: rid,
toolName: toolName,
displayName: request['display_name'] as String? ?? toolName,
description: request['description'] as String?,
toolUseId: request['tool_use_id'] as String? ?? '',
input: input,
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
));
_queue.add(
ToolPrompt(
promptId: rid,
toolName: toolName,
displayName: request['display_name'] as String? ?? toolName,
description: request['description'] as String?,
toolUseId: request['tool_use_id'] as String? ?? '',
input: input,
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
),
);
_pendingCtl.add(pendingPrompt);
return; // awaits resolvePrompt
}
@@ -465,10 +665,12 @@ class StreamJsonSession {
unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>()));
return;
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
}),
);
}
/// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named
@@ -489,14 +691,16 @@ class StreamJsonSession {
} else {
mcpResponse = await _dispatchMcp(server, message);
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {
'subtype': 'success',
'request_id': rid,
'response': {'mcp_response': mcpResponse}
},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_response',
'response': {
'subtype': 'success',
'request_id': rid,
'response': {'mcp_response': mcpResponse},
},
}),
);
}
McpServer? _mcpServerNamed(String? name) {
@@ -559,11 +763,21 @@ class StreamJsonSession {
_toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId);
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
}),
);
if (decision is AllowTool) {
// Approving ExitPlanMode leaves plan mode. The CLI performs the
// transition itself on the approval, so we don't send a
// set_permission_mode control request — we just sync our tracked status
// (exits to 'default', matching Claude Code) so the permission-mode
// indicator and composer reflect the change (T-337).
if (prompt.toolName == 'ExitPlanMode') {
_mergeStatus(const SessionStatus(permissionMode: 'default'));
}
// The prompt card is ephemeral (it vanishes once resolved), so leave a
// compact record of an answered question in the conversation log (D-78).
if (prompt.isQuestion) {
@@ -652,28 +866,39 @@ class StreamJsonSession {
/// so it renders immediately (stream-json doesn't replay stdin without
/// `--replay-user-messages`).
void send(String text) {
_proc.writeLine(jsonEncode({
'type': 'user',
'message': {'role': 'user', 'content': text},
}));
_items.add(UserMessage(
uuid: 'local-${_localSeq++}',
timestamp: DateTime.now(),
isSidechain: false,
text: text,
));
_proc.writeLine(
jsonEncode({
'type': 'user',
'message': {'role': 'user', 'content': text},
}),
);
_items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text));
_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
/// the `interrupt` control_request; claude cancels the current turn and ends
/// it with a `result`, which clears [busy]. Safe to call when idle.
void interrupt() {
_proc.writeLine(jsonEncode({
'type': 'control_request',
'request_id': 'interrupt-${_localSeq++}',
'request': {'subtype': 'interrupt'},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': 'interrupt-${_localSeq++}',
'request': {'subtype': 'interrupt'},
}),
);
}
/// Set the session's permission mode (T-181, D-77). Sends a
@@ -685,11 +910,13 @@ class StreamJsonSession {
/// cockpit badge's plain click; bypassPermissions is reachable only via a
/// confirmed shift-click (T-181).
void setPermissionMode(String mode) {
_proc.writeLine(jsonEncode({
'type': 'control_request',
'request_id': 'set-perm-${_localSeq++}',
'request': {'subtype': 'set_permission_mode', 'mode': mode},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': 'set-perm-${_localSeq++}',
'request': {'subtype': 'set_permission_mode', 'mode': mode},
}),
);
// Optimistically reflect the change so the badge / status line update
// immediately (T-250) — the control_request emits no status event, and a
// fresh system/init only arrives later. The next init reconciles if the
@@ -697,13 +924,92 @@ class StreamJsonSession {
_mergeStatus(SessionStatus(permissionMode: mode));
}
Future<void> dispose() async {
/// 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"
/// surface off so the pane reflects reality instead of spinning forever.
void _onExit(int code) {
if (_disposed || _end != null) return;
_end = SessionEnd(exitCode: code, stderrTail: _proc.stderrTail);
_setBusy(false);
// A prompt pending against a dead process can never be answered —
// clear it so the composer comes back.
if (_queue.isNotEmpty) {
_queue.clear();
_pendingCtl.add(null);
}
_endCtl.add(_end!);
}
/// Idempotent: the conversation controller's [dispose] fires this
/// unawaited while a caller (the orchestrator's [ClaudeSessionOrchestrator.close])
/// awaits it to know the process is truly dead (T-437). Caching the future
/// makes both paths share one teardown rather than killing/closing twice.
Future<void> dispose() => _disposeFuture ??= _dispose();
Future<void>? _disposeFuture;
Future<void> _dispose() async {
_disposed = true; // deliberate teardown — suppress the exit-watch path
await _sub?.cancel();
await _proc.kill();
await _proc.kill(); // awaits the process's real exit (T-437)
await _items.close();
await _statusCtl.close();
await _workflowsCtl.close();
await _sessionIdCtl.close();
await _pendingCtl.close();
await _busyCtl.close();
await _endCtl.close();
await _modelErrorCtl.close();
}
}
+4 -4
View File
@@ -47,7 +47,7 @@ List<TaskItem> taskListFrom(List<ConversationItem> items) {
}
TaskStatus _statusFrom(Object? raw) => switch (raw) {
'in_progress' => TaskStatus.inProgress,
'completed' => TaskStatus.completed,
_ => TaskStatus.pending,
};
'in_progress' => TaskStatus.inProgress,
'completed' => TaskStatus.completed,
_ => TaskStatus.pending,
};
+16 -32
View File
@@ -35,13 +35,7 @@ class TeamMemberRef {
/// A message left for a member, in arrival order.
class TeamMessage {
const TeamMessage({
required this.from,
required this.text,
required this.at,
this.to,
this.broadcast = false,
});
const TeamMessage({required this.from, required this.text, required this.at, this.to, this.broadcast = false});
final String from;
/// Recipient name: a single member's display name (direct message), `null`
@@ -52,13 +46,7 @@ class TeamMessage {
final DateTime at;
final bool broadcast;
Map<String, dynamic> toJson() => {
'from': from,
if (to != null) 'to': to,
'text': text,
'at': at.toIso8601String(),
if (broadcast) 'broadcast': true,
};
Map<String, dynamic> toJson() => {'from': from, if (to != null) 'to': to, 'text': text, 'at': at.toIso8601String(), if (broadcast) 'broadcast': true};
}
/// A shared task. Status is one of `open` / `claimed` / `done`.
@@ -69,12 +57,7 @@ class TeamTask {
String status;
String? owner;
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'status': status,
if (owner != null) 'owner': owner,
};
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'status': status, if (owner != null) 'owner': owner};
}
/// Pushes [text] into the member identified by [toMemberId] as a user message
@@ -304,7 +287,7 @@ class TeamBroker {
return {'ok': true, 'task': t.toJson()};
}
return {
'tasks': [for (final t in _tasks.values) t.toJson()]
'tasks': [for (final t in _tasks.values) t.toJson()],
};
}
@@ -362,25 +345,26 @@ class TeamMcpServer implements McpServer {
return _result(broker.claimTask(memberId, id: arguments['id'] as String?, title: arguments['title'] as String?));
case 'task_status':
return _result(
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?));
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?),
);
default:
return _error('Unknown team tool: $name');
}
}
Map<String, dynamic> _result(Map<String, dynamic> value) => {
'content': [
{'type': 'text', 'text': jsonEncode(value)},
],
'isError': value['ok'] == false,
};
'content': [
{'type': 'text', 'text': jsonEncode(value)},
],
'isError': value['ok'] == false,
};
Map<String, dynamic> _error(String message) => {
'content': [
{'type': 'text', 'text': message},
],
'isError': true,
};
'content': [
{'type': 'text', 'text': message},
],
'isError': true,
};
}
const _toolDefs = <Map<String, dynamic>>[
+3 -7
View File
@@ -5,8 +5,8 @@
/// and the full workspace pane read from this one model — they share state,
/// they do NOT each hold their own copy.
///
/// [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
/// `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
/// target session THEN delivers the message.
///
/// Flutter-free on purpose: this module (like [TeamBroker]) runs under
@@ -29,11 +29,7 @@ typedef SessionResolver = StreamJsonSession? Function(String memberName);
/// Lifetime matches the orchestrator: created once, subscribed to the broker,
/// disposed when the orchestrator is torn down.
class TeamChatModel {
TeamChatModel({
required TeamBroker broker,
SessionResolver? sessionResolver,
}) : _broker = broker,
_sessionResolver = sessionResolver {
TeamChatModel({required TeamBroker broker, SessionResolver? sessionResolver}) : _broker = broker, _sessionResolver = sessionResolver {
_sub = broker.messages.listen(_onMessage);
}
+17 -79
View File
@@ -2,7 +2,7 @@
///
/// 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
/// ([claude.team-chat] workspace tab).
/// (`claude.team-chat` workspace tab).
///
/// Both this widget and [TeamChatPane] read from the same [TeamChatModel] —
/// there is one model, two surfaces.
@@ -26,12 +26,7 @@ import 'package:flutter/widgets.dart';
/// [onPopOut] is called when the user taps the pop-out icon to open the full
/// pane — the extension wires this to `panels.activateTab`.
class TeamChatSidebar extends StatefulWidget {
const TeamChatSidebar({
super.key,
required this.model,
required this.broker,
required this.onPopOut,
});
const TeamChatSidebar({super.key, required this.model, required this.broker, required this.onPopOut});
final TeamChatModel model;
final TeamBroker broker;
@@ -149,11 +144,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
onTap: widget.onPopOut,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
child: ClideIcon(
PhosphorIcons.byName('arrows-out-simple'),
size: 10,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
),
child: ClideIcon(PhosphorIcons.byName('arrows-out-simple'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
),
@@ -177,13 +168,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
formatLabel: (n) => '@$n',
child: Focus(
onKeyEvent: _handleKeyEvent,
child: _ChatInputField(
controller: _controller,
focusNode: _focusNode,
tokens: tokens,
onSubmit: _submit,
placeholder: '@name or @team …',
),
child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
),
),
],
@@ -200,11 +185,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
/// Reads from the same [TeamChatModel] as [TeamChatSidebar]. Supports the
/// interrupt tickbox and full @-completion.
class TeamChatPane extends StatefulWidget {
const TeamChatPane({
super.key,
required this.model,
required this.broker,
});
const TeamChatPane({super.key, required this.model, required this.broker});
final TeamChatModel model;
final TeamBroker broker;
@@ -231,11 +212,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
// Scroll to bottom on new message.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
);
_scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 120), curve: Curves.easeOut);
}
});
}
@@ -297,11 +274,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
final text = raw.trim();
if (text.isEmpty) return;
final parsed = parseAtTag(text);
widget.model.postAsUser(
parsed.body.isEmpty ? text : parsed.body,
toName: parsed.recipient,
interrupt: _interrupt,
);
widget.model.postAsUser(parsed.body.isEmpty ? text : parsed.body, toName: parsed.recipient, interrupt: _interrupt);
_controller.clear();
}
@@ -340,11 +313,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: messages.length,
itemBuilder: (_, i) => _ChatRow(
key: ValueKey(messages[i].at.microsecondsSinceEpoch),
message: messages[i],
tokens: tokens,
),
itemBuilder: (_, i) => _ChatRow(key: ValueKey(messages[i].at.microsecondsSinceEpoch), message: messages[i], tokens: tokens),
),
),
// Composer + interrupt tickbox.
@@ -375,24 +344,13 @@ class _TeamChatPaneState extends State<TeamChatPane> {
margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration(
color: _interrupt ? tokens.globalFocus.withAlpha(40) : const Color(0x00000000),
border: Border.all(
color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted,
width: 1,
),
border: Border.all(color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted, width: 1),
borderRadius: BorderRadius.circular(2),
),
child: _interrupt
? Center(
child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus),
)
: null,
child: _interrupt ? Center(child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus)) : null,
),
),
ClideText(
'Interrupt',
fontSize: clideFontSmall,
color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted,
),
ClideText('Interrupt', fontSize: clideFontSmall, color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted),
],
),
),
@@ -405,13 +363,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
formatLabel: (n) => '@$n',
child: Focus(
onKeyEvent: _handleKeyEvent,
child: _ChatInputField(
controller: _controller,
focusNode: _focusNode,
tokens: tokens,
onSubmit: _submit,
placeholder: '@name or @team …',
),
child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
),
),
],
@@ -439,8 +391,8 @@ class _ChatRow extends StatelessWidget {
final toLabel = message.broadcast
? '→ all'
: message.to != null
? '${message.to}'
: null;
? '${message.to}'
: null;
return Padding(
padding: const EdgeInsets.only(bottom: 3),
@@ -451,10 +403,7 @@ class _ChatRow extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
margin: const EdgeInsets.only(right: 5, top: 1),
decoration: BoxDecoration(
color: senderColor.withAlpha(30),
borderRadius: BorderRadius.circular(2),
),
decoration: BoxDecoration(color: senderColor.withAlpha(30), borderRadius: BorderRadius.circular(2)),
child: ClideText(message.from, fontSize: clideFontSmall, color: senderColor),
),
if (toLabel != null)
@@ -480,13 +429,7 @@ class _ChatRow extends StatelessWidget {
/// Inline text input for the chat composer.
class _ChatInputField extends StatelessWidget {
const _ChatInputField({
required this.controller,
required this.focusNode,
required this.tokens,
required this.onSubmit,
required this.placeholder,
});
const _ChatInputField({required this.controller, required this.focusNode, required this.tokens, required this.onSubmit, required this.placeholder});
final TextEditingController controller;
final FocusNode focusNode;
@@ -507,12 +450,7 @@ class _ChatInputField extends StatelessWidget {
child: EditableText(
controller: controller,
focusNode: focusNode,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: clideFontSmall,
color: tokens.globalForeground,
height: 1.4,
),
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: onSubmit,
+3 -14
View File
@@ -51,10 +51,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
void _onJoined(TeamMemberJoined m) {
if (_controllers.containsKey(m.agentId)) return;
final kernel = ClideKernel.of(context);
_controllers[m.agentId] = ConversationController.fromBus(
messages: kernel.messages,
channel: ClaudeConversation.teammateChannel(m.agentId),
);
_controllers[m.agentId] = ConversationController.fromBus(messages: kernel.messages, channel: ClaudeConversation.teammateChannel(m.agentId));
setState(() => _members.add(m));
}
@@ -92,11 +89,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
}),
),
Expanded(
child: _TeammateGrid(
members: _members,
controllers: _controllers,
tokens: tokens,
),
child: _TeammateGrid(members: _members, controllers: _controllers, tokens: tokens),
),
],
);
@@ -145,11 +138,7 @@ class _TeammateGrid extends StatelessWidget {
children: [
for (var r = 0; r < rows; r++)
Expanded(
child: Row(
children: [
for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c)),
],
),
child: Row(children: [for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c))]),
),
],
),
+7 -4
View File
@@ -36,10 +36,13 @@ Future<bool> applyTicketPickUp(
final id = data['id'] as String?;
final status = data['status'] as String?;
if (id != null && id.isNotEmpty && kPickUpStartableStatuses.contains(status)) {
final resp = await ipc.request('pql.tickets.status', args: {
'ids': [id],
'status': 'in_progress',
});
final resp = await ipc.request(
'pql.tickets.status',
args: {
'ids': [id],
'status': 'in_progress',
},
);
if (resp.ok) messages.publish('builtin.tickets', 'changed', {'id': id});
}
return true;
@@ -1,18 +1,14 @@
/// Bridges a [TranscriptReader] onto the kernel [MessageBus] (epic T-132,
/// D-75).
/// Bus addressing for Claude conversation content (epic T-132, D-75).
///
/// One reader tails a workspace transcript; this publisher republishes
/// every [ConversationItem] as a bus [Message]. Any number of Claude
/// panels can then subscribe to the same conversation via the bus instead
/// of each owning its own reader — the decoupling the team panels
/// (T-139/T-140) need, where a single observer feeds the lead tile plus a
/// tile per teammate.
/// The tmux-era `TranscriptPublisher` that used to live here (one reader
/// tailing a transcript, republished onto the bus) had no production
/// constructor calls after the stream-json pivot (D-77) and was removed
/// in the T-385 dead-code sweep. The [ClaudeConversation] channel/key
/// constants remain — the meta sidebar and team panel host still consume
/// them for member-status messages.
library;
import 'dart:async';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
/// Bus addressing for Claude conversation content.
abstract final class ClaudeConversation {
@@ -29,7 +25,7 @@ abstract final class ClaudeConversation {
/// Channel for a teammate's conversation (team work, T-139/T-140).
static String teammateChannel(String agentId) => 'conversation/$agentId';
/// Key under which the [ConversationItem] travels in a [Message]'s data.
/// Key under which the [ConversationItem] travels in a bus message's data.
static const itemKey = 'item';
/// Shared channel carrying each team member's live status (T-157). Every
@@ -38,43 +34,9 @@ abstract final class ClaudeConversation {
/// Encode [status] for [agentId] as a [memberStatusChannel] message body.
static Map<String, Object?> memberStatusData(String agentId, SessionStatus status) => {
'agentId': agentId,
if (status.model != null) 'model': status.model,
if (status.permissionMode != null) 'permissionMode': status.permissionMode,
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
};
}
class TranscriptPublisher {
/// Starts republishing [reader]'s items onto [messages] under
/// [ClaudeConversation.publisher] / [channel]. The subscription is
/// attached synchronously, so a controller that subscribes before the
/// reader's first poll never misses the initial tail.
TranscriptPublisher({
required MessageBus messages,
required TranscriptReader reader,
this.channel = ClaudeConversation.leadChannel,
}) : _messages = messages,
_reader = reader {
_sub = _reader.stream.listen((item) {
_messages.publish(ClaudeConversation.publisher, channel, {
ClaudeConversation.itemKey: item,
});
});
}
final MessageBus _messages;
final TranscriptReader _reader;
final String channel;
late final StreamSubscription<ConversationItem> _sub;
/// Live session status (model / permission-mode / context) from the
/// underlying reader — passed through for the status strip (T-145).
Stream<SessionStatus> get statusStream => _reader.statusStream;
/// Stops publishing and tears down the underlying reader.
Future<void> dispose() async {
await _sub.cancel();
await _reader.dispose();
}
'agentId': agentId,
if (status.model != null) 'model': status.model,
if (status.permissionMode != null) 'permissionMode': status.permissionMode,
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
};
}

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