71 Commits
Author SHA1 Message Date
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
182 changed files with 11445 additions and 664 deletions
-91
View File
@@ -1,91 +0,0 @@
# Gitea Actions workflow for clide.
#
# NOT YET ACTIVATED. Gitea Actions must be enabled in the instance
# settings before this runs; until then the file is just a ready-made
# pipeline Claude + the user can review.
#
# When the repo eventually lands on GitHub, copy this file verbatim to
# `.github/workflows/test.yml` — Gitea Actions consumes GitHub-Actions
# syntax, so no rewrite is needed.
#
# Steps go through the make targets (the repo's tooling-discipline rule:
# the make layer sets up the environment — gen-build-info etc. — and
# stays correct if a wrapped script moves). T-384 fixed three latent
# breaks here: a `cd app` into the flattened-away app/ directory, a
# coverage gate with no coverage run before it, and raw ci/ script
# invocations that skipped build-info generation.
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
name: unit + widget + golden + a11y + coverage gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
# test-coverage runs the full fast suite WITH coverage (it includes
# the a11y suite — see the push-check note in the Makefile), which
# is what coverage-gate consumes.
- run: make test-coverage
- run: make coverage-gate
integration:
name: integration_test (xvfb)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: flutter pub get
- uses: coactions/setup-xvfb@v1
with: { run: make test-integration }
startup-bundle:
name: bundle smoke (xvfb 5s)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: flutter pub get
- run: make smoke-bundle
# The web-WASM Playwright job is withheld: `flutter build web --wasm`
# cannot compile the tree since the tree-sitter/PTY dart:ffi pivot
# (dart:ffi is unavailable on the wasm target). Whether the web target
# gets conditional-import fences or is dropped is an open question —
# see Q-50 in governance/questions/architecture.md. Re-add the job
# (steps: setup-node, npm install + playwright install in tools/ui,
# `make test-e2e`) when Q-50 resolves toward keeping it.
docs:
name: dart doc (lib API)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
- name: dart doc --validate-links (fail on warning)
run: |
set -o pipefail
dart doc --validate-links 2>&1 | tee dartdoc.log
if grep -q "^ warning:" dartdoc.log; then
echo "::error::dartdoc emitted warnings — see log above"
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: dart-api-docs
path: doc/api/
+8 -2
View File
@@ -1,3 +1,9 @@
#!/bin/sh #!/bin/sh
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout) # pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout).
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout" # The pql hook is untracked (a local `pql init` install), so a fresh
# `git worktree add` has no .pql/hooks — source it only when present, and
# always exit 0: post-checkout is best-effort and must never abort the
# checkout / worktree creation.
hook="$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout"
if [ -f "$hook" ]; then . "$hook"; fi
exit 0
+104
View File
@@ -0,0 +1,104 @@
name: release
# Build + publish versioned Windows and Linux release bundles when the version
# in pubspec.yaml changes on main. The `version` job only proceeds when the
# v<version> tag doesn't already exist, so an unrelated pubspec edit is a no-op.
#
# FIRST CUT — neither build has run in CI yet (Windows has never been built at
# all), so expect to iterate on these from the first run's logs. The repo's own
# `make` targets are the build contract (gen-build-info + clide-cli + flutter
# build, all wired in `make build`).
on:
push:
branches: [main]
paths: ['pubspec.yaml']
workflow_dispatch:
permissions:
contents: write # create the tag + the release
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
fresh: ${{ steps.v.outputs.fresh }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # tags, to tell new vs. already-released
- id: v
shell: bash
run: |
version=$(awk -F': *' '/^version:/ {gsub(/[" ]/,"",$2); print $2; exit}' pubspec.yaml)
echo "version=$version" >> "$GITHUB_OUTPUT"
if git rev-parse "v$version" >/dev/null 2>&1; then
echo "fresh=false" >> "$GITHUB_OUTPUT"
echo "v$version already tagged — nothing to release."
else
echo "fresh=true" >> "$GITHUB_OUTPUT"
echo "v$version is new — building."
fi
build-linux:
needs: version
if: needs.version.outputs.fresh == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: sudo apt-get update && sudo apt-get install -y ninja-build libgtk-3-dev
- run: make dugite-fetch
- run: make build # gen-build-info + clide-cli + flutter build linux
- name: package
run: tar -C build/linux/x64/release/bundle -czf clide-linux-x64-${{ needs.version.outputs.version }}.tar.gz .
- uses: actions/upload-artifact@v4
with: { name: linux, path: clide-linux-x64-*.tar.gz }
build-windows:
needs: version
if: needs.version.outputs.fresh == 'true'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: choco install -y make
- name: build
shell: bash
run: make dugite-fetch && make build # MSVC + bash already on windows-latest
- name: package
shell: pwsh
run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath clide-windows-x64-${{ needs.version.outputs.version }}.zip
- uses: actions/upload-artifact@v4
with: { name: windows, path: clide-windows-x64-*.zip }
publish:
needs: [version, build-linux, build-windows]
if: needs.version.outputs.fresh == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: changelog notes for this version
shell: bash
run: |
ver="${{ needs.version.outputs.version }}"
# Pull the entries under `## [<version>]` — the changelog cut that the
# version-bump commit lands per the changelog discipline — as the
# release body; fall back to a one-liner if the section is absent.
awk -v ver="$ver" '
$0 ~ "^## \\[" ver "\\]" {grab=1; next}
grab && /^## \[/ {exit}
grab {print}
' CHANGELOG.md > release-notes.md
[ -s release-notes.md ] || echo "Release v$ver." > release-notes.md
- uses: actions/download-artifact@v4
with: { path: dist }
- uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.version.outputs.version }}
name: clide v${{ needs.version.outputs.version }}
body_path: release-notes.md # the version's CHANGELOG section
generate_release_notes: true # + auto commit list appended
files: dist/**/* # the built versioned bundles
+144
View File
@@ -0,0 +1,144 @@
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
# The web-WASM Playwright job is withheld: `flutter build web --wasm` cannot
# compile the tree since the tree-sitter/PTY dart:ffi pivot (dart:ffi is
# unavailable on the wasm target). Whether the web target gets conditional-
# import fences or is dropped is an open question — see Q-50 in
# governance/questions/architecture.md. Re-add the job (setup-node, npm
# install + playwright install in tools/ui, `make test-e2e`) when Q-50
# resolves toward keeping it.
docs:
name: dart doc (lib API)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
- name: dart doc --validate-links (fail on warning)
run: |
set -o pipefail
dart doc --validate-links 2>&1 | tee dartdoc.log
if grep -q "^ warning:" dartdoc.log; then
echo "::error::dartdoc emitted warnings — see log above"
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: dart-api-docs
path: doc/api/
+115
View File
@@ -0,0 +1,115 @@
name: windows-soak
# ConPTY orphan-leak soak on a GitHub-hosted Windows runner — the cheap
# alternative to a dedicated Windows VM. The freeze hypothesis (T-424) is that
# each WindowsPty.start() leaks its conhost/OpenConsole host because the child
# is not in a kill-on-close Job Object; across many runs those hosts pile up
# until the box starves. tools/windows-verify/soak-conpty.ps1 reproduces that
# WITHOUT crashing: it runs the ConPTY suite many times IN ONE job and counts
# the hosts that survive each dart.exe exit. A throwaway runner is fine — we
# watch the accumulation (the leading indicator), not the reboot. The repeated
# runs happen inside this single job, so the leak can build up here even though
# the runner is discarded afterwards (cf. the note in windows.yml, which only
# runs the suite once).
#
# Diagnostic, never a gate: it always exits 0 and just publishes the verdict +
# CSV. Runs on demand (workflow_dispatch) and when the soak kit itself changes.
on:
workflow_dispatch:
inputs:
iterations:
description: How many times to run the ConPTY suite (clean-path soak)
default: "25"
kill_iterations:
description: Spawn+force-kill cycles (abrupt-death orphan probe)
default: "15"
ptys_per_iter:
description: WindowsPty sessions spawned per kill cycle
default: "2"
push:
branches: [windows-support]
paths:
- tools/windows-verify/**
- .github/workflows/windows-soak.yml
jobs:
conpty-soak:
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
- name: ConPTY orphan-leak soak
shell: pwsh
run: |
$iters = "${{ github.event.inputs.iterations }}"
if (-not $iters) { $iters = "25" }
tools/windows-verify/soak-conpty.ps1 -Iterations ([int]$iters) -OutDir "$env:GITHUB_WORKSPACE/soak-out"
- name: Publish verdict to job summary
if: always()
shell: pwsh
run: |
$s = Get-ChildItem "$env:GITHUB_WORKSPACE/soak-out/*.summary.txt" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($s) { Get-Content $s.FullName | Add-Content $env:GITHUB_STEP_SUMMARY }
- name: Upload soak CSV + summary
if: always()
uses: actions/upload-artifact@v4
with:
name: conpty-soak
path: soak-out
if-no-files-found: warn
conpty-kill-probe:
# Abrupt-death half: force-kill the parent dart.exe mid-life (no close(),
# no Job Object) and count the ConPTY hosts that survive. This is the path
# the freeze hypothesis (T-424) actually implicates — the clean-path soak
# above never exercises it. Diagnostic only; always succeeds.
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
- name: ConPTY abrupt-death orphan probe
shell: pwsh
# CLIDE_LOG_DIR makes the probe emit FFI breadcrumbs (T-436): when a
# parent is force-killed mid-life, its reader/waiter isolates' last
# crumb ("ReadFile enter" / "WaitForSingleObject enter") is fsynced to
# clide-pty.crumbs.log and uploaded below — naming what the wedged
# isolate was doing at the instant of death.
env:
CLIDE_LOG_DIR: ${{ github.workspace }}/kill-crumbs
run: |
$iters = "${{ github.event.inputs.kill_iterations }}"
if (-not $iters) { $iters = "15" }
$ptys = "${{ github.event.inputs.ptys_per_iter }}"
if (-not $ptys) { $ptys = "2" }
tools/windows-verify/soak-conpty-kill.ps1 -Iterations ([int]$iters) -PtysPerIter ([int]$ptys) -OutDir "$env:GITHUB_WORKSPACE/kill-out"
- name: Publish verdict to job summary
if: always()
shell: pwsh
run: |
$s = Get-ChildItem "$env:GITHUB_WORKSPACE/kill-out/*.summary.txt" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($s) { Get-Content $s.FullName | Add-Content $env:GITHUB_STEP_SUMMARY }
- name: Upload kill-probe CSV + summary
if: always()
uses: actions/upload-artifact@v4
with:
name: conpty-kill-probe
path: kill-out
if-no-files-found: warn
- name: Upload FFI breadcrumbs (last act of each killed reader/waiter)
if: always()
uses: actions/upload-artifact@v4
with:
name: conpty-kill-crumbs
path: ${{ github.workspace }}/kill-crumbs
if-no-files-found: ignore
+48
View File
@@ -0,0 +1,48 @@
name: windows
# Windows CI on GitHub-hosted runners — the only hosted Windows available, and
# GitHub is clide's primary remote (the Gitea secondary is self-hosted Linux and
# keeps running the Linux suite). This is the first real execution of the ConPTY
# backend (lib/src/pty/windows_pty.dart), so expect genuine failures until the
# Windows fixes land (T-424). Keep this OUT of required status checks until it's
# reliably green — it reports + uploads artifacts without blocking merges. Each
# run is a fresh, discarded runner, so the accumulation freeze (which needs
# repeated runs on one machine) can't build up here.
on:
push:
branches: [main, windows-support]
pull_request:
workflow_dispatch:
jobs:
windows-pty:
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
# No `flutter analyze` here: it's platform-agnostic — the Linux job already
# analyzes windows_pty.dart and everything else statically, and the
# `flutter build windows` release job catches Windows-specific compile
# errors. Skipping it also avoids needing `make gen-build-info`, since the
# pty tests import the pty libraries directly, not the build_info-bearing
# barrel (lib/clide.dart). This job's unique value is running real ConPTY.
- name: ConPTY + Windows-arg unit tests
# windows_pty_test.dart drives real ConPTY (it self-skips off-Windows);
# the args/size suites are the pure-logic coverage. --timeout 60s so a
# wedged reader fails fast instead of hanging the runner.
run: dart test --concurrency=1 --timeout 60s test/pty/windows_pty_test.dart test/pty/windows_pty_args_test.dart test/pty/pty_size_test.dart
- name: Upload test output / logs
if: always()
uses: actions/upload-artifact@v4
with:
name: windows-test-output
# T-425 (crash-survivable FileLogSink) writes under %LOCALAPPDATA%\clide\logs;
# add that path here once it lands so a freeze leaves a downloadable log.
path: test/.test-output
if-no-files-found: ignore
+2
View File
@@ -58,6 +58,8 @@ tools/ui/.serve.pid
/native/linux-x64/clide /native/linux-x64/clide
/native/macos-arm64/clide /native/macos-arm64/clide
/native/macos-x64/clide /native/macos-x64/clide
/native/windows-x64/clide.exe
/native/windows-x64/clide.obj
# -- Test, coverage, profile output ------------------------------------ # -- Test, coverage, profile output ------------------------------------
*.test *.test
+6 -6
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7" revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
channel: "stable" channel: "stable"
project_type: app project_type: app
@@ -13,11 +13,11 @@ project_type: app
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
- platform: web - platform: windows
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
# User provided section # User provided section
+809
View File
@@ -4279,3 +4279,812 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it). - Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.', NULL, '2026-06-12 03:22:28', '2026-06-12 03:22:28', '2026-06-12 03:22:28', NULL, '267348e926541d1c7b5e55c3c1ef6219', 2) ON CONFLICT(hash) DO NOTHING; Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.', NULL, '2026-06-12 03:22:28', '2026-06-12 03:22:28', '2026-06-12 03:22:28', NULL, '267348e926541d1c7b5e55c3c1ef6219', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VTK2MYQQ173MSJN6E1DM', 'description', NULL, 'User report: typing /model in the Claude conversation view does nothing useful — it is forwarded to the session''s stream-json stdin like a plain message, and the CLI''s interactive /model picker only exists in its own TUI. Clide must own it (same class as /clear,/resume,/fork — T-156).
Interaction design:
- `/model <name>` set the session model directly to <name> (accept aliases like sonnet/opus and full ids).
- `/model` (bare) show a model picker in the interaction zone replaces the composer while open, like ToolPromptCard (D-78); list selectable via keyboard (numbers/arrows + Enter), Esc cancels back to the composer.
Implementation map (from code exploration):
- Add ''model'' to kClideOwnedCommands in lib/builtin/claude/src/slash_commands.dart and handle it in ClaudePane._send (lib/builtin/claude/src/claude_pane.dart).
- Add StreamJsonSession.setModel(String) following the setPermissionMode control_request pattern (lib/builtin/claude/src/stream_json_session.dart) subtype set_model; optimistically merge SessionStatus(model: ) so the status bar updates.
- Model list for the bare-picker: query the CLI via the supported_models-style control request if available (verify exact subtype/shapes against the installed CLI), falling back to a static alias list.
- Picker widget swaps in via the existing pending-interaction slot in ClaudePane; reuse composer focus/draft preservation (the draft must survive the swap).
Acceptance:
- `/model sonnet` switches the live session model; status bar reflects it on the next status merge.
- bare `/model` opens the picker; choosing an entry sets the model; Esc restores the composer with the draft intact.
- `/model` is never forwarded to the session as message text.
- Unit tests in test/builtin/claude/ for the parsing (slash_commands_test.dart), the pane interception, and the picker widget.', NULL, '2026-06-12 06:40:53', '2026-06-12 06:40:53', '2026-06-12 06:40:53', NULL, '86aaba4cdf1d5e3c054af341977c5315', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VYR84023Z5XFEX9DS0S0', 'description', NULL, 'Regression from T-341 (double-tap-modifier shortcuts, shipped in 2.4.0). User report: pressing shift+; to type a colon in the editor or the Claude composer no longer types '':'' — the quick-open finder opens instead.
Likely cause: the double-Shift tap detector counts a Shift press/release as a "tap" even when another key was chorded while Shift was held. Typing '':'' is shift-down, '';'', shift-up; two colons (or a colon shortly after any shifted character) within the tap window then reads as shift,shift "Search Everywhere" fires and may also swallow the keystroke.
Fix: a modifier press only qualifies as a tap if NO other key goes down between the modifier''s keydown and keyup. Any chorded key must invalidate the pending tap (and reset the double-tap sequence state).
Acceptance:
- Typing `::` rapidly in the editor and in the Claude composer produces two colons, never quick-open.
- Shifted typing in general (capitals, symbols) never triggers double-tap bindings.
- Genuine double-Shift (two bare taps within the window) still opens quick-open in all four presets.
- Regression test covering chorded-Shift-then-Shift-tap sequences.', NULL, '2026-06-12 06:40:54', '2026-06-12 06:40:54', '2026-06-12 06:40:54', NULL, '4f7eddbb58a3551d208dcd03541bed5d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VYR84023Z5XFEX9DS0S0', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 06:40:59', '2026-06-12 06:40:59', '2026-06-12 06:40:59', NULL, '6f856737a3d115b0a8dd510d2051047d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VYR84023Z5XFEX9DS0S0', 'status', 'in_progress', 'done', NULL, '2026-06-12 06:49:24', '2026-06-12 06:49:24', '2026-06-12 06:49:24', NULL, 'ce08a8f54370d08b033552e169a8bbb9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VTK2MYQQ173MSJN6E1DM', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 06:50:01', '2026-06-12 06:50:01', '2026-06-12 06:50:01', NULL, '801c09560f70be28365441175fb78440', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VTK2MYQQ173MSJN6E1DM', 'status', 'in_progress', 'done', NULL, '2026-06-12 07:04:36', '2026-06-12 07:04:36', '2026-06-12 07:04:36', NULL, '09be3f51210b0815177613db912defaa', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 10:09:07', '2026-06-12 10:09:07', '2026-06-12 10:09:07', NULL, 'e54e34e5177b55ccbdf0da9c217d44a5', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P8YERJ5R7ENSD675BX00', 'description', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls — the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use — one implementation, two surfaces (D-6).
Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual.', 'Turn the Claude sidebar Config tab''s read-only rows (T-183, split in T-395) into live controls the heart of the power-panel epic. Inline: model picker (reuse the T-408 picker, anchored popover per D-ui primitive), permission-mode control (reuse T-275''s permission_mode_control), effort selector (lands with T-412; row shows ''n/a'' with a hint until supported). Each control reads live SessionStatus and writes through the same session APIs the slash commands use one implementation, two surfaces (D-6).
Per-session scoping: controls target the active/primary session; the Team tab''s per-member badges (T-157) stay as-is. Keep read-only rows for facts (version, transcript path, skills count). A11y: every control keyboard-reachable, semantics labels per the a11y contract; run make test-a11y. Golden for the new rows if visual.
STYLING PASS (user, 2026-06-12): the Claude sidepanel looks bland and the font is
small. While making the Config tab interactive, also do a visual polish pass over
the whole Claude sidebar (Activity/Team/Config): bump the row/label typography to
the panel scale used elsewhere, give sections clearer hierarchy (headers, spacing,
accent marks per ui-design tokens), and make the controls feel like controls.
Treat ui-design skill as the reference for token/type choices.', NULL, '2026-06-12 10:24:43', '2026-06-12 10:24:43', '2026-06-12 10:24:43', NULL, '5e15c4d2cdabba62bbaba701a93f6e79', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'status', 'in_progress', 'done', NULL, '2026-06-12 10:25:53', '2026-06-12 10:25:53', '2026-06-12 10:25:53', NULL, 'f65cf1d99c1d22ced8d18e1579b048ad', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 10:25:53', '2026-06-12 10:25:53', '2026-06-12 10:25:53', NULL, 'dca846f23cb4353cc826ddd79a6c14f0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'status', 'in_progress', 'done', NULL, '2026-06-12 10:56:52', '2026-06-12 10:56:52', '2026-06-12 10:56:52', NULL, 'fd7c4c5f0b0de9144c6220a4ce43476b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3KRWM65MD3DS251NN9YX0', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 10:56:52', '2026-06-12 10:56:52', '2026-06-12 10:56:52', NULL, 'd1eb3f87ceb5508e4dac1f191750f852', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3KRWM65MD3DS251NN9YX0', 'status', 'in_progress', 'done', NULL, '2026-06-12 11:06:36', '2026-06-12 11:06:36', '2026-06-12 11:06:36', NULL, 'b39b89981d73539635ddbff0d7ca137d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P8YERJ5R7ENSD675BX00', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 11:06:36', '2026-06-12 11:06:36', '2026-06-12 11:06:36', NULL, 'e90215a982fee656911727727259d318', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ4BYD4STCKCY8JNKF23Q4W', 'description', NULL, 'The bottom central status bar mixes fonts, sizes, color tokens, and vertical alignment across its items, so the bar reads as several unrelated strips instead of one cohesive surface (user screenshot, 2026-06-12).
**Observed inconsistencies:**
- **Claude pane context status** (`lib/builtin/claude/src/claude_pane.dart:131` `_statusWidget`, `_ModeBadge` at line 816): `clideFontSmall` (12) + `clideMonoFamily` the only 12px mono run in the bar.
- **Git branch item** (`lib/builtin/git/src/git_status_item.dart:78`): `clideFontCaption` (14), default UI face (Josefin Sans w300) sits directly next to the 12px mono Claude segment.
- **Output dock item** (`lib/builtin/output/src/dock_status_item.dart:77`): `clideFontCaption` + `tokens.globalForeground` instead of `tokens.statusBarForeground`; also embeds ``/`` glyphs as text rather than a `ClideIcon`.
- **IPC tool status** (`lib/builtin/ipc_status/src/status_item.dart:45`) and **theme switcher** (`lib/builtin/theme_picker/src/theme_status_item.dart:68`): `clideFontCaption` + `statusBarForeground` these two agree with each other but not with the Claude segment.
- **Vertical alignment:** `PaneContextStatusItem` (`lib/builtin/claude/src/pane_context_status.dart:34`) clamps its content in a fixed 16px `SizedBox` inside `ClideMarquee`, while `StatusbarHost` (`lib/src/shell/layout.dart:251`) centers other items via `CrossAxisAlignment.center` on the full bar height the differing font sizes/line metrics make the Claude run sit visibly off-center relative to its neighbours.
**Direction (per /ui-design skill):**
- Typography rule says status bar text is `clideFontCaption` (14); mono (`clideFontMono`/`clideMonoFamily`) is reserved for code/paths/IDs. Decide one type treatment for the bar likely caption + UI face for labels, mono only for genuinely code-like fragments (e.g. `633k / 1.0M ctx`) if at all and apply it to every item.
- All status bar items should use `tokens.statusBarForeground` (muted variant: `globalTextMuted`) fix the `globalForeground` borrow in the output dock item.
- Ensure every item centers in the bar''s vertical space: same slot height strategy (or none) across items so baselines align; review the fixed `_slotHeight = 16` in `pane_context_status.dart` against the bar''s `statusHeight`.
- Separator `·` styling (claude_pane.dart:136) should be a shared affordance if other items adopt segmented content.
Acceptance: one font family/size/token scheme across all five status items; all items visually centered in the bar''s vertical space; no `globalForeground` borrows; goldens updated.', NULL, '2026-06-12 11:23:21', '2026-06-12 11:23:21', '2026-06-12 11:23:21', NULL, 'dd7198e29cdc41d0837ce0ff068fa26f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P8YERJ5R7ENSD675BX00', 'status', 'in_progress', 'done', NULL, '2026-06-12 11:26:14', '2026-06-12 11:26:14', '2026-06-12 11:26:14', NULL, '6033e2a267f3dc8e1102fcb2ef515a2e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'status', 'backlog', 'ready', NULL, '2026-06-12 11:28:23', '2026-06-12 11:28:23', '2026-06-12 11:28:23', NULL, '8152841064ed2ff151449541e0b405d3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'status', 'backlog', 'ready', NULL, '2026-06-12 11:28:28', '2026-06-12 11:28:28', '2026-06-12 11:28:28', NULL, 'ccb4c4193c34ef5e2bd9e20d407ccda3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'backlog', 'ready', NULL, '2026-06-12 11:28:32', '2026-06-12 11:28:32', '2026-06-12 11:28:32', NULL, '93622dd3b0a88598d1823685a0c2d3e6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'status', 'backlog', 'ready', NULL, '2026-06-12 11:28:38', '2026-06-12 11:28:38', '2026-06-12 11:28:38', NULL, '5729470fe53b18812c0338fa4f23a3c8', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'status', 'ready', 'done', NULL, '2026-06-12 11:30:22', '2026-06-12 11:30:22', '2026-06-12 11:30:22', NULL, '6dd59c198c66791e49bad07edcc24882', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'status', 'ready', 'in_progress', NULL, '2026-06-12 11:30:55', '2026-06-12 11:30:55', '2026-06-12 11:30:55', NULL, 'c5a086c9415df9fbe4e7feacecc19c73', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'status', 'in_progress', 'done', NULL, '2026-06-12 12:04:55', '2026-06-12 12:04:55', '2026-06-12 12:04:55', NULL, 'e7ff1649cb8b755d2888b851ad00c729', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62FKQQD0B9B80PFY4', 'description', 'Show the account/subscription usage budget (5-hour + weekly limits, % used, reset times) in the Claude meta sidebar (T-141). BLOCKED: this data is not programmatically exposed under subscription (OAuth) auth as of claude 2.1.150 — verified empirically + via docs (2026-05-23). /usage is TUI-only (headless ''claude -p /usage'' returns only a one-liner); stats-cache.json has activity counts only; the stream-json rate_limit_event is undocumented + needs a billed turn; ''claude auth status --json'' shows plan only. A ''claude usage --json'' + a /v1/organizations/{org}/usage/subscription endpoint are an OPEN, unshipped feature request (GitHub anthropics/claude-code#44328). Revisit when #44328 ships or an API-key usage path exists. See project memory ''claude-usage-budget-not-exposed''.
2026-06-09: detached from T-132 (which is otherwise complete) and made the RESOLVER ticket for Q-34 (how + when to surface the budget given upstream doesn''t expose it). Stays in the backlog; revisit when a viable data path lands (upstream claude usage --json / endpoint per anthropics/claude-code#44328, or an API-key usage path).', 'Show the account/subscription usage budget (5-hour + weekly limits, % used, reset times) in the Claude meta sidebar (T-141). BLOCKED: this data is not programmatically exposed under subscription (OAuth) auth as of claude 2.1.150 — verified empirically + via docs (2026-05-23). /usage is TUI-only (headless ''claude -p /usage'' returns only a one-liner); stats-cache.json has activity counts only; the stream-json rate_limit_event is undocumented + needs a billed turn; ''claude auth status --json'' shows plan only. A ''claude usage --json'' + a /v1/organizations/{org}/usage/subscription endpoint are an OPEN, unshipped feature request (GitHub anthropics/claude-code#44328). Revisit when #44328 ships or an API-key usage path exists. See project memory ''claude-usage-budget-not-exposed''.
2026-06-09: detached from T-132 (which is otherwise complete) and made the RESOLVER ticket for Q-34 (how + when to surface the budget given upstream doesn''t expose it). Stays in the backlog; revisit when a viable data path lands (upstream claude usage --json / endpoint per anthropics/claude-code#44328, or an API-key usage path).
UNBLOCKED (2026-06-12, T-415): probed claude 2.1.175 a forwarded /usage IS
answered headless in stream-json (free, num_turns 0) with parseable text
(session %, week % all-models, week % Sonnet). The Activity tab now renders it
via parseUsageText + a user-initiated refresh control. Remaining scope for this
ticket would be per-member/team budget split, if still wanted.', NULL, '2026-06-12 12:11:28', '2026-06-12 12:11:28', '2026-06-12 12:11:28', NULL, '5e45a2e89f2234f8b642e1b9968d7ef1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'description', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.
--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) ---
ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: <id>''. The run is async today clide shows only the generic tool card + that result, no fan-out.
PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id:
- task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source)
- task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index)
- task_updated: patch{status,end_time}
- task_notification: terminal status:''completed'', output_file, summary, usage
LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) live progress shows during the session; on reload only the tool card + result summary survive.
PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', NULL, '2026-06-12 14:22:42', '2026-06-12 14:22:42', '2026-06-12 14:22:42', NULL, '0b91f092dbd940481e385ccef6e45e6c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'ready', 'in_progress', NULL, '2026-06-12 14:22:45', '2026-06-12 14:22:45', '2026-06-12 14:22:45', NULL, 'bff47e0f2b4ebfbf5a4ca6b9c7b45825', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'in_progress', 'done', NULL, '2026-06-12 14:52:31', '2026-06-12 14:52:31', '2026-06-12 14:52:31', NULL, 'd05d502773832fcfb5c8baf19a606b1c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'status', 'ready', 'done', NULL, '2026-06-12 19:44:20', '2026-06-12 19:44:20', '2026-06-12 19:44:20', NULL, '3637ac53f5fc94698f604db89fdcb7e0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'status', 'backlog', 'ready', NULL, '2026-06-12 19:54:32', '2026-06-12 19:54:32', '2026-06-12 19:54:32', NULL, 'b70e45fbed6d4ad8d2c6e0e6abc5b443', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'description', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` command:panel.focus.left; `ctrl+w l` command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` command:dock.toggle document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` focus.nextPanel; `ctrl+w shift+w` focus.previousPanel
- `ctrl+w o` command:panel.focusMode (vim "only" exact semantic match)
- `ctrl+w q` and `ctrl+w c` command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands no new services:
- `ctrl+w h` command:panel.focus.left; `ctrl+w l` command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` command:dock.toggle document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` focus.nextPanel; `ctrl+w shift+w` focus.previousPanel
- `ctrl+w o` command:panel.focusMode (vim "only" exact semantic match)
- `ctrl+w q` and `ctrl+w c` command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
ACCEPTANCE CRITERIA:
- vim.yaml gains ctrl+w bindings: `ctrl+w h`panel.focus.left, `ctrl+w l`panel.focus.right, `ctrl+w j`dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`focus.nextPanel, `ctrl+w shift+w`focus.previousPanel, `ctrl+w o`panel.focusMode, `ctrl+w q`/`ctrl+w c`editor.close, all `when: vim.normal || vim.visual`.
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
- No resolution change under default/vscode/jetbrains editor_presets_test.dart `ctrl+w editor.close` (e.g. line 60) stays green.
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
- make analyze + format + keymap suite pass; 95% coverage floor holds.
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface coordinate ordering to avoid three matcher rewires.
OPEN QUESTIONS:
- Where does the global multi-chord matcher live a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
- Should the family also fire in vim.insert (it shouldn''t ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', NULL, '2026-06-12 20:03:16', '2026-06-12 20:03:16', '2026-06-12 20:03:16', NULL, '7171f5ba9998c641743b8a0341f32364', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'description', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', NULL, '2026-06-12 20:03:43', '2026-06-12 20:03:43', '2026-06-12 20:03:43', NULL, 'e6a5c7b4074b0e097a4fc5ee1d359ae6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'description', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, GmaxScrollExtent AND re-arm _atBottom follow-tail, gg0). Lists (tickets/git) trail in a follow-up commit. Navigation only no editing semantics outside the editor.
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability land/decide this first.
ACCEPTANCE CRITERIA:
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, ggoffset 0, G_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
OPEN QUESTIONS:
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard (b) only works because each pane owns its handler.
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', NULL, '2026-06-12 20:03:58', '2026-06-12 20:03:58', '2026-06-12 20:03:58', NULL, 'f7e4047127e864a5a20f1b3c5d7578a3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?', NULL, '2026-06-12 20:04:07', '2026-06-12 20:04:07', '2026-06-12 20:04:07', NULL, '916ff113755b114298ca32762fa815b0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'description', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.
--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404407) ---
SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w , g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift and it must be built ONCE, in one place, not three times.
RECOMMENDED SEQUENCING:
1. T-406 (the structural child) FIRST it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability.
2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path land it anytime for immediate value across every preset.
3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists.
4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet T-407 must add one).
All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) coordinate the shared-prefix matcher tests.', NULL, '2026-06-12 20:05:16', '2026-06-12 20:05:16', '2026-06-12 20:05:16', NULL, 'a7ce9c91b076f4f1ba94691d8cbcd631', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)', NULL, '2026-06-13 11:39:41', '2026-06-13 11:39:41', '2026-06-13 11:39:41', NULL, '658b0d19c0265ea4d753c1d8e9d52dcf', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', NULL, '2026-06-13 11:41:23', '2026-06-13 11:41:23', '2026-06-13 11:41:23', NULL, '526f24177bfa0f09ca59c571d9e84705', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'status', 'backlog', 'in_progress', NULL, '2026-06-13 11:42:26', '2026-06-13 11:42:26', '2026-06-13 11:42:26', NULL, 'a4b28821b462cd1fadab05c4e159b66b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'status', 'ready', 'in_progress', NULL, '2026-06-13 11:45:19', '2026-06-13 11:45:19', '2026-06-13 11:45:19', NULL, '7467127de4d8f29ed0e76b8a01489af9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:21', '2026-06-13 11:47:21', '2026-06-13 11:47:21', NULL, 'dd46fa777ff5fbb42396fe4037288165', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:26', '2026-06-13 11:47:26', '2026-06-13 11:47:26', NULL, 'fc555a3966e167d5273a8c8338214070', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:31', '2026-06-13 11:47:31', '2026-06-13 11:47:31', NULL, '56abf6590754059d88570072056c5e31', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'status', 'in_progress', 'done', NULL, '2026-06-13 12:45:43', '2026-06-13 12:45:43', '2026-06-13 12:45:43', NULL, 'e540413389a0c213369d83a3a75f9706', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'description', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)
--- PROGRESS (2026-06-13) ---
PART 1 DONE (commit on main): workspace.tab.next / workspace.tab.previous cycle commands with wraparound + ctrl+pagedown/ctrl+pageup defaultBindings across every preset. Tests in test/builtin/default_layout/widget_test.dart.
PART 2 (gt/gT) STILL OPEN and harder than the coordination note assumed. T-404 landed a global multi-chord matcher (root_shell), BUT it deliberately only STARTS a sequence on a MODIFIED chord (ctrl+w). Bare-key prefixes (g) are left editor/pane-local on purpose otherwise the global matcher would steal `g` before the editor sees it, breaking gg/dd. So gt/gT (bare g) CANNOT just ride the global matcher. Options for part 2: (a) the editor/pane matchers grow gt/gT and dispatch command:workspace.tab.* (but then gt only works when the editor/a pane is focused, not globally); (b) a focus-agnostic bare-g disambiguation (g g = local docStart vs g t = global tab) needs the global matcher to tentatively grab bare g AND coordinate with the editor''s matcher, which is the exact conflict T-404 avoided. Decide before building. The ctrl+pagedown/up commands already give every preset tab-cycling; gt/gT is a vim-affordance nicety on top.', NULL, '2026-06-13 16:38:16', '2026-06-13 16:38:16', '2026-06-13 16:38:16', NULL, '98c4bb26a0ac412c3c3216699555ab16', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'status', 'ready', 'done', NULL, '2026-06-13 19:55:21', '2026-06-13 19:55:21', '2026-06-13 19:55:21', NULL, '359e523cbe3e5019b291b91a92d476ba', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'description', NULL, '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).', NULL, '2026-06-14 15:29:27', '2026-06-14 15:29:27', '2026-06-14 15:29:27', NULL, 'c0706f9ad119d6c1a6efc2c36315ecf0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'description', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `` glyph) sometimes displays the branch of a *different* open clide workspace/window it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).
---
**Repro details confirmed (user, 2026-06-14):**
- The two windows were on *different repos* (distinct workspace roots distinct hashed sockets per D-70; rules out same-socket collision).
- The second window was opened from the **File menu at the top**, not from an integrated terminal.
**Refined root-cause analysis (this changes the leading hypothesis).**
The File menu has two distinct paths (`lib/builtin/menubar/src/file_actions.dart`):
- `openFolder()`/`openPath()` (l.23-63) `services.project.open()` = *in-place* switch, same process (the T-269/T-367 class). Produces ONE window, so not this report.
- `newWindow()` (l.30-32) `Process.start(Platform.resolvedExecutable, const [], mode: ProcessStartMode.detached)` = a genuinely **separate detached process**. This matches the "parallel windows" symptom.
Two facts narrow it:
1. `CLIDE_SOCK`/`CLIDE_WORKSPACE` are NOT set in clide''s own process environment they are a delta overlaid only on spawned Claude/PTY *child* processes (`lib/builtin/claude/src/agent_bootstrap.dart:57-71`, "Process.start keeps the parent environment by default, so this returns only the keys to add/override"). So a clean dock-launched window has no CLIDE_SOCK to leak.
2. `newWindow()` passes **no `environment:` override**, so the detached child inherits the parent clide process''s full environment verbatim.
**Leading hypothesis now:** environment inheritance through `newWindow()` when clide is self-hosted. If window 1 was itself launched from a clide-hosted terminal or as a clide agent, window 1''s process env already carries *that host''s* `CLIDE_SOCK`/`CLIDE_WORKSPACE`. `newWindow()` then spawns window 2 inheriting those vars so any code in window 2 that resolves its IPC endpoint (or shells out to the `clide` CLI, which keys off `CLIDE_SOCK`) can bind to the wrong workspace''s server and surface its branch. This is consistent with: different repos, opened from the File menu, intermittent.
**Caveat / not yet pinned:** the in-app status widget reportedly resolves IPC via the computed `workspaceSocketPath(root)` (`lib/main.dart:357`), NOT via `CLIDE_SOCK` so if that holds, inherited CLIDE_SOCK alone shouldn''t mislead the *in-process* status bar. The exact cross-process channel therefore still needs live confirmation. Do NOT assume; instrument.
**First diagnostic step for the fixer:**
1. Reproduce: open window 1, then File New Window, then open a *different* repo in window 2.
2. Log, in each window at branch-fetch time: the resolved socket path the status client connected to, `Platform.environment[''CLIDE_SOCK'']`, `Platform.environment[''CLIDE_WORKSPACE'']`, and `kernel.project.root`. The window showing the wrong branch will reveal whether it (a) connected to the other window''s socket, (b) read a stale/ambient env var, or (c) received a cross-process event it shouldn''t have.
**Hardening regardless of outcome:** `newWindow()` should spawn the child with an explicit, scrubbed environment strip `CLIDE_SOCK`/`CLIDE_WORKSPACE` (and not rely on inheriting them) so a fresh window always computes its own per-root socket from its own workspace. A new window must never inherit another workspace''s IPC identity.', NULL, '2026-06-14 15:41:41', '2026-06-14 15:41:41', '2026-06-14 15:41:41', NULL, 'd69c8cc3a9a120dc3e91f074921bfe7b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDKX4CVHWVGDAJC6X09602M', 'description', NULL, 'Tracks the architectural unification behind Q-51: replace the scattered, per-entry-point workspace-open logic with a single fenced primitive.
**The problem.** There is no "open workspace X" primitive only two half-primitives in different layers:
- `project.open(root)` (`lib/kernel/src/project.dart:143`) the only repo-targeting path, intrinsically *in-place*: rebuilds services in the same process reusing the shared `daemonBus` (`lib/main.dart:372-376`).
- `newWindow()` (`lib/builtin/menubar/src/file_actions.dart:30-32`) a blank detached `Process.start` with no repo argument and no env scrubbing.
To open a repo in a new window you spawn a blank window and then run the in-place switch inside it. Every fencing bug to date is a spot where one path forgets what the other remembers.
**Symptoms already filed (same root):** T-421 (status-bar branch bleeds across parallel windows), T-367 (in-place switch leaked the previous service set closed), T-269 (kept the previous repo''s Claude session closed).
**Target invariant.** `workspace root socket bus session-id`, one-to-one. Exactly one place derives IPC identity from a root. Every entry point (File menu, project switcher, `clide://` deep link, CLI, recents) routes through `WorkspaceService.open(root, {target: thisWindow | newWindow})`. New-window spawns `Process.start(exe, [''--workspace'', root], environment: <scrubbed>)` — explicit root, no inherited `CLIDE_SOCK`/`CLIDE_WORKSPACE`.
**Open decision (Q-51):** whether in-place switching survives at all, or whether a workspace is always its own window/process. If abolished, the teardown burden that T-367/T-269 patch disappears.
**Acceptance:** Q-51 resolved with a D-record fixing the in-place-vs-window stance; a single workspace-open primitive in place; all entry points routed through it; T-421 no longer reproducible; a regression test that a checkout in one workspace cannot change another''s displayed branch.
See Q-51 (governance/questions/architecture.md), D-70, D-56, D-72.', NULL, '2026-06-14 15:47:08', '2026-06-14 15:47:08', '2026-06-14 15:47:08', NULL, 'dded9e79fec698c14986e50f4b642e1e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'parent_id', NULL, 'T-422', NULL, '2026-06-14 15:47:10', '2026-06-14 15:47:10', '2026-06-14 15:47:10', NULL, '331b502dff0de7123762ab8efbf1b488', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'description', NULL, 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.', NULL, '2026-06-14 18:15:41', '2026-06-14 18:15:41', '2026-06-14 18:15:41', NULL, 'f22e1791da77972912976b1ce27cd0be', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'description', NULL, 'From the Windows test-freeze analysis (2026-06-14): the freeze left NO evidence because every log sink is volatile — stderrSink dies with the console and the in-RAM LogRing dies with the power-cycle. clide already has the logger scaffolding (lib/kernel/src/log.dart: Logger, LogLevel{trace..error}, pluggable LogSink; lib/kernel/src/log_ring.dart; the output dock + Level chip), so this epic does not add a framework — it bolts on a crash-survivable sink, FFI breadcrumbs, a watchdog, and the dev/prod verbosity toggle so the NEXT freeze (Windows or otherwise) leaves on-disk evidence that names the wedged call.
Child work (each filed as a task under this epic):
1. FileLogSink synchronous-fsync JSON-lines sink to %LOCALAPPDATA%\clide\logs (reuse ipc/paths.dart socket-dir helper); tiered flush (warn/error + any pty/ffi record flush immediately, info/debug batch on a timer); first sink in the chain so a crash cannot lose the tail; size-capped with rotation.
2. FFI breadcrumbs in windows_pty.dart inject a no-op-by-default log callback; emit BEFORE/AFTER every risky Win32 call with the return value + GetLastError read immediately; the reader/waiter SPAWNED isolates each open their OWN append handle to the log file and flushSync per breadcrumb, so the wedged isolate''s last line survives a frozen main isolate.
3. Watchdog heartbeat + resource sampler a DEDICATED isolate (NOT a main-isolate Timer, which would freeze with it) appending+fsyncing a heartbeat every ~500ms and sampling live ConPTY child count / process handle count / thread count / memory load every ~2s. A monotonically climbing child count is the leak signature; the last heartbeat bounds the freeze window to ~500ms.
4. Dev/prod verbosity toggle (the requested switch) resolve Logger.minLevel once at boot: CLIDE_LOG dart-define -> CLIDE_LOG env var -> settings.json log.level -> default warn (release) / info (debug). Level also gates FileLogSink flush-eagerness (debug = lose nothing in a repro). Live changes via a /loglevel command + `clide log level <level>` CLI (D-6 parity); the output-dock Level chip is the in-UI affordance.
5. Wire into the testmode harness + ci/test.sh attach FileLogSink in lib/test_app.dart with per-test start/end breadcrumbs; export CLIDE_LOG=debug and a log dir OUTSIDE the build tree in ci/test.sh; upload that dir as a CI artifact in an always() step so a CI freeze leaves evidence.
Verification kit for the leak this telemetry is meant to catch: tools/windows-verify/.', NULL, '2026-06-14 18:15:42', '2026-06-14 18:15:42', '2026-06-14 18:15:42', NULL, '2add8cbe3d2e0255d15010aa9fd8527b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'description', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).', NULL, '2026-06-14 18:55:21', '2026-06-14 18:55:21', '2026-06-14 18:55:21', NULL, 'db48d5823487e2c93ecc0b1c1dd8ce25', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCGJ30V24BJB001GZCR5QKTC', 'description', NULL, 'From the PTY testability audit (2026-06-14), prompted by the question "is any of the Windows FFI code testable on a pure I/O basis without Windows, and vice-versa?" Answer: yes on both backends, but the pure fragments are entangled with the syscall layer and need extraction before they can be unit-tested. Each claim below was adversarially verified (default-reject; rejected the rest of the proposed fragments because their output IS a syscall return, e.g. GetLastError, WriteFile byte count).
Context: lib/src/pty/windows_pty.dart wraps its FFI span in `// coverage:ignore-start/end` (Linux runner has no kernel32; the syscall sites are genuinely uncoverable off-Windows, and the bindings resolve through one DynamicLibrary.open so a method touching a binding can''t be entered on Linux). That exclusion is correct for the gate, but it hides a few pure transforms at file granularity. native_pty.dart (POSIX) has the mirror problem: it runs on Linux at ~78.9% but its pure marshalling is only covered incidentally by real spawns, never unit-tested.
## Windows (windows_pty.dart) extract + unit-test on Linux
Confirmed pure (verifier-approved), currently untested:
- `_Coord` struct packing (69-74) two clamped int16s into COORD; allocate via calloc, set x/y, read back.
- `_StartupInfoExW` field assembly in start() (358-361) cb / dwFlags=STARTF_USESTDHANDLES / lpAttributeList; deterministic field writes over calloc-zeroed memory.
- `write()` empty/length guard (497-498) returns 0 when `_dead` or `bytes.isEmpty`, before any WriteFile.
Plan: pull the COORD/STARTUPINFOEXW packing into free functions (e.g. `packCoord(cols, rows)`, `buildStartupInfoEx(attrList)`) that take/return plain values and don''t reference the kernel32 bindings; assert field layout in a Linux unit test. Keep the empty-guard logic in a tiny pure predicate.
## POSIX (native_pty.dart) extract + unit-test directly (closes part of the 21% gap, adds gate margin)
Confirmed pure (verifier-approved), currently only covered incidentally by integration spawns:
- argv marshalling (222-228) String list -> native UTF8 pointer array + null terminator.
- envp marshalling (230-235) Map<String,String> -> native ''KEY=VALUE'' UTF8 array.
- write() buffer copy (406-408) bytes[i] -> buf[i].
- resize() Winsize init + clamp (432-435) cols/rows -> ws.wsCol/wsRow (clamp already tested in pty_size_test).
Plan: extract marshalling into free helpers returning the pointer structures (inject the allocator so a test can read them back and free them); unit-test the round-trip and null-termination off any real spawn.
## NOT in scope (genuinely host-bound leave excluded/uncovered)
All the raw syscalls and anything whose output is a syscall return or that has no injection seam: CreatePipe / CreatePseudoConsole / CreateProcessW / ReadFile / WaitForSingleObject / WriteFile / ResizePseudoConsole / TerminateProcess / CloseHandle / GetLastError; the attribute-list APIs; the read/wait isolate bodies; and on POSIX the openpt/grantpt/unlockpt/ptsname + posix_spawn failure paths, EINTR/EBADF/EPIPE handling, and reader-isolate EOF reaping (~32 lines that need real OS error/timing state).
## Acceptance
- New Linux unit tests for the fragments above (both backends).
- windows_pty.dart `coverage:ignore` span narrowed to only the syscall sites (struct-packing helpers move out and are measured).
- Coverage floor holds (or ratchets up from the added native_pty coverage).
## Related / separate finding (file or fold as decided)
windows.yml runs the real ConPTY suite (start/write/resize/kill/errors) on windows-latest but collects NO coverage (no --coverage flag). So the FFI spawn path has functional validation on Windows + the VM soak (tools/windows-verify/) but no line-coverage metric anywhere. Decide whether to (a) accept functional-only validation explicitly, or (b) collect coverage on the Windows runner and merge it so the FFI path is measured. Cross-platform lcov merge is non-trivial (the gate reads one file) — may warrant a Q-record.
Audit detail: full per-fragment findings + adversarial verdicts in the workflow result for run wf_a3cacb2c-2c7.', NULL, '2026-06-14 22:38:01', '2026-06-14 22:38:01', '2026-06-14 22:38:01', NULL, 'd8cafbc05c3a83c1041c29ba25e4ed83', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'description', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost it lingers until the PARENT process exits (microsoft/terminal#4050) and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).
## Soak results on GitHub windows-latest (Server 2022) orphan-accumulation NOT reproduced (2026-06-14)
Ran both halves of the windows-verify soak on GitHub-hosted Windows (no VM needed windows-latest runs the ConPTY suite green, so the soak just wraps it):
1. Clean-path soak (soak-conpty.ps1, 25 iters): orphans stayed at 0, dart handles flat ~152, threads flat at 7. Orderly close() reaps everything. NOT REPRODUCED.
2. Abrupt-death probe (soak-conpty-kill.ps1 + conpty_orphan_probe.dart, 15 iters x 2 PTYs): start real WindowsPty sessions on long-lived children, block WITHOUT close(), then taskkill /F the parent dart.exe (no /T). Every cycle reaped to baseline survivors=0, cum=0. When the parent dies the OS breaks the pipes and conhost exits on its own. NOT REPRODUCED.
**Implication:** the conhost-orphan-accumulation mechanism this ticket is premised on does NOT hold on Server 2022, under clean OR abrupt teardown. The Job Object fix may still be worthwhile as defense-in-depth, but its justification (a reproduced leak) is not confirmed.
**Caveats / what''s still untested:**
- OS mismatch: the real crashes were on desktop Win10/11; this is headless Server 2022. terminal#4050 was a desktop report. A desktop-specific behavior may be unreproducible on CI.
- Both probes let the process DIE, so within-process accumulation (culprit #2: reader isolates blocked forever in ReadFile, threads/handles climbing within one long-lived process) is reclaimed at exit and never measured. A long-lived-process probe (one dart.exe spawning + abandoning PTYs, watching its OWN handle/thread count climb) would test that the more likely freeze mode for a long-running app. Not yet built.
Diagnostics live in tools/windows-verify/ and run via .github/workflows/windows-soak.yml (workflow_dispatch). The same kill-probe will validate the fix if/when it lands (survivors should stay 0 though they already do, which is the problem).', NULL, '2026-06-15 07:11:41', '2026-06-15 07:11:41', '2026-06-15 07:11:41', NULL, 'dadb5e43a8a9b44b2c4be4b7d4528beb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 07:19:13', '2026-06-15 07:19:13', '2026-06-15 07:19:13', NULL, '55a53ab84e2a10fd52fe6853bd32dc55', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'status', 'in_progress', 'done', NULL, '2026-06-15 07:29:45', '2026-06-15 07:29:45', '2026-06-15 07:29:45', NULL, 'ab3371153c8037fe287c07c4f34d1575', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'status', 'backlog', 'done', NULL, '2026-06-15 07:56:56', '2026-06-15 07:56:56', '2026-06-15 07:56:56', NULL, 'ff37484d4920968d13bdafa85336cd66', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'status', 'backlog', 'done', NULL, '2026-06-15 08:15:52', '2026-06-15 08:15:52', '2026-06-15 08:15:52', NULL, '59248f388c7cc5fd834eb1b384b6cab1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 08:49:49', '2026-06-15 08:49:49', '2026-06-15 08:49:49', NULL, '23fbf6cd85b51eebea4a6f503399df7e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'status', 'in_progress', 'done', NULL, '2026-06-15 08:58:55', '2026-06-15 08:58:55', '2026-06-15 08:58:55', NULL, '6d8fd42bc14bd668553da75acf9a2b83', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9F446MZFXVHH65Q6CKTPM', 'status', 'backlog', 'done', NULL, '2026-06-15 10:33:01', '2026-06-15 10:33:01', '2026-06-15 10:33:01', NULL, '15ad99803d60c7fbfd396f684fa3f7fe', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'description', NULL, 'Duplicate of the T-425 breakdown — I re-filed this as T-432 (FileLogSink) and implemented + closed that. Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:06', '2026-06-15 10:34:06', '2026-06-15 10:34:06', NULL, '5815d71c306bd9d0010964be15b1437d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'description', NULL, 'Duplicate — re-filed + implemented + closed as T-434 (FFI breadcrumbs). Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:12', '2026-06-15 10:34:12', '2026-06-15 10:34:12', NULL, 'a0609a97452cb0c43377d8b9afd233ef', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'description', NULL, 'Duplicate — re-filed + implemented + closed as T-435 (watchdog). Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:16', '2026-06-15 10:34:16', '2026-06-15 10:34:16', NULL, '328d3c9842ab73ea9404361c874a7973', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'description', NULL, 'Duplicate — re-filed + implemented + closed as T-433 (verbosity toggle: dock chip + clide log level CLI). Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:20', '2026-06-15 10:34:20', '2026-06-15 10:34:20', NULL, 'ce21cfebab0ff7680e396553aa1b49a9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'description', NULL, 'Duplicate — re-filed + implemented + closed as T-436 (CI crash-evidence artifacts). Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:25', '2026-06-15 10:34:25', '2026-06-15 10:34:25', NULL, '1b7ec591f720837b8cbc709e2beb1823', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, '27bae65c0f89d9f5b996b0872c2c3454', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, '4d001b92a3c33be245bcd3fa3ed89eef', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, '8940e515fde31e83ef1a551997a80be0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'cce7ddffbd88447be319407f9cc7ce91', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'f9a33140c03031c10ca45fecb2edb277', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'status', 'backlog', 'done', NULL, '2026-06-15 10:34:31', '2026-06-15 10:34:31', '2026-06-15 10:34:31', NULL, 'b9cda82863c37a365e7aa5fc8230256a', 2) ON CONFLICT(hash) DO NOTHING;
+29
View File
@@ -232,3 +232,32 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'T-405', '2026-06-12 03:21:31', '2026-06-12 03:21:31', NULL, 'e4e1695f838b8fbf02aae49a6f2df4fe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'T-405', '2026-06-12 03:21:31', '2026-06-12 03:21:31', NULL, 'e4e1695f838b8fbf02aae49a6f2df4fe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'T-406', '2026-06-12 03:21:49', '2026-06-12 03:21:49', NULL, '689352238d2050a3769b2a9613f0a793', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'T-406', '2026-06-12 03:21:49', '2026-06-12 03:21:49', NULL, '689352238d2050a3769b2a9613f0a793', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'T-407', '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '129d2b3c31022d53025a3e28169a060e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'T-407', '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '129d2b3c31022d53025a3e28169a060e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VTK2MYQQ173MSJN6E1DM', 'T-408', '2026-06-12 06:40:25', '2026-06-12 06:40:25', NULL, '0353aaab57a40900b00883fb12e135f7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VYR84023Z5XFEX9DS0S0', 'T-409', '2026-06-12 06:40:26', '2026-06-12 06:40:26', NULL, '69c280319335a8d0ef646998f4531e96', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'T-410', '2026-06-12 08:58:29', '2026-06-12 08:58:29', NULL, '4f726d1a66d38d14018a62c8d24ffe62', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'T-411', '2026-06-12 08:58:42', '2026-06-12 08:58:42', NULL, 'bdd3f8b13caf25677ac661ec29599488', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'T-412', '2026-06-12 08:58:55', '2026-06-12 08:58:55', NULL, 'e56d50bc6fc04f6d646f22c104e63183', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3KRWM65MD3DS251NN9YX0', 'T-413', '2026-06-12 08:59:08', '2026-06-12 08:59:08', NULL, 'b5247ea05c106500682be978a47e61ee', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P8YERJ5R7ENSD675BX00', 'T-414', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, '7d973f16c99441dbba0f8df89a665b32', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'T-415', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, 'a2dea9268d44b9e8a1fd746bbb947c92', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'T-416', '2026-06-12 10:25:00', '2026-06-12 10:25:00', NULL, 'f8c2a125e661607d5dd0c73cd2c3f2ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ4BYD4STCKCY8JNKF23Q4W', 'T-417', '2026-06-12 11:22:15', '2026-06-12 11:22:15', NULL, '15aa9b25417162126cbcde174d3537da', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'T-418', '2026-06-12 11:26:14', '2026-06-12 11:26:14', NULL, '000e07ae64b08273a2d2d9f8a77d193f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBTTMGKSYMTF8M1KQWTG774W', 'T-419', '2026-06-12 19:58:58', '2026-06-12 19:58:58', NULL, 'd2b01a2c3d1ce24cc863ac6d9d814d3d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FC2XY1T85A65YY9SG25VVEY4', 'T-420', '2026-06-13 14:51:51', '2026-06-13 14:51:51', NULL, '16ea4c9353a56798b894ab3d85fb7b56', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'T-421', '2026-06-14 15:29:23', '2026-06-14 15:29:23', NULL, 'a28eed4b57104034c5216344a326195c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDKX4CVHWVGDAJC6X09602M', 'T-422', '2026-06-14 15:45:57', '2026-06-14 15:45:57', NULL, '5701c634f5737a2ba1612deab8df7049', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDM61KAA3GV3CVTE8PAZ8N0', 'T-423', '2026-06-14 15:47:10', '2026-06-14 15:47:10', NULL, 'ce7dfb8b9bd088b4c2e8ddfacc8d2124', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'T-424', '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'ecbed75dcd34c39bcbd86e60d6f4a2c2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'T-425', '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'b09fc55465f7de02cf98f69c99e0e6e3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'T-426', '2026-06-14 18:15:42', '2026-06-14 18:15:42', NULL, '91f50c6e38332047f8619db428d4b376', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'T-427', '2026-06-14 18:15:43', '2026-06-14 18:15:43', NULL, '55a937def177025ef6b61a222d88b142', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'T-428', '2026-06-14 18:15:44', '2026-06-14 18:15:44', NULL, '185e7d3ce8529fbe1f4543f4c635f200', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'T-429', '2026-06-14 18:15:45', '2026-06-14 18:15:45', NULL, 'ef268614a716384a7597b3e304a6c176', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'T-430', '2026-06-14 18:15:46', '2026-06-14 18:15:46', NULL, 'd6333df4da5b7ab8958149a9f0d17974', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCGJ30V24BJB001GZCR5QKTC', 'T-431', '2026-06-14 22:37:27', '2026-06-14 22:37:27', NULL, 'b886820e87f5abd329126bf5e9f1a3da', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'T-432', '2026-06-15 07:18:58', '2026-06-15 07:18:58', NULL, '71f3e4c95f66abc5e7e5820548201e41', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9F446MZFXVHH65Q6CKTPM', 'T-433', '2026-06-15 07:19:01', '2026-06-15 07:19:01', NULL, 'b9a361f2c29b286bc808dbea57a05a7e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'T-434', '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, '403c4c8aa5659bb379cb8add9dff800b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'T-435', '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, '7c2ed604aecea99b742b341166cf2257', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+2
View File
@@ -0,0 +1,2 @@
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);
File diff suppressed because it is too large Load Diff
+100
View File
@@ -16,6 +16,106 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased] ## [Unreleased]
### Added
- **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)
## [2.5.0] — 2026-06-14
### Added
- **Experimental Windows desktop support.** clide builds and runs on Windows —
ConPTY-backed terminals, an AF_UNIX `clide` CLI client, PowerShell as the
default shell, and a `make build-windows` target. Preview quality: ConPTY
child-process reaping under sustained use is still being hardened. (T-424)
- **Vim `ctrl+w` window commands.** Under the vim preset, `ctrl+w` followed by
h/l (focus left/right panel), j (toggle dock), w / ctrl+w (cycle panels),
shift+w (cycle back), o (focus mode), or q/c (close editor). A new global
multi-chord matcher in the shell resolves these from any focus; bare `ctrl+w`
still closes the editor after the ambiguity timeout. (T-404)
- **Workspace tab cycling with ctrl+pagedown / ctrl+pageup.** New
`workspace.tab.next` / `workspace.tab.previous` commands cycle the workspace
tab strip with wraparound, bound across every preset. (T-405)
- **Vim normal-mode navigation works outside the editor.** Under the vim preset,
a focused file tree or conversation now responds to j/k, ctrl+d/ctrl+u, gg/G,
and (tree) h/l/o — a selection cursor in the tree, scrolling in the
conversation. Each pane runs its own sequence matcher; an `editor.focused`
flag keeps these keys as buffer motions while the editor holds focus. (T-406)
- **Claude Code Workflow runs surface in the conversation and sidebar.** A
`Workflow` tool-use renders a dedicated run card — phase groups, per-agent
rows with live spinner/check status, usage, and the script — driven by the
harness's out-of-band progress events. The Activity tab adds a WORKFLOWS
section showing each run's done/total agent count. (T-416)
- **Session controls and live usage in the Claude sidebar Activity tab.** A
SESSION strip offers clear/compact/fork/resume buttons (same code path as the
typed commands), and a refresh control fetches `/usage` — plan usage renders
as a USAGE block (session and weekly percentages). The runtime row now also
shows the session's effort level. (T-415)
- **The Claude sidebar Config tab is a live control panel.** Model, effort, and
permission mode are popover controls showing the running session's values;
picking an option drives the session through the same path as the typed slash
command. The sidebar tables also got a visual pass — larger type, accent
section headers, more breathing room. (T-414)
- **The TUI command family opens clide surfaces.** `/permissions` sets the mode
directly or opens a picker; `/status`, `/config`, `/mcp`, `/agents`, `/hooks`
jump to the matching Claude sidebar tab; `/memory` opens CLAUDE.md in the
editor; `/help` shows clide's own command summary. (T-413)
- **`/effort` works in the Claude pane.** With a level (`/effort xhigh`) the
session restarts in place carrying `--effort` — resume keeps the
conversation; bare `/effort` opens a picker with the five levels and the
current one marked. The active effort shows in the session status. (T-412)
- **TUI-only slash commands get a helpful notice instead of failing.** A typed
`/cost` or `/doctor` no longer errors raw from the CLI or leaks to the model
as literal text — known TUI-only commands route to a muted notice card with
the clide-native way. CLI-local output (like `/usage`) renders as a "clide"
card, never fake Claude prose. (T-411)
- **`/model` works in the Claude pane.** With a name (`/model sonnet`) it
switches the live session's model over the control channel; bare `/model`
opens a picker in the interaction zone with the CLI's model list and the
current model marked. A rejected name rolls back and raises a toast. (T-408)
### Removed
- **tmux is no longer a required tool.** clide stopped spawning tmux when Claude
session persistence moved to `--resume` (D-77); the toolchain no longer probes
for it or warns when it's absent, on any platform.
### Fixed
- **`ClaudeConfig` no longer crashes on a project switch that races teardown.**
`setProjectDir` / `refresh` / `ensureProbe` now skip `notifyListeners()` if the
config was disposed during their async load (the guard `load()` already had).
## [2.4.1] — 2026-06-12
### Fixed
- **`Shift+;` types a colon again — double-Shift no longer fires on chorded
Shift.** The double-tap detector counted any Shift press as a tap, even
mid-chord, and never saw keys the focused editor consumed; a tap now
requires a clean press-and-release, observed at the raw-keyboard level.
(T-409)
## [2.4.0] — 2026-06-12 ## [2.4.0] — 2026-06-12
### Added ### Added
+1 -1
View File
@@ -9,7 +9,7 @@ An IDE for Claude Code CLI. Single Flutter package at the repo root.
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly. - **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly.
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it. - **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages. Claude session persistence is `--resume <session-id>` against Claude Code's transcript files (D-77, superseding the original tmux-backed D-41) — the app re-attaches on restart, no tmux required. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`governance/`](governance/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`governance/README.md`](governance/README.md)). Python Textual predecessor under [`legacy/`](legacy/). Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`governance/`](governance/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`governance/README.md`](governance/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
+17 -2
View File
@@ -148,7 +148,7 @@ changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bulle
ci/changelog_gate.sh ci/changelog_gate.sh
.PHONY: smoke-bundle .PHONY: smoke-bundle
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s. smoke-bundle: gen-build-info ## Build Linux release bundle and run it under xvfb for 5s.
ci/smoke_bundle.sh ci/smoke_bundle.sh
# -- web UI harness ------------------------------------------------------ # -- web UI harness ------------------------------------------------------
@@ -182,6 +182,10 @@ build-linux: gen-build-info ## flutter build linux (desktop bundle).
build-macos: gen-build-info ## flutter build macos (desktop bundle). build-macos: gen-build-info ## flutter build macos (desktop bundle).
flutter build macos flutter build macos
.PHONY: build-windows
build-windows: gen-build-info ## flutter build windows (desktop bundle).
flutter build windows
# -- install / uninstall ----------------------------------------------------- # -- install / uninstall -----------------------------------------------------
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a # Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
@@ -196,6 +200,9 @@ ifeq ($(FLUTTER_OS),linux)
else ifeq ($(FLUTTER_OS),macos) else ifeq ($(FLUTTER_OS),macos)
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli
else ifeq ($(FLUTTER_OS),windows)
BUNDLE_DIR := build/windows/x64/runner/Release
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/clide-cli.exe
endif endif
ICON_SIZES := 16 32 48 128 192 256 512 ICON_SIZES := 16 32 48 128 192 256 512
@@ -291,7 +298,11 @@ dugite-clean: ## Remove the dugite-native directory.
# target picks up whatever `cc` is on PATH. # target picks up whatever `cc` is on PATH.
CLIDE_CLI_SRC := native/clide-cli/clide.c CLIDE_CLI_SRC := native/clide-cli/clide.c
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide ifeq ($(FLUTTER_OS),windows)
CLIDE_CLI_BIN := native/windows-x64/clide.exe
else
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
endif
CC ?= cc CC ?= cc
.PHONY: clide-cli .PHONY: clide-cli
@@ -299,7 +310,11 @@ clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC) $(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
@mkdir -p $(dir $(CLIDE_CLI_BIN)) @mkdir -p $(dir $(CLIDE_CLI_BIN))
ifeq ($(FLUTTER_OS),windows)
ci/build_cli_windows.sh
else
$(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC) $(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC)
endif
@echo "==> built $(CLIDE_CLI_BIN)" @echo "==> built $(CLIDE_CLI_BIN)"
.PHONY: clide-cli-clean .PHONY: clide-cli-clean
+65
View File
@@ -63,6 +63,71 @@ bindings:
- intent: text.scaleReset - intent: text.scaleReset
keys: [ctrl+0, meta+0] keys: [ctrl+0, meta+0]
# ---- Pane navigation (non-editor panes) ------------------------------
# When a non-editor pane holds focus (file tree, conversation, lists), the
# same motion keys mean NAVIGATION, not buffer edits (T-406). The
# `!editor.focused` guard keeps these out of the editor's way; the editor
# publishes `editor.focused` while it has focus. These MUST precede the
# editor motions below — the resolver takes the first matching binding in
# file order, so with a pane focused (editor.focused false) nav wins, and
# with the editor focused the `!editor.focused` clause fails and the buffer
# motion below wins. Each pane runs its own SequenceMatcher (PaneKeyNav).
- intent: nav.down
keys: j
when: "vim.normal && !editor.focused"
- intent: nav.up
keys: k
when: "vim.normal && !editor.focused"
- intent: nav.pageDown
keys: ctrl+d
when: "vim.normal && !editor.focused"
- intent: nav.pageUp
keys: ctrl+u
when: "vim.normal && !editor.focused"
- intent: nav.top
keys: "g g" # gg
when: "vim.normal && !editor.focused"
- intent: nav.bottom
keys: shift+g # G
when: "vim.normal && !editor.focused"
- intent: nav.expandOrRight
keys: l
when: "vim.normal && !editor.focused"
- intent: nav.collapseOrLeft
keys: h
when: "vim.normal && !editor.focused"
- intent: nav.activate
keys: [o, enter]
when: "vim.normal && !editor.focused"
# ---- ctrl+w window-command family (T-404) ----------------------------
# Multi-chord sequences resolved by the GLOBAL matcher (root_shell), so they
# work from any focus. Bare ctrl+w still closes the editor after the ambiguity
# timeout (the editor.close binding below / contributions layer). The 3-column
# clide layout approximates vim's window grid: h/l focus left/right panels,
# j toggles the dock, o is "only" (focus mode), q/c close the editor.
- intent: command:panel.focus.left
keys: ctrl+w h
when: "vim.normal || vim.visual"
- intent: command:panel.focus.right
keys: ctrl+w l
when: "vim.normal || vim.visual"
- intent: command:dock.toggle
keys: ctrl+w j
when: "vim.normal || vim.visual"
- intent: focus.nextPanel
keys: [ctrl+w w, ctrl+w ctrl+w]
when: "vim.normal || vim.visual"
- intent: focus.previousPanel
keys: ctrl+w shift+w # ctrl+w W
when: "vim.normal || vim.visual"
- intent: command:panel.focusMode
keys: ctrl+w o
when: "vim.normal || vim.visual"
- intent: command:editor.close
keys: [ctrl+w q, ctrl+w c]
when: "vim.normal || vim.visual"
# ---- Mode transitions ------------------------------------------------ # ---- Mode transitions ------------------------------------------------
- intent: command:vim.mode.visual - intent: command:vim.mode.visual
keys: v keys: v
+1 -1
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info` # Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump # (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead. # pubspec instead.
version: "2.4.0" version: "2.5.0"
homepage: https://github.com/postmeridiem/clide homepage: https://github.com/postmeridiem/clide
license: MIT license: MIT
license_file: assets/LICENSE license_file: assets/LICENSE
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# Build the C `clide` client with MSVC on Windows (Git Bash / MSYS).
# Wrapped by `make clide-cli` — don't run directly (see CLAUDE.md
# tooling discipline). Finds the VC++ toolset via vswhere, loads the
# x64 dev environment, compiles:
# native/clide-cli/clide.c -> native/windows-x64/clide.exe
# ws2_32.lib supplies winsock (AF_UNIX socket support).
set -e
VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
if [ ! -x "$VSWHERE" ]; then
echo "vswhere.exe not found — install Visual Studio (Build Tools) with the C++ workload" >&2
exit 1
fi
VSROOT=$("$VSWHERE" -products '*' -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | tr -d '\r')
if [ -z "$VSROOT" ]; then
echo "no Visual Studio C++ x64 toolset found (vswhere returned nothing)" >&2
exit 1
fi
mkdir -p native/windows-x64
# A generated .bat sidesteps the unwinnable sh->cmd quote escaping for
# the space-laden VS path. //c keeps MSYS from path-mangling cmd's /c
# switch; /Fo drops the .obj next to the .exe so the repo root stays
# clean.
BAT=$(mktemp --suffix=.bat)
trap 'rm -f "$BAT"' EXIT
cat > "$BAT" <<EOF
@call "$VSROOT\\Common7\\Tools\\VsDevCmd.bat" -arch=amd64 -no_logo
@cl /nologo /O2 /W4 /D_CRT_SECURE_NO_WARNINGS native\\clide-cli\\clide.c /Fonative\\windows-x64\\ /Fe:native\\windows-x64\\clide.exe ws2_32.lib
EOF
cmd.exe //c "$(cygpath -w "$BAT")"
rm -f native/windows-x64/clide.obj
+6 -1
View File
@@ -35,7 +35,12 @@ echo "==> dart test (pty — unreliable under the flutter test runner; serial)"
# --concurrency=1: these spawn real PTYs and compete for fds when run in # --concurrency=1: these spawn real PTYs and compete for fds when run in
# parallel, which flaked them (registry/session). Serialize — the proper fix # parallel, which flaked them (registry/session). Serialize — the proper fix
# for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193) # for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193)
dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart test/panes/registry_test.dart # windows_pty_test is the ConPTY sibling of session_test; each suite
# self-skips off-platform, so the union always contributes tests.
# --timeout 60s matches the flutter lines below: a wedged PTY test (e.g. a
# ConPTY reader blocked forever in ReadFile) fails fast instead of hanging the
# whole serial run.
dart test -r "$REPORTER" --concurrency=1 --timeout 60s --tags pty test/pty/session_test.dart test/panes/registry_test.dart test/pty/windows_pty_test.dart
# The parallel pool excludes both pty (runs under dart test, above) and # The parallel pool excludes both pty (runs under dart test, above) and
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1 # serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
+7 -1
View File
@@ -3,10 +3,16 @@
# start" regression gate. Flutter integration tests prefer one file at # start" regression gate. Flutter integration tests prefer one file at
# a time on desktop; we iterate to avoid the "Unable to start the app" # a time on desktop; we iterate to avoid the "Unable to start the app"
# error that hits when they run as a batch. # error that hits when they run as a batch.
#
# -d linux pins the desktop device explicitly: the GitHub ubuntu-latest
# runner exposes BOTH a linux desktop AND a chrome web device, so a bare
# `flutter test integration_test/...` aborts with "More than one device
# connected" before it ever compiles (the dev box / old Gitea runner only
# had the one device, so this was latent until CI moved to GitHub).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
for f in integration_test/*_test.dart; do for f in integration_test/*_test.dart; do
echo "==> integration_test: $f" echo "==> integration_test: $f"
flutter test "$f" flutter test -d linux "$f"
done done
+17
View File
@@ -0,0 +1,17 @@
# dartdoc configuration.
#
# clide is an application, not a published library — its lib/ API docs are not
# a consumed surface. The dart-doc CI gate (.github/workflows/test.yml) exists
# to catch real doc-comment defects: unresolved [symbol] references. That check
# stays strict (unresolved-doc-reference is NOT ignored).
#
# The "broken-link" category, by contrast, is pure rendering noise here:
# - The README is dartdoc's landing page; its repo-relative links
# ([CLAUDE.md], [docs/…], [LICENSE]) are correct on GitHub but don't resolve
# once copied into the generated API site.
# - The generated Phosphor icon font (lib/widgets/src/icons/phosphor_glyphs.g.dart,
# 1512 glyphs) produces broken cross-links to its own library page.
# Neither is a fixable doc-quality problem, so the category is ignored.
dartdoc:
ignore:
- broken-link
+1
View File
@@ -183,6 +183,7 @@ You might also want, project-permitting:
- [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_ - [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_
- [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_ - [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_ - [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
- [Q-51: Unify workspace lifecycle on a single fenced open primitive](questions/architecture.md#q-51-unify-workspace-lifecycle-on-a-single-fenced-open-primitive) — _architecture_
## Resolved questions ## Resolved questions
+6
View File
@@ -165,4 +165,10 @@ ticket persistence.
- **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked. - **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked.
- **Source:** T-384 / 2026-06-11 Fable review (epic T-359). - **Source:** T-384 / 2026-06-11 Fable review (epic T-359).
### Q-51: Unify workspace lifecycle on a single fenced open primitive
- **Status:** Open
- **Question:** There is no single "open workspace X" primitive — only two half-primitives in different layers. `project.open(root)` (`lib/kernel/src/project.dart`) is the only repo-targeting path and is intrinsically *in-place*: it rebuilds services in the same process, reusing the shared `daemonBus`. `newWindow()` (`lib/builtin/menubar/src/file_actions.dart`) spawns a blank detached `Process.start` with no repo argument and no env scrubbing. To open a repo in a *new* window you must spawn a blank window and then run the in-place switch inside it. Should both fold behind one `WorkspaceService.open(root, {target: thisWindow | newWindow})` that is the *sole* deriver of IPC identity from a root — and, more fundamentally, should in-place switching survive at all, or should `workspace ⇒ window ⇒ process ⇒ socket ⇒ bus ⇒ session-id` be strictly one-to-one so the leak/bleed class becomes structurally impossible?
- **Context:** Surfaced 2026-06-14 from [T-421](../../) (status-bar branch bleeds across parallel windows). The same root cause — scattered, per-entry-point workspace lifecycle with no single fencing owner — already produced T-367 (in-place switch leaked the entire previous service set) and T-269 (kept the previous repo's Claude session). If in-place switching is abolished, the teardown burden those tickets patch disappears entirely. Relevant decisions: [D-70](../decisions/architecture.md) (per-workspace socket path), [D-56](../decisions/architecture.md) (one server per workspace), [D-72](../decisions/architecture.md) (multi-connection serial dispatch).
- **Source:** T-421 / 2026-06-14 user review.
--- ---
+4 -1
View File
@@ -7,7 +7,7 @@
/// This file is pure (no Flutter): it turns a flat [ConversationItem] list /// This file is pure (no Flutter): it turns a flat [ConversationItem] list
/// into a list of [RenderGroup]s — each either a first-class [StickyItem] or /// into a list of [RenderGroup]s — each either a first-class [StickyItem] or
/// a foldable [FoldedCluster]. The widget layer renders sticky items as /// a foldable [FoldedCluster]. The widget layer renders sticky items as
/// before and clusters as one [activity card]. Kept separate + unit-tested /// before and clusters as one `activity card`. Kept separate + unit-tested
/// because the fold rules are the load-bearing part. /// because the fold rules are the load-bearing part.
library; library;
@@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
// breaks the cluster at every level, including L3, so parallel agents // breaks the cluster at every level, including L3, so parallel agents
// never merge into one Activity card. // never merge into one Activity card.
if (isAgentTool(name)) return false; if (isAgentTool(name)) return false;
// A Workflow run is a first-class orchestration card too (T-416): it owns
// the live agent fan-out, so it never folds into a generic Activity card.
if (name == 'Workflow') return false;
// The Edit/Write call stays first-class with its diff at L1/L2. // The Edit/Write call stays first-class with its diff at L1/L2.
if (level == FoldLevel.everything) return true; if (level == FoldLevel.everything) return true;
return !isDiffTool(name); return !isDiffTool(name);
+11 -23
View File
@@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
/// Returns a change stream for [dir] (fires on any file event under it). /// Returns a change stream for [dir] (fires on any file event under it).
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir); typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
/// Modest version-agnostic fallback used when the probe is unavailable, so /// Fallback used when the probe is unavailable. Mirrors the builtins a real
/// the typeahead still offers the common built-ins. /// CLI advertises in its stream-json `initialize` handshake (probed against
const List<String> kFallbackSlashCommands = [ /// 2.1.175) — i.e. the ones that genuinely work headless. It deliberately
'add-dir', /// does NOT list TUI-only commands (config, permissions, status, doctor, …):
'agents', /// this list doubles as the router's "advertised" set (T-411), and a TUI-only
'clear', /// token here would be forwarded to the CLI and error. The composer unions
'compact', /// [kClideOwnedCommands] on top for the typeahead (T-162).
'config', const List<String> kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage'];
'context',
'cost',
'doctor',
'exit',
'help',
'init',
'mcp',
'memory',
'model',
'permissions',
'resume',
'review',
'status',
'usage',
];
class ClaudeConfig extends ChangeNotifier { class ClaudeConfig extends ChangeNotifier {
ClaudeConfig({ ClaudeConfig({
@@ -272,6 +257,7 @@ class ClaudeConfig extends ChangeNotifier {
if (probe == null) return; // stay on the static fallback if (probe == null) return; // stay on the static fallback
_probe = probe; _probe = probe;
await _writeProbeCache(probe); await _writeProbeCache(probe);
if (_disposed) return; // a slow probe racing a teardown mustn't notify a disposed notifier
notifyListeners(); notifyListeners();
} finally { } finally {
_probing = false; _probing = false;
@@ -283,6 +269,7 @@ class ClaudeConfig extends ChangeNotifier {
/// not re-resolved (the binary doesn't change under us at runtime). /// not re-resolved (the binary doesn't change under us at runtime).
Future<void> refresh() async { Future<void> refresh() async {
await _loadDiskConfig(); await _loadDiskConfig();
if (_disposed) return; // a watcher-driven refresh racing a teardown mustn't notify a disposed notifier
notifyListeners(); notifyListeners();
} }
@@ -293,6 +280,7 @@ class ClaudeConfig extends ChangeNotifier {
_stopWatching(); _stopWatching();
_projectDir = dir; _projectDir = dir;
await _loadDiskConfig(); await _loadDiskConfig();
if (_disposed) return; // a project switch racing a teardown mustn't notify a disposed notifier
_startWatchers(); _startWatchers();
notifyListeners(); notifyListeners();
} }
@@ -36,8 +36,10 @@ import 'package:clide/builtin/claude/src/meta_sidebar/tab_strip.dart';
import 'package:clide/builtin/claude/src/meta_sidebar/team_tab.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/team_tab.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask; import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, parseUsageText;
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation; import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; import 'package:clide/builtin/claude/src/transcript_reader.dart' show AssistantTextMessage, ConversationItem, SessionStatus;
import 'package:clide/builtin/claude/src/workflow_run.dart' show WorkflowRun;
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -83,7 +85,12 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
StreamSubscription<TeamMemberJoined>? _joinSub; StreamSubscription<TeamMemberJoined>? _joinSub;
StreamSubscription<TeamMemberLeft>? _leftSub; StreamSubscription<TeamMemberLeft>? _leftSub;
StreamSubscription<Message>? _statusSub; StreamSubscription<Message>? _statusSub;
StreamSubscription<Message>? _tabSub;
StreamSubscription<SessionStatus>? _primarySub; StreamSubscription<SessionStatus>? _primarySub;
StreamSubscription<ConversationItem>? _primaryItemsSub;
StreamSubscription<Map<String, WorkflowRun>>? _primaryWorkflowsSub;
ClaudeUsage? _usage;
Map<String, WorkflowRun> _workflows = const {};
StreamSubscription<void>? _brokerChangeSub; StreamSubscription<void>? _brokerChangeSub;
Timer? _timer; Timer? _timer;
late final Future<ClaudeStats> Function() _load; late final Future<ClaudeStats> Function() _load;
@@ -163,6 +170,13 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_memberStatus.remove(m.agentId); _memberStatus.remove(m.agentId);
}); });
}); });
// Slash-command navigation (T-413): /status, /config, /mcp, … publish a
// meta.tab message; switch the sub-tab to match.
_tabSub = kernel.messages.subscribe(publisher: 'builtin.claude', channel: 'meta.tab').listen((msg) {
final name = msg.data['tab'] as String?;
final tab = SidebarTab.values.where((t) => t.name == name).firstOrNull;
if (tab != null && mounted) setState(() => _tab = tab);
});
// Live per-member status forwarded by the observer (T-157). // Live per-member status forwarded by the observer (T-157).
_statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) { _statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) {
final agentId = msg.data['agentId'] as String?; final agentId = msg.data['agentId'] as String?;
@@ -191,15 +205,42 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final session = _orchestrator?.byId('primary')?.session; final session = _orchestrator?.byId('primary')?.session;
_primarySub?.cancel(); _primarySub?.cancel();
_primarySub = null; _primarySub = null;
_primaryItemsSub?.cancel();
_primaryItemsSub = null;
_primaryWorkflowsSub?.cancel();
_primaryWorkflowsSub = null;
if (session == null) { if (session == null) {
if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null); if (mounted && (_primaryStatus != null || _workflows.isNotEmpty)) {
setState(() {
_primaryStatus = null;
_workflows = const {};
});
}
return; return;
} }
final seed = session.status; final seed = session.status;
if (mounted) setState(() => _primaryStatus = seed); if (mounted) {
setState(() {
_primaryStatus = seed;
_workflows = session.workflows;
});
}
_primarySub = session.statusStream.listen((s) { _primarySub = session.statusStream.listen((s) {
if (mounted) setState(() => _primaryStatus = s); if (mounted) setState(() => _primaryStatus = s);
}); });
// The Activity tab's WORKFLOWS section tracks the primary session's live
// workflow runs (T-416).
_primaryWorkflowsSub = session.workflowsStream.listen((w) {
if (mounted) setState(() => _workflows = w);
});
// Watch for /usage responses: CLI-local output arrives as synthetic
// assistant text; when it parses as usage, the Activity block updates
// (T-415). Driven by the refresh control publishing '/usage'.
_primaryItemsSub = session.items.listen((item) {
if (item is! AssistantTextMessage || !item.synthetic) return;
final parsed = parseUsageText(item.text);
if (parsed != null && mounted) setState(() => _usage = parsed);
});
} }
void _onConfigChange() { void _onConfigChange() {
@@ -247,7 +288,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_joinSub?.cancel(); _joinSub?.cancel();
_leftSub?.cancel(); _leftSub?.cancel();
_statusSub?.cancel(); _statusSub?.cancel();
_tabSub?.cancel();
_primarySub?.cancel(); _primarySub?.cancel();
_primaryItemsSub?.cancel();
_primaryWorkflowsSub?.cancel();
_brokerChangeSub?.cancel(); _brokerChangeSub?.cancel();
_injectCtl.dispose(); _injectCtl.dispose();
_config?.removeListener(_onConfigChange); _config?.removeListener(_onConfigChange);
@@ -263,7 +307,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)), SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)),
Expanded( Expanded(
child: switch (_tab) { child: switch (_tab) {
SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config), SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage, workflows: _workflows),
SidebarTab.team => TeamTabView( SidebarTab.team => TeamTabView(
members: _members, members: _members,
memberStatus: _memberStatus, memberStatus: _memberStatus,
@@ -303,6 +347,8 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
), ),
SidebarTab.config => ConfigTabView( SidebarTab.config => ConfigTabView(
config: _config, config: _config,
status: _primaryStatus,
models: _orchestrator?.byId('primary')?.session.availableModels,
expanded: _expanded, expanded: _expanded,
onToggleSection: (section) => setState(() { onToggleSection: (section) => setState(() {
if (_expanded.contains(section)) { if (_expanded.contains(section)) {
+243 -3
View File
@@ -14,6 +14,7 @@ import 'clipboard_paste.dart';
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey; import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
import 'conversation_controller.dart'; import 'conversation_controller.dart';
import 'conversation_view.dart'; import 'conversation_view.dart';
import 'model_picker_card.dart';
import 'permission_mode_control.dart'; import 'permission_mode_control.dart';
import 'prompt_card.dart'; import 'prompt_card.dart';
import 'session_index.dart'; import 'session_index.dart';
@@ -24,6 +25,7 @@ import 'slash_commands.dart';
import 'stream_json_session.dart'; import 'stream_json_session.dart';
import 'task_list.dart'; import 'task_list.dart';
import 'transcript_reader.dart'; import 'transcript_reader.dart';
import 'workflow_run.dart';
/// The Claude conversation pane. Drives `claude` over the stream-json control /// The Claude conversation pane. Drives `claude` over the stream-json control
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events /// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
@@ -73,6 +75,9 @@ class _ClaudePaneState extends State<ClaudePane> {
StreamSubscription<SessionStatus>? _statusSub; StreamSubscription<SessionStatus>? _statusSub;
StreamSubscription<SessionEnd>? _endSub; StreamSubscription<SessionEnd>? _endSub;
StreamSubscription<ProjectOpened>? _projectSub; StreamSubscription<ProjectOpened>? _projectSub;
StreamSubscription<Message>? _commandSub;
StreamSubscription<String>? _modelErrorSub;
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
ConversationController? _conversation; ConversationController? _conversation;
StreamJsonSession? _session; StreamJsonSession? _session;
SessionStatus _status = const SessionStatus(); SessionStatus _status = const SessionStatus();
@@ -85,6 +90,17 @@ class _ClaudePaneState extends State<ClaudePane> {
/// /resume, and respawns operate on this pane's own session (T-375). /// /resume, and respawns operate on this pane's own session (T-375).
late String? _forkSource = widget.forkSourceId; late String? _forkSource = widget.forkSourceId;
/// Whether a bare `/model` opened the picker in the interaction zone
/// (T-408). An open prompt takes precedence; the picker shows once it
/// resolves.
bool _modelPickerOpen = false;
bool _effortPickerOpen = false;
bool _permissionPickerOpen = false;
/// Effort level this pane's session runs at (`--effort`, T-412). Null =
/// the CLI default. Set by /effort; carried by every respawn.
String? _effort;
bool _spawned = false; bool _spawned = false;
/// Per-session composer draft (text + caret), held here so an unsent /// Per-session composer draft (text + caret), held here so an unsent
@@ -164,6 +180,18 @@ class _ClaudePaneState extends State<ClaudePane> {
// GlobalKey and spawns once, so without this it would keep the previous // GlobalKey and spawns once, so without this it would keep the previous
// repo's session after a switch (T-269). // repo's session after a switch (T-269).
_projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged); _projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
// Sidebar controls (and any future surface) drive this pane's session by
// publishing slash-command text on builtin.claude/command (T-414) —
// executed through the exact _send routing the composer uses, so the
// control and the typed command are one code path (D-6). Only the
// primary pane listens: the controls target the primary session, and a
// second listener would double-execute.
if (widget.isPrimary) {
_commandSub = ClideKernel.of(context).messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen((msg) {
final text = msg.data['text'] as String?;
if (text != null && text.isNotEmpty) _send(text);
});
}
// Re-fold the conversation when the activity fold-level setting changes // Re-fold the conversation when the activity fold-level setting changes
// (claude.activity.fold-level command, T-235). // (claude.activity.fold-level command, T-235).
ClideKernel.of(context).settings.addListener(_onSettingsChanged); ClideKernel.of(context).settings.addListener(_onSettingsChanged);
@@ -179,11 +207,16 @@ class _ClaudePaneState extends State<ClaudePane> {
activeClaudeConfig?.removeListener(_onConfigChanged); activeClaudeConfig?.removeListener(_onConfigChanged);
_kernel?.settings.removeListener(_onSettingsChanged); _kernel?.settings.removeListener(_onSettingsChanged);
_projectSub?.cancel(); _projectSub?.cancel();
_commandSub?.cancel();
_projectSub = null; _projectSub = null;
_statusSub?.cancel(); _statusSub?.cancel();
_statusSub = null; _statusSub = null;
_endSub?.cancel(); _endSub?.cancel();
_endSub = null; _endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
// The orchestrator owns the session, so disposing this pane does NOT kill // The orchestrator owns the session, so disposing this pane does NOT kill
// it — that's what lets a hidden/kept-alive pane keep its session (T-169). // it — that's what lets a hidden/kept-alive pane keep its session (T-169).
// A secondary tab being *closed* is a real teardown, so close its session; // A secondary tab being *closed* is a real teardown, so close its session;
@@ -237,6 +270,13 @@ class _ClaudePaneState extends State<ClaudePane> {
_statusSub = null; _statusSub = null;
_endSub?.cancel(); _endSub?.cancel();
_endSub = null; _endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
_conversation = null; _conversation = null;
_session = null; _session = null;
@@ -284,7 +324,14 @@ class _ClaudePaneState extends State<ClaudePane> {
_sessionId ??= freshSessionId(); _sessionId ??= freshSessionId();
try { try {
managed = await orch.spawn( managed = await orch.spawn(
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource), SpawnSpec(
id: _orchId,
role: 'fork ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
forkSourceSessionId: forkSource,
effort: _effort,
),
); );
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = 'Could not start fork: $e'); if (mounted) setState(() => _error = 'Could not start fork: $e');
@@ -315,6 +362,7 @@ class _ClaudePaneState extends State<ClaudePane> {
cwd: repoRoot, cwd: repoRoot,
resume: resume, resume: resume,
transcriptPath: resume ? transcriptFile : null, transcriptPath: resume ? transcriptFile : null,
effort: _effort,
), ),
); );
} catch (e) { } catch (e) {
@@ -327,6 +375,9 @@ class _ClaudePaneState extends State<ClaudePane> {
_session = managed.session; _session = managed.session;
_conversation = managed.conversation; _conversation = managed.conversation;
// The wire never reports effort — record what this session was spawned
// with so the status line / sidebar can show it (T-412).
if (_effort != null) managed.session.noteEffort(_effort!);
// Diagnostic (T-274 follow-up): record how this pane bound its session — // Diagnostic (T-274 follow-up): record how this pane bound its session —
// a fresh spawn vs connecting to existing on-disk history (the seed read // a fresh spawn vs connecting to existing on-disk history (the seed read
// from the transcript/sidecar). Surfaces the resume path in `make run`. // from the transcript/sidecar). Surfaces the resume path in `make run`.
@@ -340,6 +391,18 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return; if (!mounted) return;
setState(() => _status = s); setState(() => _status = s);
}); });
// Workflow runs arrive on out-of-band system events that add no
// conversation item, so the view won't rebuild on its own — drive a
// rebuild as the run map changes so the workflow card updates live (T-416).
_workflowsSub = managed.session.workflowsStream.listen((_) {
if (!mounted) return;
setState(() {});
});
// A rejected /model change (unknown name) rolls back silently in the
// status — say why out loud (T-408).
_modelErrorSub = managed.session.modelErrors.listen((msg) {
_kernel?.notify.warn(msg, title: 'model');
});
// Surface a dead process instead of letting it look thoughtful (T-361): // Surface a dead process instead of letting it look thoughtful (T-361):
// late binders read the replayed end; live sessions stream it. // late binders read the replayed end; live sessions stream it.
final alreadyEnded = managed.session.end; final alreadyEnded = managed.session.end;
@@ -377,10 +440,152 @@ class _ClaudePaneState extends State<ClaudePane> {
case 'fork': case 'fork':
_forkSession(); _forkSession();
return; return;
case 'model':
_modelCommand(slashCommandArg(text) ?? '');
return;
case 'effort':
_effortCommand(slashCommandArg(text) ?? '');
return;
case 'permissions':
_permissionsCommand(slashCommandArg(text) ?? '');
return;
case 'status':
_openMetaTab('activity');
return;
case 'config':
case 'mcp':
case 'agents':
case 'hooks':
_openMetaTab('config');
return;
case 'memory':
_openMemory();
return;
case 'help':
_helpCommand();
return;
}
// Route the rest (T-411): a known TUI-only builtin never reaches the
// session — forwarded it would error (or, un-advertised, bracket-paste to
// the model as literal text, burning a turn). It becomes a local notice
// card pointing at the clide-native way instead.
final advertised = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands;
if (routeSlashCommand(text, advertised: advertised) == SlashRoute.unavailable) {
_session?.addLocalNotice(tuiOnlyNotice(slashCommandToken(text)!));
return;
} }
_session?.send(text); _session?.send(text);
} }
/// clide-owned `/model` (T-408): with an argument, set the model directly;
/// bare, open the picker in the interaction zone (D-78).
void _modelCommand(String arg) {
if (_session == null) return;
if (arg.isNotEmpty) {
_session!.setModel(arg);
return;
}
setState(() => _modelPickerOpen = true);
}
void _pickModel(String value) {
_session?.setModel(value);
_closeModelPicker();
}
void _closeModelPicker() {
setState(() => _modelPickerOpen = false);
_composerFocus.requestFocus();
}
/// clide-owned `/effort` (T-412): with a level, respawn-with-resume carrying
/// `--effort`; bare, open the picker. No set_effort control subtype exists
/// (probed 2.1.175), so the respawn IS the mechanism — resume keeps the
/// conversation, only the process restarts.
void _effortCommand(String arg) {
if (_session == null) return;
if (arg.isEmpty) {
setState(() => _effortPickerOpen = true);
return;
}
if (!kEffortLevels.any((l) => l.value == arg)) {
_session!.addLocalNotice('unknown effort "$arg" — levels: ${kEffortLevels.map((l) => l.value).join(', ')}');
return;
}
_setEffort(arg);
}
void _pickEffort(String value) {
_closeEffortPicker();
_setEffort(value);
}
void _closeEffortPicker() {
setState(() => _effortPickerOpen = false);
_composerFocus.requestFocus();
}
void _setEffort(String level) {
final sid = _sessionId;
if (sid == null) return;
_effort = level;
_kernel?.notify.info('effort $level — restarting the session to apply', title: 'effort');
unawaited(_respawnWithSession(sid));
}
/// clide-owned `/permissions` (T-413): with a mode, set it directly over
/// set_permission_mode; bare, open a picker — the same interaction-zone
/// pattern as /model and /effort.
void _permissionsCommand(String arg) {
final s = _session;
if (s == null) return;
if (arg.isEmpty) {
setState(() => _permissionPickerOpen = true);
return;
}
if (!kPermissionModes.any((m) => m.value == arg)) {
s.addLocalNotice('unknown permission mode "$arg" — modes: ${kPermissionModes.map((m) => m.value).join(', ')}');
return;
}
s.setPermissionMode(arg);
}
void _pickPermissionMode(String value) {
_closePermissionPicker();
_session?.setPermissionMode(value);
}
void _closePermissionPicker() {
setState(() => _permissionPickerOpen = false);
_composerFocus.requestFocus();
}
/// Navigate to the Claude sidebar and select a sub-tab (T-413): the
/// /status//config//mcp//agents//hooks commands land here.
void _openMetaTab(String tab) {
final k = _kernel;
if (k == null) return;
k.panels.activateTab(Slots.sidebar, 'claude.meta');
k.messages.publish('builtin.claude', 'meta.tab', {'tab': tab});
}
/// clide-owned `/memory` (T-413): open the workspace CLAUDE.md in the editor.
void _openMemory() {
final root = _repoRoot;
if (root == null) return;
unawaited(_ipc()?.request('editor.open', args: {'path': '$root/CLAUDE.md'}));
}
/// clide-owned `/help` (T-413): a local summary card — never the CLI's TUI
/// help, which doesn't exist headless.
void _helpCommand() {
final advertised = (activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands).where((c) => !kClideOwnedCommands.contains(c)).toList()..sort();
_session?.addLocalNotice(
'clide commands: ${(kClideOwnedCommands.toList()..sort()).map((c) => '/$c').join(' ')}\n'
'claude commands & skills: ${advertised.map((c) => '/$c').join(' ')}',
);
}
/// Record a submitted prompt in the active session's history (T-163), /// Record a submitted prompt in the active session's history (T-163),
/// de-duping immediate repeats. Empty/whitespace prompts are skipped. /// de-duping immediate repeats. Empty/whitespace prompts are skipped.
void _appendHistory(String text) { void _appendHistory(String text) {
@@ -405,7 +610,7 @@ class _ClaudePaneState extends State<ClaudePane> {
/// background tap must never pull focus from (or resurrect) the composer /// background tap must never pull focus from (or resurrect) the composer
/// over an open prompt. /// over an open prompt.
void _focusComposerOnTap() { void _focusComposerOnTap() {
if (_session?.pendingPrompt != null) return; if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen || _permissionPickerOpen) return;
_composerFocus.requestFocus(); _composerFocus.requestFocus();
} }
@@ -477,6 +682,13 @@ class _ClaudePaneState extends State<ClaudePane> {
_statusSub = null; _statusSub = null;
_endSub?.cancel(); _endSub?.cancel();
_endSub = null; _endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old session await activeSessionOrchestrator?.close(_orchId); // kills the old session
// Erase only after the process is dead, so claude isn't mid-write. // Erase only after the process is dead, so claude isn't mid-write.
final root = _repoRoot; final root = _repoRoot;
@@ -532,6 +744,7 @@ class _ClaudePaneState extends State<ClaudePane> {
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{}, hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{}, toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{}, quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
emptyState: ClaudeBanner( emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot, workspace: _repoRoot,
@@ -548,9 +761,36 @@ class _ClaudePaneState extends State<ClaudePane> {
), ),
// An open prompt takes the composer's space and hides the text // An open prompt takes the composer's space and hides the text
// input until it's answered, so interaction stays out of the // input until it's answered, so interaction stays out of the
// conversation stream (D-78). // conversation stream (D-78). The /model picker uses the same
// slot; a prompt outranks it (T-408).
if (prompt != null && _session != null) if (prompt != null && _session != null)
ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt) ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt)
else if (_modelPickerOpen && _session != null)
ModelPickerCard(
models: _session!.availableModels.isEmpty ? kFallbackModels : _session!.availableModels,
currentModel: _status.model,
onPick: _pickModel,
onCancel: _closeModelPicker,
)
else if (_effortPickerOpen && _session != null)
ModelPickerCard(
title: 'effort',
models: kEffortLevels,
currentModel: _status.effort,
// Exact match — containment would mark `high` inside `xhigh`.
isCurrent: (o, c) => c != null && o.value == c,
onPick: _pickEffort,
onCancel: _closeEffortPicker,
)
else if (_permissionPickerOpen && _session != null)
ModelPickerCard(
title: 'permissions',
models: kPermissionModes,
currentModel: _status.permissionMode,
isCurrent: (o, c) => c != null && o.value == c,
onPick: _pickPermissionMode,
onCancel: _closePermissionPicker,
)
else else
StreamBuilder<bool>( StreamBuilder<bool>(
stream: _session?.busyStream, stream: _session?.busyStream,
+40 -2
View File
@@ -53,8 +53,8 @@ String nextSafePermissionMode(String current) {
} }
/// Status-line segments split around the permission-mode badge so the UI can /// Status-line segments split around the permission-mode badge so the UI can
/// render the mode as an interactive control between them (T-226). [leading] /// render the mode as an interactive control between them (T-226). `leading`
/// is the model; [trailing] joins context / cost / rate-limit. Either may be /// is the model; `trailing` joins context / cost / rate-limit. Either may be
/// null when there's nothing to show. /// null when there's nothing to show.
({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) { ({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) {
final trailing = [ final trailing = [
@@ -92,3 +92,41 @@ String formatTokenCount(int n) {
if (n >= 1000) return '${(n / 1000).round()}k'; if (n >= 1000) return '${(n / 1000).round()}k';
return '$n'; return '$n';
} }
/// Parsed `/usage` output (T-415). The CLI answers a forwarded `/usage`
/// headless and free (probed 2.1.175, num_turns 0) with plain text:
///
/// Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam)
/// Current week (all models): 53% used · resets Jun 15, 6:59pm (…)
/// Current week (Sonnet only): 0% used
class ClaudeUsage {
const ClaudeUsage({this.session, this.week, this.weekSonnet});
/// The value text per line (e.g. `15% used · resets Jun 12, 3:39pm`),
/// timezone parenthetical stripped. Null when the line wasn't present.
final String? session;
final String? week;
final String? weekSonnet;
bool get isEmpty => session == null && week == null && weekSonnet == null;
}
/// Parse `/usage` response text into a [ClaudeUsage], or null when [text]
/// isn't usage output. Tolerant of label drift: any `Current …: …% used`
/// line is matched by its key phrase.
ClaudeUsage? parseUsageText(String text) {
if (!text.contains('% used')) return null;
String? valueOf(String keyPhrase) {
for (final line in text.split('\n')) {
if (!line.contains(keyPhrase)) continue;
final colon = line.indexOf(':');
if (colon < 0) continue;
// Strip the trailing timezone parenthetical — noise at sidebar width.
return line.substring(colon + 1).replaceAll(RegExp(r'\s*\([^)]*\)\s*$'), '').trim();
}
return null;
}
final usage = ClaudeUsage(session: valueOf('Current session'), week: valueOf('(all models)'), weekSonnet: valueOf('(Sonnet only)'));
return usage.isEmpty ? null : usage;
}
@@ -28,8 +28,8 @@ class ConversationController extends ChangeNotifier {
} }
/// Build a controller fed from the kernel [MessageBus] — it consumes /// Build a controller fed from the kernel [MessageBus] — it consumes
/// the [ConversationItem]s a [TranscriptPublisher] writes onto /// the [ConversationItem]s a `TranscriptPublisher` writes onto
/// [publisher]/[channel]. Decouples the view from the reader so several /// `publisher`/[channel]. Decouples the view from the reader so several
/// panels can render the same conversation (team work, T-139/T-140). /// panels can render the same conversation (team work, T-139/T-140).
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) { factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
final stream = messages final stream = messages
+170 -1
View File
@@ -15,13 +15,17 @@ import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/bash_tail_source.dart'; import 'package:clide/builtin/claude/src/bash_tail_source.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabel;
import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/file_tail_follower.dart'; import 'package:clide/builtin/claude/src/file_tail_follower.dart';
import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/src/facade.dart'; import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/keymap/intents.dart';
import 'package:clide/kernel/src/keymap/pane_key_nav.dart';
import 'package:clide/kernel/src/syntax/language_map.dart'; import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart'; import 'package:clide/kernel/src/theme/tokens.dart';
@@ -38,11 +42,18 @@ class ConversationView extends StatefulWidget {
this.hiddenToolUseIds = const <String>{}, this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{}, this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{}, this.quietErrorToolUseIds = const <String>{},
this.workflows = const <String, WorkflowRun>{},
this.foldLevel = FoldLevel.tools, this.foldLevel = FoldLevel.tools,
}); });
final ConversationController controller; final ConversationController controller;
/// Live Workflow runs keyed by their launching `Workflow` tool-use id
/// (T-416). A `Workflow` tool-use card with a matching run renders the
/// dedicated run card (phases, agent rows, status) instead of the generic
/// tool card; absent (pre-progress, or on reload) it falls back to generic.
final Map<String, WorkflowRun> workflows;
/// How aggressively consecutive meta items (tool calls/results, thinking) /// How aggressively consecutive meta items (tool calls/results, thinking)
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]). /// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
final FoldLevel foldLevel; final FoldLevel foldLevel;
@@ -332,6 +343,7 @@ class _ConversationViewState extends State<ConversationView> {
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId, promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId, runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
), ),
FoldedCluster(:final items) => _ActivityCard( FoldedCluster(:final items) => _ActivityCard(
key: ValueKey('cluster.${items.first.uuid}'), key: ValueKey('cluster.${items.first.uuid}'),
@@ -343,6 +355,7 @@ class _ConversationViewState extends State<ConversationView> {
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId, promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId, runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
), ),
EditRun(:final edits) => _EditRunCard( EditRun(:final edits) => _EditRunCard(
key: ValueKey('edits.${edits.first.uuid}'), key: ValueKey('edits.${edits.first.uuid}'),
@@ -376,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
return list; return list;
}, },
); );
return ColoredBox( final body = ColoredBox(
color: tokens.panelBackground, color: tokens.panelBackground,
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized, child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized,
); );
// Vim nav scrolls the conversation while this region holds focus under the
// vim preset (T-406): j/k by a line, ctrl+d/u by half a viewport, gg/G to
// the ends — G also re-arms follow-tail so new output keeps it pinned.
return PaneKeyNav(onNav: _onNav, child: body);
}
/// One "line" of scroll for j/k — a few text rows' worth.
static const double _lineScroll = 48;
void _onNav(NavIntent intent, int count) {
if (!_scroll.hasClients) return;
final p = _scroll.position;
final half = p.viewportDimension / 2;
switch (intent) {
case NavDownIntent():
_scrollBy(_lineScroll * count);
case NavUpIntent():
_scrollBy(-_lineScroll * count);
case NavPageDownIntent():
_scrollBy(half);
case NavPageUpIntent():
_scrollBy(-half);
case NavTopIntent():
_scroll.jumpTo(0);
_atBottom = false;
case NavBottomIntent():
_scroll.jumpTo(p.maxScrollExtent);
_atBottom = true; // re-arm follow-tail (T-297)
case NavExpandOrRightIntent() || NavCollapseOrLeftIntent() || NavActivateIntent():
break; // a reader pane has no expand/activate semantics
}
}
void _scrollBy(double delta) {
final p = _scroll.position;
final target = (p.pixels + delta).clamp(0.0, p.maxScrollExtent);
_scroll.jumpTo(target);
_atBottom = (p.maxScrollExtent - target) <= _bottomEpsilon;
} }
} }
@@ -503,6 +554,7 @@ class _ConversationTurn extends StatelessWidget {
this.resultByToolUseId = const <String, ToolResultMessage>{}, this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{}, this.promptsByToolUseId = const <String, List<UserMessage>>{},
this.runByToolUseId = const <String, List<ConversationItem>>{}, this.runByToolUseId = const <String, List<ConversationItem>>{},
this.workflows = const <String, WorkflowRun>{},
}); });
final ConversationItem item; final ConversationItem item;
@@ -538,6 +590,9 @@ class _ConversationTurn extends StatelessWidget {
/// thinking, tool cards) nested under the Agent card in a holder (T-264). /// thinking, tool cards) nested under the Agent card in a holder (T-264).
final Map<String, List<ConversationItem>> runByToolUseId; final Map<String, List<ConversationItem>> runByToolUseId;
/// Live Workflow runs keyed by launching tool-use id (T-416).
final Map<String, WorkflowRun> workflows;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i = item; final i = item;
@@ -577,6 +632,17 @@ class _ConversationTurn extends StatelessWidget {
onOpenFile: (path, line) => _openFile(context, path, line), onOpenFile: (path, line) => _openFile(context, path, line),
), ),
), ),
// CLI-local output (model "<synthetic>": a forwarded local command's
// response or a clide-injected notice, T-411) is not Claude speaking —
// framed + muted like the context card (T-306), attributed to clide.
AssistantTextMessage() when i.synthetic => ConversationCard(
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: 'clide',
copyText: i.text,
margin: _childMargin,
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the // Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
// agent with a muted accent, never the coral "claude" brand (T-265). The // agent with a muted accent, never the coral "claude" brand (T-265). The
// coral claudeAccent is reserved for the real main-thread Claude. // coral claudeAccent is reserved for the real main-thread Claude.
@@ -686,6 +752,13 @@ class _ConversationTurn extends StatelessWidget {
/// and its own per-item mark. An Agent/Task call also nests its visible /// and its own per-item mark. An Agent/Task call also nests its visible
/// sub-agent run in a second collapser below (T-264). /// sub-agent run in a second collapser below (T-264).
Widget _toolUseCollapser(AssistantToolUse t) { Widget _toolUseCollapser(AssistantToolUse t) {
// A Workflow tool-use with a live run (T-416) renders the dedicated run
// card — phases, agent rows, status — instead of the generic tool card. No
// run yet (pre-progress, or on reload where the system events are gone)
// falls through to the generic collapser below.
if (t.name == 'Workflow' && workflows[t.toolUseId] != null) {
return _workflowCard(t, workflows[t.toolUseId]!);
}
final outcome = toolUseOutcomes[t.toolUseId]; final outcome = toolUseOutcomes[t.toolUseId];
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError); final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
final collapser = ClideCollapserCard( final collapser = ClideCollapserCard(
@@ -729,6 +802,99 @@ class _ConversationTurn extends StatelessWidget {
); );
} }
/// A dedicated card for a Workflow run (T-416): the harness's multi-agent
/// orchestration. The collapser header carries the run's live status (spinner
/// while running, check when done) and a `done/total agents` counter; the body
/// lists each fanned-out agent — grouped under phase headers when the workflow
/// declared phases — plus the run's usage and the orchestration script.
Widget _workflowCard(AssistantToolUse t, WorkflowRun run) {
final title = run.name ?? 'workflow';
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
final detail = run.done ? (run.summary ?? run.description) : run.description;
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
return ClideCollapserCard(
label: 'workflow',
color: color,
collapsedSummary: collapsedSummary,
counter: counter,
status: run.done ? ClideRunStatus.success : ClideRunStatus.running,
children: [_workflowBody(t, run)],
);
}
Widget _workflowBody(AssistantToolUse t, WorkflowRun run) {
final agents = run.orderedAgents;
final phases = run.orderedPhases;
final rows = <Widget>[];
if (phases.isEmpty) {
rows.addAll(agents.map(_workflowAgentRow));
} else {
for (final p in phases) {
rows.add(
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600),
),
);
rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow));
}
// Agents the deltas never tagged with a phase still render, after the
// phased groups, so nothing fanned out is silently dropped.
rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow));
}
if (rows.isEmpty) {
rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta));
}
final script = t.input['script'];
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: run.done ? tokens.statusSuccess : tokens.globalFocus,
label: run.name ?? 'workflow',
copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input),
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows),
extraSegments: [
if (run.totalTokens != null && run.totalTokens! > 0)
CardSegment(
label: 'usage',
child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta),
),
if (script is String)
CardSegment(
label: 'script',
child: ClideCodeBlock(source: script, language: 'javascript'),
),
],
margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH),
);
}
/// One agent row in a workflow card: a state glyph (spinner while running, a
/// muted check once done), the agent's label, and its model (T-416).
Widget _workflowAgentRow(WorkflowAgent a) {
final done = a.state == WorkflowAgentState.done;
final Widget glyph = done
? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess)
: ClideSpinner(size: 12, color: tokens.globalTextMuted);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(width: 16, child: Center(child: glyph)),
const SizedBox(width: 6),
Expanded(
child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis),
),
if (a.model != null && a.model!.isNotEmpty) ...[
const SizedBox(width: 8),
ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1),
],
],
),
);
}
/// The inner content card for a tool use (T-305): the call body + folded /// The inner content card for a tool use (T-305): the call body + folded
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own /// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
/// collapse caret — the enclosing collapser owns collapse. Used both as a /// collapse caret — the enclosing collapser owns collapse. Used both as a
@@ -896,6 +1062,7 @@ class _ActivityCard extends StatelessWidget {
required this.resultByToolUseId, required this.resultByToolUseId,
required this.promptsByToolUseId, required this.promptsByToolUseId,
required this.runByToolUseId, required this.runByToolUseId,
this.workflows = const <String, WorkflowRun>{},
}); });
final List<ConversationItem> items; final List<ConversationItem> items;
@@ -906,6 +1073,7 @@ class _ActivityCard extends StatelessWidget {
final Map<String, ToolResultMessage> resultByToolUseId; final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId; final Map<String, List<UserMessage>> promptsByToolUseId;
final Map<String, List<ConversationItem>> runByToolUseId; final Map<String, List<ConversationItem>> runByToolUseId;
final Map<String, WorkflowRun> workflows;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -927,6 +1095,7 @@ class _ActivityCard extends StatelessWidget {
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId, promptsByToolUseId: promptsByToolUseId,
runByToolUseId: runByToolUseId, runByToolUseId: runByToolUseId,
workflows: workflows,
), ),
], ],
); );
+4
View File
@@ -5,6 +5,7 @@ import 'package:clide/clide.dart';
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel; import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode; import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
import 'package:clide/builtin/claude/src/conversation_view.dart' show claudeAccent;
import 'package:clide/builtin/claude/src/claude_session_host.dart'; import 'package:clide/builtin/claude/src/claude_session_host.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/pane_context_status.dart'; import 'package:clide/builtin/claude/src/pane_context_status.dart';
@@ -308,6 +309,9 @@ class ClaudeExtension extends ClideExtension {
slot: Slots.sidebar, slot: Slots.sidebar,
title: 'Activity', title: 'Activity',
icon: PhosphorIcons.byName('robot'), icon: PhosphorIcons.byName('robot'),
// Claude's accent marks Claude's own panel in the rail (T-418) —
// nominative use per the licenses.yaml trademark note.
iconColor: claudeAccent,
priority: 60, priority: 60,
build: (_) => const ClaudeMetaSidebar(), build: (_) => const ClaudeMetaSidebar(),
), ),
@@ -4,7 +4,7 @@
/// process), so to "watch the same output" we open our OWN read-only follower /// process), so to "watch the same output" we open our OWN read-only follower
/// on the file the command tails. This never spawns a process and never /// on the file the command tails. This never spawns a process and never
/// touches Claude's command — it just reads the file as it grows, like /// touches Claude's command — it just reads the file as it grows, like
/// `tail -f`, and hands new bytes to [onData]. /// `tail -f`, and hands new bytes to `onData`.
/// ///
/// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather /// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather
/// than using a watcher so it works uniformly across platforms and survives /// than using a watcher so it works uniformly across platforms and survives
@@ -1,28 +1,61 @@
/// The Activity tab: usage stats (stats-cache.json) + the primary /// The Activity tab: session controls, usage, stats (stats-cache.json), and
/// session's live runtime row. Split out of claude_meta_sidebar.dart /// the primary session's live runtime row. Split out of
/// (T-395). /// claude_meta_sidebar.dart (T-395); session controls + the usage block are
/// the power-panel additions (T-415).
library; library;
import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_stats.dart'; import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel; import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class ActivityTabView extends StatelessWidget { class ActivityTabView extends StatelessWidget {
const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config}); const ActivityTabView({
super.key,
required this.stats,
required this.primaryStatus,
required this.config,
this.usage,
this.workflows = const <String, WorkflowRun>{},
});
final ClaudeStats stats; final ClaudeStats stats;
final SessionStatus? primaryStatus; final SessionStatus? primaryStatus;
final ClaudeConfig? config; final ClaudeConfig? config;
/// Parsed `/usage` output for the usage block, refreshed via the refresh
/// control (T-415). Null until the first refresh.
final ClaudeUsage? usage;
/// Live Workflow runs in the primary session, keyed by launching tool-use id
/// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with
/// its done/total agent count and running/done state.
final Map<String, WorkflowRun> workflows;
/// Publish a slash command for the primary pane to execute — the session
/// controls are the same code path as typing the command (D-6).
void _command(BuildContext context, String text) {
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': text});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final latest = stats.latest; final latest = stats.latest;
final u = usage;
final sections = <MetaSection>[ final sections = <MetaSection>[
..._workflowSection(tokens),
if (u != null)
MetaSection('USAGE', [
if (u.session != null) MetaRow('session', u.session!),
if (u.week != null) MetaRow('week (all)', u.week!),
if (u.weekSonnet != null) MetaRow('week (sonnet)', u.weekSonnet!),
]),
if (latest != null) if (latest != null)
MetaSection('TODAY', [ MetaSection('TODAY', [
MetaRow('messages', '${latest.messageCount}'), MetaRow('messages', '${latest.messageCount}'),
@@ -32,10 +65,65 @@ class ActivityTabView extends StatelessWidget {
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]), if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
..._runtimeSection(tokens), ..._runtimeSection(tokens),
]; ];
if (sections.isEmpty) {
return metaPlaceholder('No activity recorded yet.'); return ListView(
} padding: const EdgeInsets.all(12),
return buildMetaTable(tokens, sections); children: [
// SESSION control strip (T-415): drives the primary session through
// the builtin.claude/command bus — identical to typing the command.
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClideText('SESSION', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
),
Row(
children: [
_control(context, tokens, 'clear', 'trash', '/clear'),
_control(context, tokens, 'compact', 'arrows-in-simple', '/compact'),
_control(context, tokens, 'fork', 'git-branch', '/fork'),
_control(context, tokens, 'resume', 'clock-counter-clockwise', '/resume'),
const Spacer(),
_control(context, tokens, 'refresh usage', 'arrow-clockwise', '/usage'),
],
),
const SizedBox(height: 6),
if (sections.isEmpty) metaPlaceholder('No activity recorded yet.') else ...metaTableChildren(tokens, sections),
],
);
}
Widget _control(BuildContext context, SurfaceTokens tokens, String label, String glyph, String command) {
return Semantics(
button: true,
label: '$label session',
excludeSemantics: true,
onTap: () => _command(context, command),
child: ClideTappable(
tooltip: '$label · $command',
onTap: () => _command(context, command),
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: ClideIcon(PhosphorIcons.byName(glyph), size: 15, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
);
}
/// An aggregate WORKFLOWS section while one or more workflow runs exist this
/// session (T-416): a row per run — its name and `done/total agents`, tinted
/// focus while running and success once complete.
List<MetaSection> _workflowSection(SurfaceTokens tokens) {
final runs = workflows.values.toList();
if (runs.isEmpty) return const [];
return [
MetaSection('WORKFLOWS', [
for (final r in runs)
MetaRow(
r.name ?? r.taskId ?? 'workflow',
r.agentCount == 0 ? (r.done ? 'done' : 'starting') : '${r.doneCount}/${r.agentCount} agents${r.done ? '' : ''}',
valueColor: r.done ? tokens.statusSuccess : tokens.globalFocus,
),
]),
];
} }
List<MetaSection> _runtimeSection(SurfaceTokens tokens) { List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
@@ -43,6 +131,7 @@ class ActivityTabView extends StatelessWidget {
final skills = config?.skills.length; final skills = config?.skills.length;
final rows = <MetaRow>[ final rows = <MetaRow>[
if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus), if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
if (st?.effort != null) MetaRow('effort', st!.effort!),
if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'), if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)), if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
if (skills != null) MetaRow('skills', '$skills'), if (skills != null) MetaRow('skills', '$skills'),
@@ -1,22 +1,40 @@
/// The Config tab (T-183): the pinned settings table over [ClaudeConfig] /// The Config tab (T-183): the settings table over [ClaudeConfig] plus the
/// plus the skills/agents/commands/hooks/permissions/MCP accordion. /// skills/agents/commands/hooks/permissions/MCP accordion. Split out of
/// Split out of claude_meta_sidebar.dart (T-395). The accordion's /// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in
/// expansion state lives in the parent (it survives tab switches) and /// the parent (it survives tab switches) and arrives as a prop + toggle
/// arrives as a prop + toggle callback. /// callback.
///
/// T-414 makes the settings table a control panel: model / effort /
/// permission-mode rows are live popover controls. Picking an option
/// publishes the explicit slash command (`/model sonnet`) on the
/// `builtin.claude`/`command` channel; the primary Claude pane executes it
/// through the same `_send` routing the composer uses — one implementation,
/// two surfaces (D-6).
library; library;
import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel; import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart' show ModelOption, kEffortLevels, kFallbackModels, kPermissionModes;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class ConfigTabView extends StatelessWidget { class ConfigTabView extends StatelessWidget {
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection}); const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection, this.status, this.models});
final ClaudeConfig? config; final ClaudeConfig? config;
/// The primary session's live status — drives the control rows' current
/// values. Null before the session reports (controls fall back to the
/// probe/settings values).
final SessionStatus? status;
/// Models selectable for the primary session (from its `initialize`
/// response); falls back to [kFallbackModels].
final List<ModelOption>? models;
/// Sections currently expanded — owned by the parent state. /// Sections currently expanded — owned by the parent state.
final Set<ConfigSection> expanded; final Set<ConfigSection> expanded;
final void Function(ConfigSection section) onToggleSection; final void Function(ConfigSection section) onToggleSection;
@@ -29,19 +47,34 @@ class ConfigTabView extends StatelessWidget {
return metaPlaceholder('Claude environment not loaded.'); return metaPlaceholder('Claude environment not loaded.');
} }
final settings = cfg.settings; final settings = cfg.settings;
final model = cfg.probe?.model ?? settings['model']?.toString() ?? ''; final model = status?.model ?? cfg.probe?.model ?? settings['model']?.toString() ?? 'default';
final outputStyle = settings['outputStyle']?.toString() ?? 'default'; final outputStyle = settings['outputStyle']?.toString() ?? 'default';
final mode = cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default'; final mode = status?.permissionMode ?? cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
final children = <Widget>[ final children = <Widget>[
// Pinned SETTINGS table — not collapsible. // Pinned SETTINGS control panel — not collapsible.
Padding( Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 8),
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted), child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
),
SettingControlRow(
label: 'model',
value: model,
valueColor: tokens.globalFocus,
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
command: 'model',
),
SettingControlRow(label: 'effort', value: effort, options: kEffortLevels, isActive: (o) => o.value == effort, command: 'effort'),
SettingControlRow(
label: 'permission mode',
value: permissionModeLabel(mode),
options: kPermissionModes,
isActive: (o) => o.value == mode,
command: 'permissions',
), ),
_configRow(tokens, 'model', model, valueColor: tokens.globalFocus),
_configRow(tokens, 'output style', outputStyle), _configRow(tokens, 'output style', outputStyle),
_configRow(tokens, 'permission mode', permissionModeLabel(mode)),
_configRow(tokens, 'source', '~/.claude + .claude'), _configRow(tokens, 'source', '~/.claude + .claude'),
// ---- Accordion sections ---- // ---- Accordion sections ----
@@ -57,7 +90,7 @@ class ConfigTabView extends StatelessWidget {
return ListView(padding: const EdgeInsets.all(12), children: children); return ListView(padding: const EdgeInsets.all(12), children: children);
} }
/// One key→value row in the pinned SETTINGS table. /// One read-only key→value row in the pinned SETTINGS table.
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) { Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
@@ -66,10 +99,10 @@ class ConfigTabView extends StatelessWidget {
children: [ children: [
SizedBox( SizedBox(
width: kMetaLabelColumnWidth, width: kMetaLabelColumnWidth,
child: ClideText(label, muted: true, fontSize: clideFontSmall), child: ClideText(label, muted: true, fontSize: kMetaFont),
), ),
Expanded( Expanded(
child: ClideText(value, fontSize: clideFontSmall, color: valueColor ?? tokens.globalForeground), child: ClideText(value, fontSize: kMetaFont, color: valueColor ?? tokens.globalForeground),
), ),
], ],
), ),
@@ -223,3 +256,105 @@ class ConfigTabView extends StatelessWidget {
return rows; return rows;
} }
} }
/// One live setting row (T-414): label + current value as a popover control on
/// the owned anchored-menu primitive. Picking an option publishes the explicit
/// slash command on `builtin.claude`/`command`; the primary Claude pane
/// executes it through its normal `_send` routing — so the sidebar control and
/// the typed command are literally the same code path (D-6).
class SettingControlRow extends StatefulWidget {
const SettingControlRow({
super.key,
required this.label,
required this.value,
required this.options,
required this.isActive,
required this.command,
this.valueColor,
});
final String label;
/// Current value, displayed on the trigger.
final String value;
final Color? valueColor;
final List<ModelOption> options;
final bool Function(ModelOption option) isActive;
/// The slash-command token this control drives (`model`, `effort`,
/// `permissions`); a pick publishes `/<command> <option.value>`.
final String command;
@override
State<SettingControlRow> createState() => _SettingControlRowState();
}
class _SettingControlRowState extends State<SettingControlRow> {
final ClideOverlayController _overlay = ClideOverlayController();
void _pick(String value) {
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': '/${widget.command} $value'});
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: kMetaLabelColumnWidth,
child: ClideText(widget.label, muted: true, fontSize: kMetaFont),
),
Expanded(
child: ClideAnchoredOverlay(
controller: _overlay,
align: ClideAnchorAlign.start,
overlayBuilder: (ctx, c) => ClideMenu(
onClose: c.close,
entries: [
for (final o in widget.options)
ClideMenuItem(
label: o.description.isEmpty ? o.displayName : '${o.displayName}${o.description}',
active: widget.isActive(o),
semanticLabel: '${widget.label}: ${o.displayName}',
onSelect: () => _pick(o.value),
),
],
),
anchor: Semantics(
button: true,
label: '${widget.label}: ${widget.value}. Click to change.',
excludeSemantics: true,
onTap: _overlay.toggle,
child: ClideTappable(
tooltip: 'change ${widget.label}',
onTap: _overlay.toggle,
builder: (ctx, hovered, _) => DecoratedBox(
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ClideText(widget.value, fontSize: kMetaFont, color: widget.valueColor ?? tokens.globalForeground, maxLines: 1),
),
const SizedBox(width: 4),
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
],
),
),
),
),
),
),
),
],
),
);
}
}
@@ -9,7 +9,11 @@ import 'package:flutter/widgets.dart';
/// The shared label-column width + row pitch the Activity and Config tables /// The shared label-column width + row pitch the Activity and Config tables
/// both use, so toggling between tabs keeps every value at the same x and y. /// both use, so toggling between tabs keeps every value at the same x and y.
const double kMetaLabelColumnWidth = 110; const double kMetaLabelColumnWidth = 110;
const double kMetaRowPitch = 4; const double kMetaRowPitch = 6;
/// Type scale for the sidebar tables (T-414 styling pass): labels/values read
/// at meta size (13) — the old 12px-everything read as bland and cramped.
const double kMetaFont = clideFontMeta;
/// The sidebar's sub-tabs. /// The sidebar's sub-tabs.
enum SidebarTab { activity, team, config } enum SidebarTab { activity, team, config }
@@ -36,18 +40,23 @@ class MetaRow {
/// The muted empty-state body shared by every tab. /// The muted empty-state body shared by every tab.
Widget metaPlaceholder(String text) => Padding( Widget metaPlaceholder(String text) => Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: ClideText(text, muted: true, fontSize: clideFontSmall), child: ClideText(text, muted: true, fontSize: kMetaFont),
); );
/// Key→value sections on the shared table geometry (Activity + Config). /// Key→value sections on the shared table geometry (Activity + Config).
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) { Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) =>
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections));
/// The table rows without the enclosing ListView, for tabs that compose extra
/// widgets around the sections (the Activity tab's control strip, T-415).
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
final children = <Widget>[]; final children = <Widget>[];
for (var i = 0; i < sections.length; i++) { for (var i = 0; i < sections.length; i++) {
final s = sections[i]; final s = sections[i];
children.add( children.add(
Padding( Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6), padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted), child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
), ),
); );
for (final r in s.rows) { for (final r in s.rows) {
@@ -59,10 +68,10 @@ Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
children: [ children: [
SizedBox( SizedBox(
width: kMetaLabelColumnWidth, width: kMetaLabelColumnWidth,
child: ClideText(r.label, muted: true, fontSize: clideFontSmall), child: ClideText(r.label, muted: true, fontSize: kMetaFont),
), ),
Expanded( Expanded(
child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground), child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
), ),
], ],
), ),
@@ -70,5 +79,5 @@ Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
); );
} }
} }
return ListView(padding: const EdgeInsets.all(12), children: children); return children;
} }
@@ -51,7 +51,7 @@ class AgentRosterRow extends StatefulWidget {
final void Function(String memberName, String text) onInjectSubmit; final void Function(String memberName, String text) onInjectSubmit;
final void Function(String memberName) onClose; final void Function(String memberName) onClose;
/// Called when the badge cycles to a new [mode] string for this member. /// Called when the badge cycles to a new `mode` string for this member.
/// Handles both safe-trio clicks and confirmed bypass. The parent sends /// Handles both safe-trio clicks and confirmed bypass. The parent sends
/// the mode to the session via `StreamJsonSession.setPermissionMode`. /// the mode to the session via `StreamJsonSession.setPermissionMode`.
final void Function(String memberName, String mode) onSetPermissionMode; final void Function(String memberName, String mode) onSetPermissionMode;
@@ -0,0 +1,183 @@
/// The `/model` picker for the interaction zone (T-408, D-78): a bare
/// `/model` swaps this card in for the composer; picking an entry sends
/// `set_model` over the control channel and the composer returns. Esc
/// cancels. Like [ToolPromptCard], it lives in the composer zone — never
/// inline in the conversation.
///
/// Keyboard: number keys pick directly (CLI muscle memory, T-240), Up/Down
/// move the highlight, Enter picks the highlighted entry, Esc cancels.
library;
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Whether [option] is the session's current model. Options carry aliases
/// (`sonnet`) or full ids while the status holds the full id
/// (`claude-sonnet-4-6`), so match on equality or alias containment.
bool modelOptionIsCurrent(ModelOption option, String? currentModel) {
if (currentModel == null || option.value == 'default') return false;
if (option.value == currentModel) return true;
return currentModel.toLowerCase().contains(option.value.toLowerCase());
}
class ModelPickerCard extends StatefulWidget {
const ModelPickerCard({
super.key,
required this.models,
this.currentModel,
required this.onPick,
required this.onCancel,
this.title = 'model',
this.isCurrent = modelOptionIsCurrent,
});
/// Selectable entries, in display order. Callers pass [kFallbackModels]
/// when the session hasn't reported its list yet.
final List<ModelOption> models;
/// The session's current model (full id), to mark the active entry.
final String? currentModel;
/// Called once with the picked [ModelOption.value].
final void Function(String value) onPick;
/// Called when the user dismisses the picker without choosing.
final VoidCallback onCancel;
/// Header label. The /effort picker reuses this card with its own title
/// and an exact-match [isCurrent] (T-412).
final String title;
/// Marks the active entry. The model default ([modelOptionIsCurrent]) also
/// alias-matches (`sonnet` ⊂ `claude-sonnet-4-6`); effort needs exact match
/// (`high` would falsely match inside `xhigh`).
final bool Function(ModelOption option, String? current) isCurrent;
@override
State<ModelPickerCard> createState() => _ModelPickerCardState();
}
class _ModelPickerCardState extends State<ModelPickerCard> {
late int _highlight = _initialHighlight();
int _initialHighlight() {
for (var i = 0; i < widget.models.length; i++) {
if (widget.isCurrent(widget.models[i], widget.currentModel)) return i;
}
return 0;
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent || !node.hasPrimaryFocus) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored;
final key = e.logicalKey;
if (key == LogicalKeyboardKey.escape) {
widget.onCancel();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
setState(() => _highlight = (_highlight + 1) % widget.models.length);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowUp) {
setState(() => _highlight = (_highlight - 1 + widget.models.length) % widget.models.length);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
widget.onPick(widget.models[_highlight].value);
return KeyEventResult.handled;
}
final digit = _digitOf(key);
if (digit != null && digit >= 1 && digit <= widget.models.length) {
widget.onPick(widget.models[digit - 1].value);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
static int? _digitOf(LogicalKeyboardKey key) {
const digits = [
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
];
const numpad = [
LogicalKeyboardKey.numpad1,
LogicalKeyboardKey.numpad2,
LogicalKeyboardKey.numpad3,
LogicalKeyboardKey.numpad4,
LogicalKeyboardKey.numpad5,
LogicalKeyboardKey.numpad6,
LogicalKeyboardKey.numpad7,
LogicalKeyboardKey.numpad8,
LogicalKeyboardKey.numpad9,
];
var i = digits.indexOf(key);
if (i < 0) i = numpad.indexOf(key);
return i < 0 ? null : i + 1;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border(top: BorderSide(color: tokens.statusInfo, width: 2)),
),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusInfo),
const Spacer(),
ClideText('↑↓ · 1-${widget.models.length} · Enter · Esc', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
],
),
const SizedBox(height: 8),
for (var i = 0; i < widget.models.length; i++) _row(tokens, i),
const SizedBox(height: 6),
Row(
children: [
const Spacer(),
ClideButton(label: 'cancel', variant: ClideButtonVariant.subtle, onPressed: widget.onCancel),
],
),
],
),
),
);
}
Widget _row(SurfaceTokens tokens, int i) {
final m = widget.models[i];
final current = widget.isCurrent(m, widget.currentModel);
final highlighted = i == _highlight;
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ClideButton(
label: '${i + 1}. ${current ? '' : ''} ${m.displayName}${m.description.isEmpty ? '' : '${m.description}'}',
variant: highlighted ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
onPressed: () => widget.onPick(m.value),
),
);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
/// Rendered in the composer zone (not inline in the conversation) so /// Rendered in the composer zone (not inline in the conversation) so
/// interaction and conversation widgets don't mix — the pane swaps it in for /// interaction and conversation widgets don't mix — the pane swaps it in for
/// the text input while a prompt is open. The decision is returned via /// the text input while a prompt is open. The decision is returned via
/// [onResolve]; the pane then removes the card. /// `onResolve`; the pane then removes the card.
/// ///
/// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no /// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no
/// hover-revealed chrome that would fight the buttons. /// hover-revealed chrome that would fight the buttons.
@@ -2,8 +2,8 @@
/// ///
/// A session is a `claude` stream-json process clide spawns and renders; a /// A session is a `claude` stream-json process clide spawns and renders; a
/// pane is just a *view* on one. The orchestrator decouples a session's /// pane is just a *view* on one. The orchestrator decouples a session's
/// lifecycle from any pane: [spawn] starts and registers it, [show]/[hide] /// lifecycle from any pane: `spawn` starts and registers it, `show`/`hide`
/// toggle visibility WITHOUT tearing the process down, and [close] kills it. /// toggle visibility WITHOUT tearing the process down, and `close` kills it.
/// This is the one primitive behind teammate / secondary tab / forked branch /// This is the one primitive behind teammate / secondary tab / forked branch
/// (Phase 2): they are all just managed sessions shown as panes. /// (Phase 2): they are all just managed sessions shown as panes.
/// ///
@@ -49,6 +49,7 @@ class SpawnSpec {
this.team = false, this.team = false,
this.memberName, this.memberName,
this.forkSourceSessionId, this.forkSourceSessionId,
this.effort,
}); });
final String id; final String id;
@@ -81,6 +82,12 @@ class SpawnSpec {
/// Takes precedence over [resume]/[sessionId] for arg selection. /// Takes precedence over [resume]/[sessionId] for arg selection.
final String? forkSourceSessionId; final String? forkSourceSessionId;
/// Effort level passed to `claude --effort` (low/medium/high/xhigh/max,
/// T-412). Null spawns without the flag — the CLI uses its configured
/// default (settings.json `effortLevel`). No set_effort control subtype
/// exists, so changing effort means respawn-with-resume carrying this.
final String? effort;
/// Whether this spec spawns a forked session. /// Whether this spec spawns a forked session.
bool get isFork => forkSourceSessionId != null; bool get isFork => forkSourceSessionId != null;
} }
@@ -238,7 +245,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
preambles.add(_teamSystemPrompt(name, spec.role)); preambles.add(_teamSystemPrompt(name, spec.role));
} }
final bootstrap = agentBootstrap(spec.cwd, base: spec.env); final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs]; sessionArgs = [
'--append-system-prompt',
preambles.join('\n\n'),
...bootstrap.extraArgs,
if (spec.effort != null) ...['--effort', spec.effort!],
...sessionArgs,
];
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta); final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start(); final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
+110 -2
View File
@@ -30,8 +30,29 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
/// Slash commands clide handles itself instead of forwarding to Claude: /// Slash commands clide handles itself instead of forwarding to Claude:
/// Claude Code's own handling forks the session to a new id that clide's /// Claude Code's own handling forks the session to a new id that clide's
/// transcript reader can't follow, so clide owns the semantics (T-156). /// transcript reader can't follow, so clide owns the semantics (T-156).
/// `/fork` branches the current session into a new pane (T-172). /// `/fork` branches the current session into a new pane (T-172). `/model`
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'}; /// is interactive in the CLI's TUI only — forwarded it does nothing — so
/// clide owns it as a set_model control request / picker (T-408). `/effort`
/// has no control subtype, so clide owns it as a respawn-with-resume
/// carrying `--effort` (T-412). `/permissions` is a picker over
/// set_permission_mode; the rest navigate to clide surfaces (T-413):
/// /status//config//mcp//agents//hooks → the Claude sidebar tabs,
/// /memory → CLAUDE.md in the editor, /help → a local command summary.
const Set<String> kClideOwnedCommands = {
'clear',
'resume',
'fork',
'model',
'effort',
'permissions',
'status',
'config',
'mcp',
'agents',
'hooks',
'memory',
'help',
};
/// The clide-owned command in [text] (a single-line leading-slash token in /// The clide-owned command in [text] (a single-line leading-slash token in
/// [kClideOwnedCommands]), or null. /// [kClideOwnedCommands]), or null.
@@ -40,6 +61,93 @@ String? clideOwnedCommand(String text) {
return token != null && kClideOwnedCommands.contains(token) ? token : null; return token != null && kClideOwnedCommands.contains(token) ? token : null;
} }
/// Where slash input goes (T-411). One source of truth so a TUI-only command
/// neither errors raw from the CLI nor bracket-pastes to the model as text
/// (burning a real turn — observed with /effort on claude 2.1.175).
enum SlashRoute {
/// clide implements it natively ([kClideOwnedCommands]).
owned,
/// The CLI handles it headless — advertised in the `initialize` handshake's
/// `slash_commands` (skills + the headless builtins: compact, context, …).
forward,
/// A known TUI-only builtin: never forwarded; clide shows a local notice
/// with the clide-native way ([kTuiOnlyCommands]).
unavailable,
}
/// Claude Code TUI-only builtins (probed against 2.1.175: not advertised in
/// stream-json, and forwarding would either error "isn't available in this
/// environment" or — worse, for un-advertised tokens — bracket-paste to the
/// model as literal text). Value = the clide-native pointer shown in the
/// notice card. Commands clide later implements move to [kClideOwnedCommands].
const Map<String, String> kTuiOnlyCommands = {
'effort': '', // owned (T-412) — only routes here if ever removed from owned
'status': '', // owned (T-413)
'cost': 'cost and context usage live in the Claude sidebar (Activity tab)',
'context': '', // advertised on current CLIs — only routes here on older ones
'help': '', // owned (T-413)
'config': '', // owned (T-413)
'permissions': '', // owned (T-413)
'memory': '', // owned (T-413)
'mcp': '', // owned (T-413)
'agents': '', // owned (T-413)
'hooks': '', // owned (T-413)
'todos': "Claude's task list docks above the composer",
'model': '', // owned (T-408) — only routes here if ever removed from owned
'doctor': 'run `claude doctor` in a terminal',
'login': 'run `claude` in a terminal and use /login there',
'logout': 'run `claude` in a terminal and use /logout there',
'exit': 'close the pane or switch sessions instead',
'vim': 'clide ships its own editor vim mode',
'add-dir': '',
'bashes': '',
'bug': '',
'export': '',
'fast': '',
'ide': "you're already in one",
'install-github-app': '',
'migrate-installer': '',
'output-style': '',
'pr-comments': '',
'privacy-settings': '',
'release-notes': '',
'rewind': '',
'statusline': '',
'terminal-setup': '',
'upgrade': '',
};
/// Route [text] (composer input). Null when it isn't slash-command input —
/// send it as a normal message. Precedence: owned > advertised > TUI-only
/// catalog > forward (unknown tokens stay literal text via bracketed paste).
SlashRoute? routeSlashCommand(String text, {required Iterable<String> advertised}) {
final token = slashCommandToken(text);
if (token == null) return null;
if (kClideOwnedCommands.contains(token)) return SlashRoute.owned;
if (advertised.contains(token)) return SlashRoute.forward;
if (kTuiOnlyCommands.containsKey(token)) return SlashRoute.unavailable;
return SlashRoute.forward;
}
/// The notice text for a TUI-only [token] — the CLI's own phrasing plus the
/// clide-native pointer when the catalog has one.
String tuiOnlyNotice(String token) {
final hint = kTuiOnlyCommands[token] ?? '';
final base = "/$token is a Claude Code TUI command — it isn't available in clide's conversation pane.";
return hint.isEmpty ? base : '$base\n$hint';
}
/// The argument text after the command token — `"/model sonnet"` → `"sonnet"`
/// — trimmed; empty when there is none (`"/model"`). Null when [text] isn't
/// single-line leading-slash input.
String? slashCommandArg(String text) {
if (slashCommandToken(text) == null) return null;
final ws = text.indexOf(RegExp(r'\s'));
return ws < 0 ? '' : text.substring(ws + 1).trim();
}
bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n'; bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n';
/// An in-progress slash query at the cursor — the `/` position and the word /// An in-progress slash query at the cursor — the `/` position and the word
+190 -16
View File
@@ -19,6 +19,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/src/util/value_stream.dart'; import 'package:clide/src/util/value_stream.dart';
/// The claude subprocess, abstracted so tests drive it without spawning. /// The claude subprocess, abstracted so tests drive it without spawning.
@@ -147,6 +148,53 @@ abstract class McpServer {
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments); Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments);
} }
/// A model selectable for a session, from the `initialize` control_response's
/// `models[]` (T-408). Pure data, Flutter-free.
class ModelOption {
const ModelOption({required this.value, required this.displayName, this.description = ''});
/// The id/alias sent in `set_model` — e.g. `default`, `sonnet`, `opus`.
final String value;
/// Human label, e.g. `Sonnet`.
final String displayName;
/// One-line blurb shown muted next to the label.
final String description;
}
/// Effort levels `claude --effort` accepts (probed against 2.1.175). There is
/// NO set_effort control subtype (probed: rejected), so changing effort
/// respawns the session with the flag — resume keeps the conversation (T-412).
/// Expressed as [ModelOption]s so the /effort picker reuses the /model card.
const List<ModelOption> kEffortLevels = [
ModelOption(value: 'low', displayName: 'low', description: 'fastest, minimal thinking'),
ModelOption(value: 'medium', displayName: 'medium', description: 'balanced'),
ModelOption(value: 'high', displayName: 'high', description: 'thorough'),
ModelOption(value: 'xhigh', displayName: 'xhigh', description: 'deeper reasoning'),
ModelOption(value: 'max', displayName: 'max', description: 'maximum thinking budget'),
];
/// Permission modes for the /permissions picker (T-413), set over the
/// set_permission_mode control request. Bypass is last and explicit — the
/// footgun stays visible but never the default reach (T-181).
const List<ModelOption> kPermissionModes = [
ModelOption(value: 'default', displayName: 'default', description: 'ask before sensitive tools'),
ModelOption(value: 'acceptEdits', displayName: 'acceptEdits', description: 'auto-approve file edits'),
ModelOption(value: 'plan', displayName: 'plan', description: 'read-only planning mode'),
ModelOption(value: 'bypassPermissions', displayName: 'bypassPermissions', description: 'no prompts at all — careful'),
];
/// Fallback picker entries for when the `initialize` response hasn't arrived
/// (or carried no models): the stable aliases every claude build accepts
/// (T-408). `default` resets to the CLI's configured model.
const List<ModelOption> kFallbackModels = [
ModelOption(value: 'default', displayName: 'Default', description: 'recommended — the CLI\'s configured model'),
ModelOption(value: 'sonnet', displayName: 'Sonnet', description: 'fast, great for everyday tasks'),
ModelOption(value: 'opus', displayName: 'Opus', description: 'most capable'),
ModelOption(value: 'haiku', displayName: 'Haiku', description: 'fastest, lightweight'),
];
/// An interactive prompt Claude is blocked on, from the stream-json control /// An interactive prompt Claude is blocked on, from the stream-json control
/// channel (a `can_use_tool` control_request) — a tool needing permission, or /// channel (a `can_use_tool` control_request) — a tool needing permission, or
/// an `AskUserQuestion`. Pure data; the decision goes back via /// an `AskUserQuestion`. Pure data; the decision goes back via
@@ -261,6 +309,27 @@ class StreamJsonSession {
String? _claudeSessionId; String? _claudeSessionId;
int _localSeq = 0; int _localSeq = 0;
/// The `initialize` handshake's request id — its control_response carries
/// the selectable `models[]` (T-408).
String? _initRequestId;
/// In-flight `set_model` request ids → the model the status held before the
/// optimistic merge, so an error response can roll it back (T-408).
final _pendingSetModel = <String, String?>{};
List<ModelOption> _availableModels = const [];
/// Models selectable for this session, from the `initialize` response.
/// Empty until that response arrives (callers fall back to
/// [kFallbackModels]).
List<ModelOption> get availableModels => _availableModels;
final _modelErrorCtl = StreamController<String>.broadcast();
/// Errors from rejected `set_model` requests (e.g. an unknown model name),
/// for the pane to surface (T-408).
Stream<String> get modelErrors => _modelErrorCtl.stream;
/// Token-by-token streaming state (T-168, wire shape verified by T-184). /// Token-by-token streaming state (T-168, wire shape verified by T-184).
/// ///
/// With `--include-partial-messages`, claude emits the in-progress reply as /// With `--include-partial-messages`, claude emits the in-progress reply as
@@ -309,6 +378,20 @@ class StreamJsonSession {
Map<String, bool> get toolUseOutcomes => _toolUseOutcome; Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
Set<String> get quietErrorToolUseIds => _quietErrorToolUses; Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
/// Live Workflow runs, keyed by their launching `Workflow` tool-use id
/// (T-416). Accumulated from the out-of-band `system` task_* events the
/// harness emits while a workflow runs in the background; the conversation
/// card and the sidebar indicator both read this snapshot. Ephemeral — the
/// events aren't in the resumed transcript, so this is empty on reload.
final _workflows = <String, WorkflowRun>{};
final _workflowsCtl = ValueStream<Map<String, WorkflowRun>>.seeded(const {});
/// The current workflow runs, keyed by launching tool-use id.
Map<String, WorkflowRun> get workflows => Map.unmodifiable(_workflows);
/// Emits the workflow-run map whenever a `system` task event updates it.
Stream<Map<String, WorkflowRun>> get workflowsStream => _workflowsCtl.stream;
/// Whether a turn is in flight (between a send and claude's `result`). Drives /// Whether a turn is in flight (between a send and claude's `result`). Drives
/// the composer's Stop affordance. /// the composer's Stop affordance.
bool _busy = false; bool _busy = false;
@@ -368,22 +451,22 @@ class StreamJsonSession {
// code is not (T-361). // code is not (T-361).
final exit = _proc.exitCode; final exit = _proc.exitCode;
if (exit != null) unawaited(exit.then(_onExit)); if (exit != null) unawaited(exit.then(_onExit));
// Declaring our in-process MCP servers in the `initialize` handshake is what // The `initialize` handshake is side-effect-free (verified in the protocol
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent // spike) and does double duty: declaring our in-process MCP servers is what
// when we actually host a server, so a plain session is unchanged. // makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
if (_mcpServers.isNotEmpty) { // response's `models[]` feeds the /model picker (T-408).
_proc.writeLine( _initRequestId = 'init-${_localSeq++}';
jsonEncode({ _proc.writeLine(
'type': 'control_request', jsonEncode({
'request_id': 'init-${_localSeq++}', 'type': 'control_request',
'request': { 'request_id': _initRequestId,
'subtype': 'initialize', 'request': {
'hooks': <String, dynamic>{}, 'subtype': 'initialize',
'sdkMcpServers': [for (final s in _mcpServers) s.name], 'hooks': <String, dynamic>{},
}, 'sdkMcpServers': [for (final s in _mcpServers) s.name],
}), },
); }),
} );
} }
void _onLine(String line) { void _onLine(String line) {
@@ -411,6 +494,12 @@ class StreamJsonSession {
_onControlRequest(ev); _onControlRequest(ev);
return; return;
} }
// Responses to OUR control requests: the initialize result (models) and
// set_model acks/errors (T-408).
if (ev['type'] == 'control_response') {
_onControlResponse(ev);
return;
}
// A `result` ends the turn — clear the busy/interruptible state and reset // A `result` ends the turn — clear the busy/interruptible state and reset
// streaming state so the next turn is fresh. // streaming state so the next turn is fresh.
if (ev['type'] == 'result') { if (ev['type'] == 'result') {
@@ -427,6 +516,15 @@ class StreamJsonSession {
return; return;
} }
// Workflow run progress (T-416): the harness reports a backgrounded Workflow
// tool's fan-out on out-of-band `system` task_* events keyed by the
// launching tool-use id. Fold them into the run snapshot and notify; they
// carry no conversation item, so don't fall through to the parser.
if (isWorkflowSystemEvent(ev)) {
_onWorkflowEvent(ev);
return;
}
// Finalise a streamed reply: when the real text `assistant` event for a // Finalise a streamed reply: when the real text `assistant` event for a
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid // message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
// so the controller replaces the placeholder in place rather than appending // so the controller replaces the placeholder in place rather than appending
@@ -500,6 +598,15 @@ class StreamJsonSession {
} }
} }
/// Fold one workflow `system` task event into its run snapshot, keyed by the
/// launching tool-use id, and publish the updated map (T-416).
void _onWorkflowEvent(Map<String, dynamic> ev) {
final id = ev['tool_use_id'] as String;
final prior = _workflows[id] ?? WorkflowRun(toolUseId: id);
_workflows[id] = prior.foldEvent(ev);
_workflowsCtl.add(Map.unmodifiable(_workflows));
}
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt] /// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
/// item the UI resolves; every other subtype is answered with an error so /// item the UI resolves; every other subtype is answered with an error so
/// the turn never hangs waiting on us (D-78). /// the turn never hangs waiting on us (D-78).
@@ -742,6 +849,18 @@ class StreamJsonSession {
_setBusy(true); _setBusy(true);
} }
/// Inject a clide-local notice card into the conversation — nothing is sent
/// to claude. Used by the slash-command router for TUI-only commands
/// (T-411); renders as the muted synthetic "clide" card.
void addLocalNotice(String text) {
_items.add(AssistantTextMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text, synthetic: true));
}
/// Record the effort level this session was spawned with (`--effort`,
/// T-412). The wire never reports effort, so the spawner tells the status
/// what it set; the status line / sidebar read it from [SessionStatus].
void noteEffort(String level) => _mergeStatus(SessionStatus(effort: level));
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends /// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
/// the `interrupt` control_request; claude cancels the current turn and ends /// the `interrupt` control_request; claude cancels the current turn and ends
/// it with a `result`, which clears [busy]. Safe to call when idle. /// it with a `result`, which clears [busy]. Safe to call when idle.
@@ -778,6 +897,59 @@ class StreamJsonSession {
_mergeStatus(SessionStatus(permissionMode: mode)); _mergeStatus(SessionStatus(permissionMode: mode));
} }
/// Set the model for subsequent turns (T-408). Sends a `set_model`
/// control_request; [model] is an alias (`sonnet`, `opus`) or full id, and
/// `default` resets to the CLI's configured model. The status merges
/// optimistically (mirroring [setPermissionMode]); an error response rolls
/// it back and surfaces on [modelErrors].
void setModel(String model) {
final rid = 'set-model-${_localSeq++}';
_pendingSetModel[rid] = _status.model;
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': rid,
'request': {'subtype': 'set_model', 'model': model},
}),
);
// `default` resolves to a model only the CLI knows — leave the status to
// the next assistant event in that case.
if (model != 'default') _mergeStatus(SessionStatus(model: model));
}
/// A `control_response` to one of our requests: capture the initialize
/// result's `models[]`, and roll back + surface a rejected set_model (T-408).
void _onControlResponse(Map<String, dynamic> ev) {
final resp = ev['response'];
if (resp is! Map) return;
final rid = resp['request_id'] as String?;
if (rid == null) return;
final isError = resp['subtype'] == 'error';
if (rid == _initRequestId && !isError) {
final result = resp['response'];
final models = result is Map ? result['models'] : null;
if (models is List) {
_availableModels = List.unmodifiable([
for (final m in models)
if (m is Map && m['value'] is String)
ModelOption(
value: m['value'] as String,
displayName: m['displayName'] as String? ?? m['value'] as String,
description: m['description'] as String? ?? '',
),
]);
}
return;
}
if (_pendingSetModel.containsKey(rid)) {
final previous = _pendingSetModel.remove(rid);
if (isError) {
if (previous != null) _mergeStatus(SessionStatus(model: previous));
_modelErrorCtl.add(resp['error'] as String? ?? 'model change rejected');
}
}
}
/// The process exited under a live session. Flip every "in flight" /// The process exited under a live session. Flip every "in flight"
/// surface off so the pane reflects reality instead of spinning forever. /// surface off so the pane reflects reality instead of spinning forever.
void _onExit(int code) { void _onExit(int code) {
@@ -799,9 +971,11 @@ class StreamJsonSession {
await _proc.kill(); await _proc.kill();
await _items.close(); await _items.close();
await _statusCtl.close(); await _statusCtl.close();
await _workflowsCtl.close();
await _sessionIdCtl.close(); await _sessionIdCtl.close();
await _pendingCtl.close(); await _pendingCtl.close();
await _busyCtl.close(); await _busyCtl.close();
await _endCtl.close(); await _endCtl.close();
await _modelErrorCtl.close();
} }
} }
+2 -2
View File
@@ -5,8 +5,8 @@
/// and the full workspace pane read from this one model — they share state, /// and the full workspace pane read from this one model — they share state,
/// they do NOT each hold their own copy. /// they do NOT each hold their own copy.
/// ///
/// [postAsUser] is the user's write path: it routes by @tag (one agent or /// `postAsUser` is the user's write path: it routes by @tag (one agent or
/// broadcast) and, when the interrupt flag is set, calls [interrupt()] on the /// broadcast) and, when the interrupt flag is set, calls `interrupt()` on the
/// target session THEN delivers the message. /// target session THEN delivers the message.
/// ///
/// Flutter-free on purpose: this module (like [TeamBroker]) runs under /// Flutter-free on purpose: this module (like [TeamBroker]) runs under
@@ -2,7 +2,7 @@
/// ///
/// Displays the live broker chat timeline as colour-coded rows and provides a /// Displays the live broker chat timeline as colour-coded rows and provides a
/// quick @-post composer. Tapping the pop-out icon opens the full chat pane /// quick @-post composer. Tapping the pop-out icon opens the full chat pane
/// ([claude.team-chat] workspace tab). /// (`claude.team-chat` workspace tab).
/// ///
/// Both this widget and [TeamChatPane] read from the same [TeamChatModel] — /// Both this widget and [TeamChatPane] read from the same [TeamChatModel] —
/// there is one model, two surfaces. /// there is one model, two surfaces.
+31 -9
View File
@@ -24,7 +24,7 @@
/// ///
/// # Version drift-guard /// # Version drift-guard
/// If the envelope `version` field has an unfamiliar major version the reader /// If the envelope `version` field has an unfamiliar major version the reader
/// warns via [onWarn] (or stderr if omitted) and degrades gracefully — it /// warns via `onWarn` (or stderr if omitted) and degrades gracefully — it
/// parses whatever it can and skips the rest rather than crashing. /// parses whatever it can and skips the rest rather than crashing.
library; library;
@@ -114,12 +114,19 @@ final class AssistantTextMessage extends ConversationItem {
super.parentUuid, super.parentUuid,
super.parentToolUseId, super.parentToolUseId,
required this.text, required this.text,
this.synthetic = false,
}); });
final String text; final String text;
/// CLI-local output, not the model: the wire marks it `model: "<synthetic>"`
/// (a forwarded local command's response — /usage output, "/x isn't
/// available in this environment", …). clide-injected notices use it too.
/// Rendered as a muted "clide" card, never coral Claude prose (T-411).
final bool synthetic;
@override @override
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars)'; String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars${synthetic ? ', synthetic' : ''})';
} }
/// Extended thinking block from an assistant turn. /// Extended thinking block from an assistant turn.
@@ -220,7 +227,7 @@ class TranscriptReader {
/// [pollInterval] controls how often the reader polls for new data and /// [pollInterval] controls how often the reader polls for new data and
/// session switches (default 500 ms). /// session switches (default 500 ms).
/// ///
/// [onWarn] receives warning messages from the version drift-guard. /// `onWarn` receives warning messages from the version drift-guard.
/// If omitted, warnings are written to stderr. /// If omitted, warnings are written to stderr.
TranscriptReader( TranscriptReader(
this.workspacePath, { this.workspacePath, {
@@ -412,7 +419,7 @@ class TranscriptReader {
} }
/// Parse a single JSONL line into its items (forwarding any version /// Parse a single JSONL line into its items (forwarding any version
/// warnings to [onWarn]). Public so tests exercise the real parser. /// warnings to `onWarn`). Public so tests exercise the real parser.
List<ConversationItem> parseLine(String line) { List<ConversationItem> parseLine(String line) {
final parsed = parseTranscriptChunk(line); final parsed = parseTranscriptChunk(line);
for (final w in parsed.warnings) { for (final w in parsed.warnings) {
@@ -426,7 +433,7 @@ class TranscriptReader {
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw, /// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
/// and the reader [merge]s deltas into a running status. /// and the reader [merge]s deltas into a running status.
class SessionStatus { class SessionStatus {
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo}); const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo, this.effort});
/// Assistant `message.model`, e.g. `claude-opus-4-7`. /// Assistant `message.model`, e.g. `claude-opus-4-7`.
final String? model; final String? model;
@@ -451,7 +458,13 @@ class SessionStatus {
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited. /// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
final String? rateLimitInfo; final String? rateLimitInfo;
bool get isEmpty => model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null; /// The session's effort level (`--effort`, T-412). The wire never reports
/// it — clide records what it spawned with via [StreamJsonSession.noteEffort];
/// null means the CLI default (settings.json `effortLevel`).
final String? effort;
bool get isEmpty =>
model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null && effort == null;
/// Overlay [other]'s non-null fields onto this one. /// Overlay [other]'s non-null fields onto this one.
SessionStatus merge(SessionStatus other) => SessionStatus( SessionStatus merge(SessionStatus other) => SessionStatus(
@@ -461,6 +474,7 @@ class SessionStatus {
cost: other.cost ?? cost, cost: other.cost ?? cost,
contextWindow: other.contextWindow ?? contextWindow, contextWindow: other.contextWindow ?? contextWindow,
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo, rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
effort: other.effort ?? effort,
); );
@override @override
@@ -471,10 +485,11 @@ class SessionStatus {
other.contextTokens == contextTokens && other.contextTokens == contextTokens &&
other.cost == cost && other.cost == cost &&
other.contextWindow == contextWindow && other.contextWindow == contextWindow &&
other.rateLimitInfo == rateLimitInfo; other.rateLimitInfo == rateLimitInfo &&
other.effort == effort;
@override @override
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo); int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo, effort);
} }
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and /// Result of [parseTranscriptChunk]: items, version-drift warnings, and
@@ -582,7 +597,9 @@ void _extractAssistantStatus(Map<String, dynamic> envelope, _StatusAcc status) {
final message = envelope['message'] as Map?; final message = envelope['message'] as Map?;
if (message == null) return; if (message == null) return;
final model = message['model'] as String?; final model = message['model'] as String?;
if (model != null && model.isNotEmpty) status.model = model; // "<synthetic>" marks CLI-local output (a forwarded local command's
// response) — not a model switch; it must not clobber the tracked model.
if (model != null && model.isNotEmpty && model != kSyntheticModel) status.model = model;
final usage = message['usage'] as Map?; final usage = message['usage'] as Map?;
if (usage != null) { if (usage != null) {
int n(String k) => (usage[k] as num?)?.toInt() ?? 0; int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
@@ -656,6 +673,9 @@ void _parseUserInto(
} }
} }
/// The model marker on CLI-local output (forwarded local-command responses).
const String kSyntheticModel = '<synthetic>';
void _parseAssistantInto( void _parseAssistantInto(
Map<String, dynamic> envelope, Map<String, dynamic> envelope,
String uuid, String uuid,
@@ -669,6 +689,7 @@ void _parseAssistantInto(
if (message == null) return; if (message == null) return;
final content = message['content']; final content = message['content'];
if (content is! List) return; if (content is! List) return;
final synthetic = (message['model'] as String?) == kSyntheticModel;
for (final item in content) { for (final item in content) {
if (item is! Map) continue; if (item is! Map) continue;
@@ -684,6 +705,7 @@ void _parseAssistantInto(
parentUuid: parentUuid, parentUuid: parentUuid,
parentToolUseId: parentToolUseId, parentToolUseId: parentToolUseId,
text: text, text: text,
synthetic: synthetic,
), ),
); );
} }
+234
View File
@@ -0,0 +1,234 @@
/// Live state of a Claude Code Workflow run (T-416).
///
/// A Workflow is the harness's multi-agent orchestration tool. The model calls
/// it as an ordinary `tool_use` (`name: "Workflow"`, `input: {script}`); the
/// tool returns immediately ("launched in background") and the run's real
/// progress arrives out-of-band on stream-json `type: "system"` events keyed by
/// the launching tool-use id. This file is the pure, Flutter-free model that
/// folds those events into a snapshot the conversation/sidebar surfaces render.
///
/// Wire shape (captured by the T-416 spike, claude 2.1.175):
/// - `task_started` — task_id, tool_use_id, description, workflow_name,
/// prompt (script source)
/// - `task_progress` — usage{total_tokens,tool_uses,duration_ms}, summary,
/// and `workflow_progress[]`, a DELTA list mixing
/// `{type:"workflow_phase", index, title}` and
/// `{type:"workflow_agent", index, label, phaseIndex?,
/// phaseTitle?, model, state(start|progress|done),
/// agentId?}` — partial, merged by index.
/// - `task_updated` — patch{status, end_time}
/// - `task_notification` — terminal status:"completed", summary, usage
///
/// Limit: these events are ephemeral (not persisted to the resumed transcript
/// JSONL), so live progress shows during the session; on reload only the tool
/// card + its "launched in background" result survive.
library;
/// Lifecycle of a single workflow agent, from its `state` field.
enum WorkflowAgentState { start, progress, done, unknown }
WorkflowAgentState parseWorkflowAgentState(Object? raw) => switch (raw) {
'start' || 'queued' || 'running' => WorkflowAgentState.start,
'progress' => WorkflowAgentState.progress,
'done' || 'complete' || 'completed' => WorkflowAgentState.done,
_ => WorkflowAgentState.unknown,
};
/// One phase declared by `meta.phases` / a `phase()` call.
class WorkflowPhase {
const WorkflowPhase({required this.index, required this.title});
final int index;
final String title;
}
/// One agent fanned out by the workflow. Fields accrete across `task_progress`
/// deltas — a later delta fills in `agentId` / upgrades `model` / advances
/// `state`, so [mergeDelta] overlays non-null fields onto the prior snapshot.
class WorkflowAgent {
const WorkflowAgent({
required this.index,
required this.label,
this.model,
this.state = WorkflowAgentState.start,
this.agentId,
this.phaseIndex,
this.phaseTitle,
});
final int index;
final String label;
final String? model;
final WorkflowAgentState state;
final String? agentId;
final int? phaseIndex;
final String? phaseTitle;
/// Fold a raw `workflow_agent` delta entry onto this snapshot, keeping prior
/// values where the delta omits a field.
WorkflowAgent mergeDelta(Map<String, dynamic> e) => WorkflowAgent(
index: index,
label: (e['label'] as String?)?.isNotEmpty == true ? e['label'] as String : label,
model: (e['model'] as String?) ?? model,
state: e.containsKey('state') ? parseWorkflowAgentState(e['state']) : state,
agentId: (e['agentId'] as String?) ?? agentId,
phaseIndex: (e['phaseIndex'] as num?)?.toInt() ?? phaseIndex,
phaseTitle: (e['phaseTitle'] as String?) ?? phaseTitle,
);
static WorkflowAgent fromDelta(Map<String, dynamic> e) => WorkflowAgent(
index: (e['index'] as num).toInt(),
label: (e['label'] as String?) ?? '',
model: e['model'] as String?,
state: parseWorkflowAgentState(e['state']),
agentId: e['agentId'] as String?,
phaseIndex: (e['phaseIndex'] as num?)?.toInt(),
phaseTitle: e['phaseTitle'] as String?,
);
}
/// An immutable snapshot of one workflow run. [foldEvent] returns a new snapshot
/// with a single `system` task event applied (the session keeps one per
/// launching tool-use id and replaces it as events arrive).
class WorkflowRun {
const WorkflowRun({
required this.toolUseId,
this.taskId,
this.name,
this.description,
this.summary,
this.done = false,
this.totalTokens,
this.toolUses,
this.durationMs,
this.phases = const {},
this.agents = const {},
});
/// The launching `Workflow` tool-use id — the join key to the conversation
/// card and across all of this run's system events.
final String toolUseId;
/// The harness task id (e.g. `wy01fihjt`), assigned at `task_started`.
final String? taskId;
/// `workflow_name` from `meta.name`.
final String? name;
final String? description;
final String? summary;
/// True once a `task_updated{status:completed}` or `task_notification`
/// terminal event lands.
final bool done;
final int? totalTokens;
final int? toolUses;
final int? durationMs;
/// Phase index → phase. Empty for a phase-less workflow.
final Map<int, WorkflowPhase> phases;
/// Agent index → agent snapshot.
final Map<int, WorkflowAgent> agents;
bool get running => !done;
int get agentCount => agents.length;
int get doneCount => agents.values.where((a) => a.state == WorkflowAgentState.done).length;
/// Agents in index order — the order the script fanned them out.
List<WorkflowAgent> get orderedAgents {
final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index));
return list;
}
/// Phases in index order.
List<WorkflowPhase> get orderedPhases {
final list = phases.values.toList()..sort((a, b) => a.index.compareTo(b.index));
return list;
}
WorkflowRun _copyWith({
String? taskId,
String? name,
String? description,
String? summary,
bool? done,
int? totalTokens,
int? toolUses,
int? durationMs,
Map<int, WorkflowPhase>? phases,
Map<int, WorkflowAgent>? agents,
}) => WorkflowRun(
toolUseId: toolUseId,
taskId: taskId ?? this.taskId,
name: name ?? this.name,
description: description ?? this.description,
summary: summary ?? this.summary,
done: done ?? this.done,
totalTokens: totalTokens ?? this.totalTokens,
toolUses: toolUses ?? this.toolUses,
durationMs: durationMs ?? this.durationMs,
phases: phases ?? this.phases,
agents: agents ?? this.agents,
);
/// Apply one `system` task event ([ev]) and return the updated snapshot.
/// [ev] must already be the decoded envelope; unknown subtypes return `this`.
WorkflowRun foldEvent(Map<String, dynamic> ev) {
switch (ev['subtype']) {
case 'task_started':
return _copyWith(taskId: ev['task_id'] as String?, name: ev['workflow_name'] as String?, description: ev['description'] as String?);
case 'task_progress':
return _foldProgress(ev);
case 'task_updated':
final patch = ev['patch'];
final status = patch is Map ? patch['status'] as String? : null;
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null);
case 'task_notification':
final status = ev['status'] as String?;
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null, summary: ev['summary'] as String?)._foldUsage(ev['usage']);
default:
return this;
}
}
WorkflowRun _foldProgress(Map<String, dynamic> ev) {
final phases = Map<int, WorkflowPhase>.from(this.phases);
final agents = Map<int, WorkflowAgent>.from(this.agents);
final progress = ev['workflow_progress'];
if (progress is List) {
for (final raw in progress) {
if (raw is! Map) continue;
final e = raw.cast<String, dynamic>();
final idx = (e['index'] as num?)?.toInt();
if (idx == null) continue;
switch (e['type']) {
case 'workflow_phase':
phases[idx] = WorkflowPhase(index: idx, title: (e['title'] as String?) ?? 'phase $idx');
case 'workflow_agent':
final prior = agents[idx];
agents[idx] = prior != null ? prior.mergeDelta(e) : WorkflowAgent.fromDelta(e);
}
}
}
return _copyWith(summary: ev['summary'] as String?, phases: phases, agents: agents)._foldUsage(ev['usage']);
}
WorkflowRun _foldUsage(Object? usage) {
if (usage is! Map) return this;
return _copyWith(
totalTokens: (usage['total_tokens'] as num?)?.toInt(),
toolUses: (usage['tool_uses'] as num?)?.toInt(),
durationMs: (usage['duration_ms'] as num?)?.toInt(),
);
}
}
/// The `system` subtypes that carry workflow run progress (T-416). Other system
/// subtypes (`init`, `hook_*`, `thinking_tokens`) are unrelated and left alone.
const Set<String> kWorkflowSystemSubtypes = {'task_started', 'task_progress', 'task_updated', 'task_notification'};
/// True when [ev] is a `system` event carrying workflow run progress that names
/// a launching tool-use id we can key on.
bool isWorkflowSystemEvent(Map<String, dynamic> ev) =>
ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true;
@@ -53,6 +53,23 @@ class DefaultLayoutExtension extends ClideExtension {
// Editor split (D-049, D-054) // Editor split (D-049, D-054)
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor), CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor), CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
// Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every
// preset; the vim preset additionally binds gt/gT to these (T-405 part 2,
// once a global multi-chord matcher lands — see T-404).
CommandContribution(
id: 'workspace.tab.next',
command: 'workspace.tab.next',
title: 'Next Workspace Tab',
defaultBinding: 'ctrl+pagedown',
run: _nextWorkspaceTab,
),
CommandContribution(
id: 'workspace.tab.previous',
command: 'workspace.tab.previous',
title: 'Previous Workspace Tab',
defaultBinding: 'ctrl+pageup',
run: _prevWorkspaceTab,
),
// Sidebar section switching (D-054): alt+1 through alt+5 // Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
CommandContribution( CommandContribution(
@@ -195,6 +212,27 @@ class DefaultLayoutExtension extends ClideExtension {
return IpcResponse.ok(id: '', data: {'focused': 'workspace'}); return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
} }
Future<IpcResponse> _nextWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: true);
Future<IpcResponse> _prevWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: false);
/// Cycle the workspace tab strip with wraparound (T-405). A no-op when there
/// are fewer than two tabs. Activating a tab also focuses the workspace slot
/// so the newly-shown pane takes keyboard focus.
Future<IpcResponse> _cycleWorkspaceTab({required bool forward}) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
final tabs = ctx.panels.tabsFor(Slots.workspace);
if (tabs.length < 2) return IpcResponse.ok(id: '', data: const {'cycled': false});
final active = ctx.panels.activeTabIn(Slots.workspace);
final cur = tabs.indexWhere((t) => t.id == active);
final start = cur < 0 ? 0 : cur;
final next = (start + (forward ? 1 : -1) + tabs.length) % tabs.length;
final nextId = tabs[next].id;
ctx.panels.activateTab(Slots.workspace, nextId);
ctx.focus.setActive(slot: Slots.workspace, contributionId: nextId);
return IpcResponse.ok(id: '', data: {'active': nextId});
}
Future<IpcResponse> _focusRight(List<String> args) async { Future<IpcResponse> _focusRight(List<String> args) async {
final ctx = _ctx; final ctx = _ctx;
if (ctx == null) return _notActivated(); if (ctx == null) return _notActivated();
+9 -1
View File
@@ -13,7 +13,7 @@ import 'vim_edit_ops.dart';
/// Tier-2 editor pane. Shows one tab per open buffer via the shared /// Tier-2 editor pane. Shows one tab per open buffer via the shared
/// [MultitabPane] (the same strip the Claude pane uses); the body /// [MultitabPane] (the same strip the Claude pane uses); the body
/// reflects the daemon's active buffer. The daemon ([EditorRegistry]) /// reflects the daemon's active buffer. The daemon (`EditorRegistry`)
/// is the source of truth for which buffers are open and which is /// is the source of truth for which buffers are open and which is
/// active — the local [MultitabController] is reconciled from it, and /// active — the local [MultitabController] is reconciled from it, and
/// tab gestures (select / close) are routed back as `editor.activate` /// tab gestures (select / close) are routed back as `editor.activate`
@@ -54,10 +54,16 @@ class _EditorViewState extends State<EditorView> {
super.initState(); super.initState();
_text = SyntaxTextController(syntax: _syntax); _text = SyntaxTextController(syntax: _syntax);
_focus = FocusNode(); _focus = FocusNode();
_focus.addListener(_onFocusChanged);
_text.addListener(_onTextChanged); _text.addListener(_onTextChanged);
_tabs.addListener(_onTabsChanged); _tabs.addListener(_onTabsChanged);
} }
/// Publish `editor.focused` so non-editor panes can guard their vim nav
/// bindings (`!editor.focused`) — when the editor holds focus, j/k/h/l/gg/G
/// stay buffer motions; when a pane holds focus they become nav (T-406).
void _onFocusChanged() => _keymap?.setScopeFlag('editor.focused', _focus.hasFocus);
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -75,12 +81,14 @@ class _EditorViewState extends State<EditorView> {
void dispose() { void dispose() {
_text.removeListener(_onTextChanged); _text.removeListener(_onTextChanged);
_text.dispose(); _text.dispose();
_focus.removeListener(_onFocusChanged);
_focus.dispose(); _focus.dispose();
_tabs.removeListener(_onTabsChanged); _tabs.removeListener(_onTabsChanged);
_tabs.dispose(); _tabs.dispose();
_controller?.removeListener(_onControllerChanged); _controller?.removeListener(_onControllerChanged);
_controller?.dispose(); _controller?.dispose();
_keymap?.removeListener(_onModeChanged); _keymap?.removeListener(_onModeChanged);
_keymap?.clearScopeFlag('editor.focused');
super.dispose(); super.dispose();
} }
@@ -9,11 +9,26 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// One row in the flattened, currently-visible tree (T-406). The visible set is
/// a pre-order walk of the root plus the children of every expanded directory —
/// the same order the tree renders — so a selection cursor can move over it with
/// j/k.
@immutable
class TreeNode {
const TreeNode({required this.path, required this.name, required this.isDirectory, required this.depth});
final String path;
final String name;
final bool isDirectory;
final int depth;
}
class FileTreeController extends ChangeNotifier { class FileTreeController extends ChangeNotifier {
FileTreeController({required this.ipc, required this.events}) { FileTreeController({required this.ipc, required this.events}) {
_eventSub = events.on<DaemonEvent>().listen(_onEvent); _eventSub = events.on<DaemonEvent>().listen(_onEvent);
@@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier {
final Map<String, List<FileEntry>> _entries = {}; final Map<String, List<FileEntry>> _entries = {};
List<FileEntry>? entriesFor(String path) => _entries[path]; List<FileEntry>? entriesFor(String path) => _entries[path];
/// Display name of the workspace root row ('' path).
String get rootName => _rootPath?.split(Platform.pathSeparator).last ?? '';
// -- Keyboard selection cursor (T-406) -------------------------------------
/// The path of the currently selected row, or null when nothing is selected.
/// '' is the workspace-root row.
String? _selectedPath;
String? get selectedPath => _selectedPath;
/// The flattened, currently-visible rows in render order: the root, then the
/// children of every expanded directory, depth-first.
List<TreeNode> visibleNodes() {
final out = <TreeNode>[];
if (_rootPath == null) return out;
out.add(TreeNode(path: '', name: rootName, isDirectory: true, depth: 0));
if (isExpanded('')) _appendChildren('', 1, out);
return out;
}
void _appendChildren(String path, int depth, List<TreeNode> out) {
final entries = _entries[path];
if (entries == null) return;
for (final e in entries) {
out.add(TreeNode(path: e.path, name: e.name, isDirectory: e.isDirectory, depth: depth));
if (e.isDirectory && _expanded.contains(e.path)) _appendChildren(e.path, depth + 1, out);
}
}
TreeNode? _selectedNode([List<TreeNode>? nodes]) {
final list = nodes ?? visibleNodes();
for (final n in list) {
if (n.path == _selectedPath) return n;
}
return null;
}
/// Move the selection cursor [delta] rows (negative = up), clamped to the
/// visible list. A first move with nothing selected lands on the first row
/// (down) or last row (up).
void moveSelection(int delta) {
final nodes = visibleNodes();
if (nodes.isEmpty) return;
final cur = nodes.indexWhere((n) => n.path == _selectedPath);
final next = cur < 0 ? (delta > 0 ? 0 : nodes.length - 1) : (cur + delta).clamp(0, nodes.length - 1);
if (nodes[next].path == _selectedPath) return;
_selectedPath = nodes[next].path;
notifyListeners();
}
/// Select the first ([top]) or last visible row — vim gg / G.
void selectEdge({required bool top}) {
final nodes = visibleNodes();
if (nodes.isEmpty) return;
final path = (top ? nodes.first : nodes.last).path;
if (path == _selectedPath) return;
_selectedPath = path;
notifyListeners();
}
/// Collapse the selected directory, or — if it's already collapsed (or a
/// file) — step the selection out to its parent row (vim `h`).
Future<void> collapseOrOut() async {
final node = _selectedNode();
if (node == null) return;
if (node.isDirectory && node.path != '' && _expanded.contains(node.path)) {
await toggle(node.path); // collapse in place; selection stays on the dir
return;
}
if (node.path == '') return; // already at root
_selectedPath = _parentOf(node.path);
notifyListeners();
}
/// Expand the selected directory, or — if it's already expanded — step the
/// selection into its first child (vim `l`). A file is a no-op.
Future<void> expandOrInto() async {
final node = _selectedNode();
if (node == null || !node.isDirectory) return;
if (!_expanded.contains(node.path)) {
await toggle(node.path); // expand
return;
}
final children = _entries[node.path];
if (children != null && children.isNotEmpty) {
_selectedPath = children.first.path;
notifyListeners();
}
}
/// Resolve the selected row to an action target for the view: a directory to
/// toggle, or a file path to open (vim `o` / `enter`). Returns null when
/// nothing is selected.
({bool isDirectory, String path})? activateTarget() {
final node = _selectedNode();
if (node == null) return null;
return (isDirectory: node.isDirectory, path: node.path);
}
List<FileEntry> allLoadedEntries() { List<FileEntry> allLoadedEntries() {
final out = <FileEntry>[]; final out = <FileEntry>[];
for (final list in _entries.values) { for (final list in _entries.values) {
+101 -23
View File
@@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget {
class _FileTreeViewState extends State<FileTreeView> { class _FileTreeViewState extends State<FileTreeView> {
FileTreeController? _controller; FileTreeController? _controller;
String _filter = ''; String _filter = '';
final ScrollController _scroll = ScrollController();
/// Key on the currently-selected row, so a keyboard move can scroll it into
/// view (T-406).
final GlobalKey _selectedKey = GlobalKey();
/// Half-page step for ctrl+d / ctrl+u over the flattened tree.
static const int _pageStep = 10;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -39,9 +47,52 @@ class _FileTreeViewState extends State<FileTreeView> {
@override @override
void dispose() { void dispose() {
_controller?.dispose(); _controller?.dispose();
_scroll.dispose();
super.dispose(); super.dispose();
} }
void _onNav(NavIntent intent, int count, FileTreeController c) {
switch (intent) {
case NavDownIntent():
c.moveSelection(count);
case NavUpIntent():
c.moveSelection(-count);
case NavPageDownIntent():
c.moveSelection(_pageStep);
case NavPageUpIntent():
c.moveSelection(-_pageStep);
case NavTopIntent():
c.selectEdge(top: true);
case NavBottomIntent():
c.selectEdge(top: false);
case NavExpandOrRightIntent():
unawaited(c.expandOrInto());
case NavCollapseOrLeftIntent():
unawaited(c.collapseOrOut());
case NavActivateIntent():
_activateSelected(c);
}
}
void _activateSelected(FileTreeController c) {
final t = c.activateTarget();
if (t == null) return;
if (t.isDirectory) {
unawaited(c.toggle(t.path));
} else {
openWorkspaceFile(ClideKernel.of(context), t.path);
}
}
/// Scroll the selected row into view after the frame it's laid out in.
void _ensureSelectedVisible() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _selectedKey.currentContext;
if (ctx == null) return;
Scrollable.ensureVisible(ctx, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: const Duration(milliseconds: 80));
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final c = _controller; final c = _controller;
@@ -57,6 +108,23 @@ class _FileTreeViewState extends State<FileTreeView> {
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)); return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
} }
final rootName = root.split(Platform.pathSeparator).last; final rootName = root.split(Platform.pathSeparator).last;
final selected = c.selectedPath;
if (_filter.isEmpty && selected != null) _ensureSelectedVisible();
final scroller = SingleChildScrollView(
controller: _scroll,
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_filter.isEmpty) ...[
_DirRow(name: rootName, path: '', controller: c, depth: 0, selectedPath: selected, selectedKey: _selectedKey),
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1, selectedPath: selected, selectedKey: _selectedKey),
] else
..._filteredEntries(c),
],
),
);
return Column( return Column(
children: [ children: [
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)), ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
@@ -65,20 +133,10 @@ class _FileTreeViewState extends State<FileTreeView> {
label: 'file tree — $rootName', label: 'file tree — $rootName',
container: true, container: true,
explicitChildNodes: true, explicitChildNodes: true,
child: SingleChildScrollView( // Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
padding: const EdgeInsets.symmetric(vertical: 4), // region holds focus under the vim preset (T-406). The filter
child: Column( // box sits outside it, so typing a filter is never intercepted.
crossAxisAlignment: CrossAxisAlignment.stretch, child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller,
mainAxisSize: MainAxisSize.min,
children: [
if (_filter.isEmpty) ...[
_DirRow(name: rootName, path: '', controller: c, depth: 0),
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
] else
..._filteredEntries(c),
],
),
),
), ),
), ),
], ],
@@ -97,11 +155,13 @@ class _FileTreeViewState extends State<FileTreeView> {
} }
class _Children extends StatelessWidget { class _Children extends StatelessWidget {
const _Children({required this.path, required this.controller, required this.depth}); const _Children({required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
final String path; final String path;
final FileTreeController controller; final FileTreeController controller;
final int depth; final int depth;
final String? selectedPath;
final Key? selectedKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -117,58 +177,67 @@ class _Children extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth), _DirRow(name: e.name, path: e.path, controller: controller, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1), if (controller.isExpanded(e.path))
_Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey),
], ],
) )
else else
_FileRow(name: e.name, path: e.path, depth: depth), _FileRow(name: e.name, path: e.path, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
], ],
); );
} }
} }
class _DirRow extends StatelessWidget { class _DirRow extends StatelessWidget {
const _DirRow({required this.name, required this.path, required this.controller, required this.depth}); const _DirRow({required this.name, required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
final String name; final String name;
final String path; final String path;
final FileTreeController controller; final FileTreeController controller;
final int depth; final int depth;
final String? selectedPath;
final Key? selectedKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final expanded = controller.isExpanded(path); final expanded = controller.isExpanded(path);
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final selected = path == selectedPath;
return Semantics( return Semantics(
button: true, button: true,
label: '${expanded ? 'Collapse' : 'Expand'} $name', label: '${expanded ? 'Collapse' : 'Expand'} $name',
onTap: () => controller.toggle(path), onTap: () => controller.toggle(path),
child: _Row( child: _Row(
key: selected ? selectedKey : null,
depth: depth, depth: depth,
onTap: () => controller.toggle(path), onTap: () => controller.toggle(path),
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground), leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
label: name, label: name,
rotateLeading: expanded, rotateLeading: expanded,
selected: selected,
), ),
); );
} }
} }
class _FileRow extends StatelessWidget { class _FileRow extends StatelessWidget {
const _FileRow({required this.name, required this.path, required this.depth}); const _FileRow({required this.name, required this.path, required this.depth, this.selectedPath, this.selectedKey});
final String name; final String name;
final String path; final String path;
final int depth; final int depth;
final String? selectedPath;
final Key? selectedKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selected = path == selectedPath;
return Semantics( return Semantics(
button: true, button: true,
label: 'Open $name', label: 'Open $name',
onTap: () => _openFile(context, path), onTap: () => _openFile(context, path),
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name), child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected),
); );
} }
@@ -180,7 +249,7 @@ class _FileRow extends StatelessWidget {
} }
class _Row extends StatelessWidget { class _Row extends StatelessWidget {
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false}); const _Row({super.key, required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false, this.selected = false});
final int depth; final int depth;
final VoidCallback onTap; final VoidCallback onTap;
@@ -188,6 +257,10 @@ class _Row extends StatelessWidget {
final Widget? leading; final Widget? leading;
final bool rotateLeading; final bool rotateLeading;
/// True when the keyboard selection cursor is on this row (T-406) — draws a
/// persistent highlight + accent ring, distinct from transient hover.
final bool selected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
@@ -195,7 +268,12 @@ class _Row extends StatelessWidget {
return ClideTappable( return ClideTappable(
onTap: onTap, onTap: onTap,
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
color: hovered ? tokens.sidebarItemHover : null, decoration: selected
? BoxDecoration(
color: tokens.sidebarItemHover,
border: Border.all(color: tokens.globalFocus, width: 1),
)
: (hovered ? BoxDecoration(color: tokens.sidebarItemHover) : null),
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3), padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
child: Row( child: Row(
children: [ children: [
+16 -1
View File
@@ -2,6 +2,8 @@
/// merged health/toggle status-bar widget, and the `dock.toggle` command. /// merged health/toggle status-bar widget, and the `dock.toggle` command.
library; library;
import 'dart:async';
import 'package:clide/builtin/output/src/dock_status_item.dart'; import 'package:clide/builtin/output/src/dock_status_item.dart';
import 'package:clide/builtin/output/src/output_view.dart'; import 'package:clide/builtin/output/src/output_view.dart';
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
@@ -35,7 +37,20 @@ class OutputExtension extends ClideExtension {
slot: Slots.dock, slot: Slots.dock,
title: 'Output', title: 'Output',
priority: -100, // sort before Problems in the dock tab bar priority: -100, // sort before Problems in the dock tab bar
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing), build: (ctx) {
// The Level chip is the live verbosity toggle (T-433): drive the kernel
// logger + persist app.log.level so the choice survives restart and
// matches the `clide log level` CLI (D-6 parity).
final k = ClideKernel.of(ctx);
return OutputView(
ring: k.logRing,
initialLevel: k.log.minLevel,
onMinLevelChanged: (level) {
k.log.minLevel = level;
unawaited(k.settings.set('app.log.level', level.name));
},
);
},
), ),
StatusItemContribution( StatusItemContribution(
id: 'output.dock-toggle', id: 'output.dock-toggle',
+12 -3
View File
@@ -10,15 +10,23 @@ import 'package:clide/kernel/src/log_ring.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class OutputController extends ChangeNotifier { class OutputController extends ChangeNotifier {
OutputController(this.ring) { OutputController(this.ring, {LogLevel? initialLevel, this.onMinLevelChanged}) : minLevel = initialLevel ?? LogLevel.debug {
_sub = ring.changes.listen((_) => notifyListeners()); _sub = ring.changes.listen((_) => notifyListeners());
} }
final LogRing ring; final LogRing ring;
late final StreamSubscription<void> _sub; late final StreamSubscription<void> _sub;
/// Minimum level shown. Defaults to debug (trace is firehose-noise). /// Invoked when the Level chip changes the level — the dock chip is the live
LogLevel minLevel = LogLevel.debug; /// dev/prod verbosity toggle (T-433), not just a view filter. The owner wires
/// this to set the kernel `Logger.minLevel` and persist `app.log.level`. Null
/// in tests / when no kernel is attached, leaving the chip a pure view filter.
final void Function(LogLevel)? onMinLevelChanged;
/// Minimum level shown — initialized from the kernel logger's level so the
/// chip reflects the real verbosity (which a `clide log level` CLI may have
/// already set), defaulting to debug (trace is firehose-noise).
LogLevel minLevel;
/// Source filter; null = all sources. /// Source filter; null = all sources.
String? source; String? source;
@@ -29,6 +37,7 @@ class OutputController extends ChangeNotifier {
void setMinLevel(LogLevel level) { void setMinLevel(LogLevel level) {
if (minLevel == level) return; if (minLevel == level) return;
minLevel = level; minLevel = level;
onMinLevelChanged?.call(level);
notifyListeners(); notifyListeners();
} }
+7 -2
View File
@@ -10,18 +10,23 @@ import 'package:flutter/widgets.dart';
import 'output_controller.dart'; import 'output_controller.dart';
class OutputView extends StatefulWidget { class OutputView extends StatefulWidget {
const OutputView({super.key, required this.ring}); const OutputView({super.key, required this.ring, this.initialLevel, this.onMinLevelChanged});
/// The retained log buffer to render. The view owns a controller over it /// The retained log buffer to render. The view owns a controller over it
/// but never the ring itself (the app owns that). /// but never the ring itself (the app owns that).
final LogRing ring; final LogRing ring;
/// Initial verbosity (the kernel logger's current level) + the sink that
/// applies a chip change to the kernel + persists it (T-433).
final LogLevel? initialLevel;
final void Function(LogLevel)? onMinLevelChanged;
@override @override
State<OutputView> createState() => _OutputViewState(); State<OutputView> createState() => _OutputViewState();
} }
class _OutputViewState extends State<OutputView> { class _OutputViewState extends State<OutputView> {
late final OutputController _c = OutputController(widget.ring); late final OutputController _c = OutputController(widget.ring, initialLevel: widget.initialLevel, onMinLevelChanged: widget.onMinLevelChanged);
final ScrollController _scroll = ScrollController(); final ScrollController _scroll = ScrollController();
/// Follow the tail until the user scrolls up; resumes when they return to /// Follow the tail until the user scrolls up; resumes when they return to
+6 -8
View File
@@ -77,20 +77,18 @@ class _TerminalPaneState extends State<TerminalPane> {
return; return;
} }
final shell = Platform.environment['SHELL'] ?? '/bin/bash'; // Windows has no $SHELL convention and no login-shell flag —
// PowerShell 7 first, classic PowerShell as the always-there
// fallback.
final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash');
final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo'];
// The open workspace, not Directory.current — a desktop launch starts // The open workspace, not Directory.current — a desktop launch starts
// in $HOME and a project switch doesn't move the process CWD (T-381). // in $HOME and a project switch doesn't move the process CWD (T-381).
final cwd = _kernel?.project.current?.path ?? Directory.current.path; final cwd = _kernel?.project.current?.path ?? Directory.current.path;
final response = await ipc.request( final response = await ipc.request(
'pane.spawn', 'pane.spawn',
args: { args: {'argv': argv, 'kind': PaneKind.terminal.wire, 'cwd': cwd, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight},
'argv': [shell, '-l'],
'kind': PaneKind.terminal.wire,
'cwd': cwd,
'cols': _terminal.viewWidth,
'rows': _terminal.viewHeight,
},
); );
if (!mounted) return; if (!mounted) return;
if (!response.ok) { if (!response.ok) {
+2 -2
View File
@@ -9,7 +9,7 @@
/// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal` /// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal`
/// etc. Nothing reaches into this object across the builtin boundary. /// etc. Nothing reaches into this object across the builtin boundary.
/// ///
/// The whole layer is gated by [enabled], which the Vim extension ties to /// The whole layer is gated by `enabled`, which the Vim extension ties to
/// the active preset: under a non-Vim preset the flags are cleared so they /// the active preset: under a non-Vim preset the flags are cleared so they
/// can never affect another preset's bindings. /// can never affect another preset's bindings.
library; library;
@@ -47,7 +47,7 @@ class VimModeService extends ChangeNotifier {
/// Whether the Vim layer is live. False under non-Vim presets. /// Whether the Vim layer is live. False under non-Vim presets.
bool get enabled => _enabled; bool get enabled => _enabled;
/// The active mode. Meaningful only while [enabled]; defaults to /// The active mode. Meaningful only while `enabled`; defaults to
/// [VimMode.normal] and resets to it whenever the layer is enabled. /// [VimMode.normal] and resets to it whenever the layer is enabled.
VimMode get mode => _mode; VimMode get mode => _mode;
+4
View File
@@ -27,6 +27,7 @@ class TabContribution extends ContributionPoint {
required this.title, required this.title,
required this.build, required this.build,
this.icon, this.icon,
this.iconColor,
this.priority = 0, this.priority = 0,
this.fileGlobs = const [], this.fileGlobs = const [],
this.listenable, this.listenable,
@@ -39,6 +40,9 @@ class TabContribution extends ContributionPoint {
final String title; final String title;
final WidgetBuilder build; final WidgetBuilder build;
final Object? icon; final Object? icon;
/// Optional identity tint for the icon-rail glyph (T-418).
final Color? iconColor;
final int priority; final int priority;
final List<String> fileGlobs; final List<String> fileGlobs;
final Listenable? listenable; final Listenable? listenable;
+3
View File
@@ -17,6 +17,8 @@ export 'src/events/message_bus.dart';
export 'src/events/types.dart'; export 'src/events/types.dart';
export 'src/ipc/client.dart'; export 'src/ipc/client.dart';
export 'src/log.dart'; export 'src/log.dart';
export 'src/file_log_sink.dart';
export 'src/watchdog.dart';
export 'src/settings.dart'; export 'src/settings.dart';
export 'src/facade.dart'; export 'src/facade.dart';
export 'src/clipboard.dart'; export 'src/clipboard.dart';
@@ -28,6 +30,7 @@ export 'src/keymap/key_chord.dart';
export 'src/keymap/keymap.dart'; export 'src/keymap/keymap.dart';
export 'src/keymap/keymap_service.dart'; export 'src/keymap/keymap_service.dart';
export 'src/keymap/modifier_tap.dart'; export 'src/keymap/modifier_tap.dart';
export 'src/keymap/pane_key_nav.dart';
export 'src/keymap/sequence_matcher.dart'; export 'src/keymap/sequence_matcher.dart';
export 'src/keymap/when_clause.dart'; export 'src/keymap/when_clause.dart';
export 'src/dialog.dart'; export 'src/dialog.dart';
+29 -11
View File
@@ -127,7 +127,7 @@ class CliInstaller {
'`make build` so the C client ships inside the app bundle.', '`make build` so the C client ships inside the app bundle.',
); );
} }
final dest = '${_normalize(installDir)}/clide'; final dest = '${_normalize(installDir)}/clide${Platform.isWindows ? '.exe' : ''}';
try { try {
Directory(installDir).createSync(recursive: true); Directory(installDir).createSync(recursive: true);
// Delete any existing entry first so a stale symlink (e.g. one into // Delete any existing entry first so a stale symlink (e.g. one into
@@ -180,42 +180,60 @@ class CliInstaller {
bool _dirOnPath(String dir) { bool _dirOnPath(String dir) {
final norm = _normalize(dir); final norm = _normalize(dir);
return _expandedPath().split(':').any((d) => d.isNotEmpty && _normalize(d) == norm); return _expandedPath().split(_pathSep).any((d) => d.isNotEmpty && _normalize(d) == norm);
} }
String _normalize(String p) => p.length > 1 && p.endsWith('/') ? p.substring(0, p.length - 1) : p; /// Trim a trailing slash; on Windows also fold separators and case so
/// `C:\Users\x/.local/bin` and `c:\users\x\.local\bin` compare equal.
String _normalize(String p) {
var s = p;
if (Platform.isWindows) s = s.replaceAll('\\', '/').toLowerCase();
return s.length > 1 && s.endsWith('/') ? s.substring(0, s.length - 1) : s;
}
String? _findOnPath(String name) { String? _findOnPath(String name) {
for (final dir in _expandedPath().split(':')) { for (final dir in _expandedPath().split(_pathSep)) {
if (dir.isEmpty) continue; if (dir.isEmpty) continue;
final f = File('$dir/$name'); if (Platform.isWindows) {
if (f.existsSync()) return f.path; for (final ext in const ['.exe', '.bat', '.cmd', '']) {
final f = File('$dir\\$name$ext');
if (f.existsSync()) return f.path;
}
} else {
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
} }
return null; return null;
} }
static String get _pathSep => Platform.isWindows ? ';' : ':';
String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? ''); String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? '');
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin'; /// `~/.local/bin` on every platform — on Windows that is
/// `%USERPROFILE%\.local\bin`, the same convention the claude and
/// pql installers use there.
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? env['USERPROFILE'] ?? ''}/.local/bin';
/// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev /// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev
/// override first, then `<exe-dir>/clide-cli` — where `make build` drops it /// override first, then `<exe-dir>/clide-cli` — where `make build` drops it
/// inside the bundle (next to the GUI runner on Linux, in /// inside the bundle (next to the GUI runner on Linux and Windows, in
/// `Contents/MacOS/` on macOS). /// `Contents/MacOS/` on macOS).
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) { static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
final exeDir = File(resolvedExecutable).parent.path; final exeDir = File(resolvedExecutable).parent.path;
return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, '$exeDir/clide-cli']; return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, if (Platform.isWindows) '$exeDir/clide-cli.exe' else '$exeDir/clide-cli'];
} }
} }
final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos)-(x64|arm64)/clide$'); final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos|windows)-(x64|arm64)/clide(\.exe)?$');
/// True when [path] is a dev-tree C-client build artifact — /// True when [path] is a dev-tree C-client build artifact —
/// `native/<platform>/clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide /// `native/<platform>/clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide
/// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on /// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on
/// PATH; it's a working client but not a packaged production install, so it's /// PATH; it's a working client but not a packaged production install, so it's
/// classified separately (T-256) rather than as a clean install. /// classified separately (T-256) rather than as a clean install.
bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path); bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path.replaceAll('\\', '/'));
/// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps /// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps
/// launch with a sparse PATH that omits the usual user/homebrew bins, so on /// launch with a sparse PATH that omits the usual user/homebrew bins, so on
+5 -1
View File
@@ -137,9 +137,13 @@ class KernelServices {
Future<void> Function(String path)? onProjectOpen, Future<void> Function(String path)? onProjectOpen,
Future<String?> Function(String path)? onValidateProject, Future<String?> Function(String path)? onValidateProject,
DaemonBus? sharedBus, DaemonBus? sharedBus,
List<LogSink> additionalSinks = const [],
LogLevel? minLogLevel,
}) async { }) async {
final logRing = LogRing(); final logRing = LogRing();
final log = Logger(sinks: [stderrSink, logRing.add]); // additionalSinks lead the chain so a crash-survivable sink (FileLogSink,
// T-425) records the tail before the volatile stderr/ring sinks run.
final log = Logger(minLevel: minLogLevel ?? LogLevel.info, sinks: [...additionalSinks, stderrSink, logRing.add]);
final events = sharedBus ?? DaemonBus(); final events = sharedBus ?? DaemonBus();
final messages = MessageBus(); final messages = MessageBus();
final filterStates = FilterStateCache(messages: messages); final filterStates = FilterStateCache(messages: messages);
+165
View File
@@ -0,0 +1,165 @@
/// Crash-survivable [LogSink] (T-425).
///
/// Every other sink in clide is volatile: [stderrSink] dies with the console,
/// the [LogRing] dies with the process. The Windows freeze that motivated this
/// (a hard power-cycle, no dumps, no logs) left no evidence for exactly that
/// reason. [FileLogSink] is the durable tail: it appends each [LogRecord] as
/// one JSON line to a size-rotated file under a persistent per-platform log
/// dir, and — crucially — fsyncs the records most likely to immediately
/// precede a crash, so the last breadcrumb is on disk before the box dies.
///
/// Design choices that matter for a CRASH logger:
/// - Synchronous I/O only. No async buffering / no IOSink — a hard death
/// between an `await` and its flush would lose the tail, which is the one
/// thing this sink exists to keep.
/// - Tiered flush. `warn`/`error` and records from inherently-risky sources
/// (pty/ffi/conpty/watchdog) `flushSync` immediately. High-volume
/// `info`/`debug` write through to the OS (surviving a process crash) and
/// are fsynced on a low-frequency timer — enough to bound power-loss to a
/// couple of seconds without an fsync per line.
/// - Never throws. A disk-full / permission error must not take logging — or
/// the app — down; every operation swallows its own failure.
///
/// Flutter-free (only `dart:io`/`dart:async`/`dart:convert` + [LogRecord]) so
/// it unit-tests under `dart test` against a temp dir, and so the PTY/FFI
/// layer (also Flutter-free) can route breadcrumbs through it.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'log.dart';
class FileLogSink {
FileLogSink({
required Directory dir,
String baseName = 'clide',
int maxBytes = 5 * 1024 * 1024,
int maxFiles = 5,
Set<String> eagerSources = const {'pty', 'ffi', 'conpty', 'watchdog'},
LogLevel eagerLevel = LogLevel.warn,
Duration flushInterval = const Duration(seconds: 2),
bool startFlushTimer = true,
}) : _dir = dir,
_baseName = baseName,
_maxBytes = maxBytes,
_maxFiles = maxFiles < 1 ? 1 : maxFiles,
_eagerSources = eagerSources,
_eagerLevel = eagerLevel {
_open();
if (startFlushTimer && flushInterval > Duration.zero) {
_timer = Timer.periodic(flushInterval, (_) => _flush());
}
}
final Directory _dir;
final String _baseName;
final int _maxBytes;
final int _maxFiles;
final Set<String> _eagerSources;
final LogLevel _eagerLevel;
RandomAccessFile? _raf;
int _size = 0;
bool _dirty = false;
Timer? _timer;
String get _sep => Platform.pathSeparator;
File get _active => File('${_dir.path}$_sep$_baseName.log');
File _archive(int i) => File('${_dir.path}$_sep$_baseName.$i.log');
/// The active log file's path — handy for the caller to surface (e.g. an
/// "open log folder" affordance) or to add to a CI artifact upload.
String get activePath => _active.path;
void _open() {
try {
_dir.createSync(recursive: true);
final f = _active;
_size = f.existsSync() ? f.lengthSync() : 0;
_raf = f.openSync(mode: FileMode.append);
} catch (_) {
_raf = null; // a disk problem must never kill logging
}
}
/// The [LogSink] entry point: `logger.addSink(fileSink.call)`.
void call(LogRecord r) {
if (_raf == null) return;
try {
final bytes = utf8.encode('${jsonEncode(_encode(r))}\n');
// Rotate BEFORE writing when this line would push the file past the cap,
// so the newest entries always live in the active file (and a single
// oversized line still lands rather than spinning rotations on an empty
// file).
if (_size > 0 && _size + bytes.length > _maxBytes) _rotate();
final raf = _raf;
if (raf == null) return;
raf.writeFromSync(bytes);
_size += bytes.length;
_dirty = true;
if (r.level.index >= _eagerLevel.index || _eagerSources.contains(r.source)) {
raf.flushSync();
_dirty = false;
}
} catch (_) {
// swallow — a logging failure must never propagate to the app
}
}
Map<String, Object?> _encode(LogRecord r) => {
'ts': r.timestamp.toIso8601String(),
'lvl': r.level.name,
'src': r.source,
'msg': r.message,
if (r.error != null) 'err': r.error.toString(),
if (r.stackTrace != null) 'stack': r.stackTrace.toString(),
};
void _flush() {
if (!_dirty) return;
try {
_raf?.flushSync();
_dirty = false;
} catch (_) {}
}
/// Roll `<base>.log` → `<base>.1.log`, shifting older archives up and
/// dropping the oldest past [maxFiles]. With `maxFiles == 1` the active file
/// is simply truncated (no archives kept).
void _rotate() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
try {
if (_maxFiles <= 1) {
if (_active.existsSync()) _active.deleteSync();
} else {
final oldest = _archive(_maxFiles - 1);
if (oldest.existsSync()) oldest.deleteSync();
for (var i = _maxFiles - 2; i >= 1; i--) {
final src = _archive(i);
if (src.existsSync()) src.renameSync(_archive(i + 1).path);
}
if (_active.existsSync()) _active.renameSync(_archive(1).path);
}
} catch (_) {}
_size = 0;
_open();
}
/// Flush + close. Call on orderly shutdown; a crash is covered by the eager
/// fsync above, not by this.
Future<void> close() async {
_timer?.cancel();
_timer = null;
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
+67
View File
@@ -98,6 +98,63 @@ class TextScaleResetIntent extends Intent {
const TextScaleResetIntent(); const TextScaleResetIntent();
} }
// -- Pane navigation (vim normal-mode motions outside the editor) ------------
/// Base for the preset-neutral navigation intents (T-406). A focused non-editor
/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and
/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds
/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys
/// to the same ids. Marker base so a pane's key handler can tell a nav motion
/// apart from any other fired intent.
sealed class NavIntent extends Intent {
const NavIntent();
}
/// Move the selection / scroll down one step (vim `j`).
class NavDownIntent extends NavIntent {
const NavDownIntent();
}
/// Move the selection / scroll up one step (vim `k`).
class NavUpIntent extends NavIntent {
const NavUpIntent();
}
/// Scroll down half a viewport (vim `ctrl+d`).
class NavPageDownIntent extends NavIntent {
const NavPageDownIntent();
}
/// Scroll up half a viewport (vim `ctrl+u`).
class NavPageUpIntent extends NavIntent {
const NavPageUpIntent();
}
/// Jump to the first item / top (vim `gg`).
class NavTopIntent extends NavIntent {
const NavTopIntent();
}
/// Jump to the last item / bottom (vim `G`).
class NavBottomIntent extends NavIntent {
const NavBottomIntent();
}
/// Expand the focused node, or step into it / move right (vim `l`).
class NavExpandOrRightIntent extends NavIntent {
const NavExpandOrRightIntent();
}
/// Collapse the focused node, or step out of it / move left (vim `h`).
class NavCollapseOrLeftIntent extends NavIntent {
const NavCollapseOrLeftIntent();
}
/// Activate the focused item — open the file, run the row (vim `o` / `enter`).
class NavActivateIntent extends NavIntent {
const NavActivateIntent();
}
// -- Command bridge --------------------------------------------------------- // -- Command bridge ---------------------------------------------------------
/// Generic "invoke this CommandRegistry command id" intent. Used for /// Generic "invoke this CommandRegistry command id" intent. Used for
@@ -136,6 +193,16 @@ final Map<String, Intent Function()> builtinIntents = {
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(), 'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(), 'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(), 'findInFiles.open': () => const FindInFilesIntent(),
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
'nav.down': () => const NavDownIntent(),
'nav.up': () => const NavUpIntent(),
'nav.pageDown': () => const NavPageDownIntent(),
'nav.pageUp': () => const NavPageUpIntent(),
'nav.top': () => const NavTopIntent(),
'nav.bottom': () => const NavBottomIntent(),
'nav.expandOrRight': () => const NavExpandOrRightIntent(),
'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(),
'nav.activate': () => const NavActivateIntent(),
'text.scaleIncrease': () => const TextScaleIncreaseIntent(), 'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
'text.scaleDecrease': () => const TextScaleDecreaseIntent(), 'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
'text.scaleReset': () => const TextScaleResetIntent(), 'text.scaleReset': () => const TextScaleResetIntent(),
+1 -1
View File
@@ -13,7 +13,7 @@
/// ///
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g. /// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
/// `palette.open`, `editor.focused`). Producing services call /// `palette.open`, `editor.focused`). Producing services call
/// [setScopeFlag] when their state changes; consumers reference the /// `setScopeFlag` when their state changes; consumers reference the
/// flag name in when-clauses. /// flag name in when-clauses.
library; library;
+46 -23
View File
@@ -1,10 +1,16 @@
/// Detects a double-tapped bare modifier (e.g. JetBrains "Search /// Detects a double-tapped bare modifier (e.g. JetBrains "Search
/// Everywhere" = double-Shift). (T-341) /// Everywhere" = double-Shift). (T-341)
/// ///
/// Headless and clock-injected: the caller (the global key handler) passes /// A "tap" is a clean press-and-release: no other key may go down while the
/// the event time so it neither reads a clock nor consumes events. Feed it /// modifier is held, otherwise the press was a chord (`Shift+;` typing a
/// every [KeyDownEvent]: a bare modifier press via [tap], any other key via /// colon) and must not count (T-409). The gesture therefore completes on the
/// [reset] (an intervening key breaks the gesture, e.g. `Shift a Shift`). /// second clean *release*, never on a key-down — at down time it's unknowable
/// whether the press will stay bare.
///
/// Headless and clock-injected: the caller (the root shell's raw-keyboard
/// handler) passes the event time so it neither reads a clock nor consumes
/// events. Feed every [KeyDownEvent] to `down` and every [KeyUpEvent] to
/// `up`, passing the event's [KeyModifier] (null for non-modifier keys).
library; library;
import 'key_chord.dart'; import 'key_chord.dart';
@@ -12,33 +18,50 @@ import 'key_chord.dart';
class ModifierTapTracker { class ModifierTapTracker {
ModifierTapTracker({this.window = const Duration(milliseconds: 350)}); ModifierTapTracker({this.window = const Duration(milliseconds: 350)});
/// Max gap between the two taps to count as a double-tap. /// Max gap between the two tap releases to count as a double-tap.
final Duration window; final Duration window;
KeyModifier? _last; /// Modifier currently held whose press is still bare (no chorded key yet).
DateTime? _lastAt; KeyModifier? _pressing;
/// Record a bare-modifier press at [now]. Returns the modifier when this /// Modifier of the last completed clean tap, arming the double-tap.
/// press completes a double-tap of the *same* modifier within [window]; KeyModifier? _armed;
/// otherwise records it as the first tap and returns null. DateTime? _armedAt;
KeyModifier? tap(KeyModifier m, DateTime now) {
final last = _last; /// Record a key press. A non-modifier key ([mod] == null) — or any key
final lastAt = _lastAt; /// landing while a modifier is already held — is a chord: it dirties the
if (last == m && lastAt != null) { /// held press and breaks the armed gesture.
final gap = now.difference(lastAt); void down(KeyModifier? mod) {
if (mod == null || _pressing != null) {
_pressing = null;
_disarm();
return;
}
_pressing = mod;
}
/// Record a key release at [now]. Returns the modifier when this release
/// completes a double-tap: the second clean tap of the *same* modifier
/// within [window] of the first tap's release.
KeyModifier? up(KeyModifier? mod, DateTime now) {
if (mod == null) return null;
final pressing = _pressing;
_pressing = null;
if (pressing != mod) return null; // press went dirty (chorded) or stale
if (_armed == mod && _armedAt != null) {
final gap = now.difference(_armedAt!);
if (gap >= Duration.zero && gap <= window) { if (gap >= Duration.zero && gap <= window) {
reset(); _disarm();
return m; return mod;
} }
} }
_last = m; _armed = mod;
_lastAt = now; _armedAt = now;
return null; return null;
} }
/// Break the gesture — any non-modifier key press resets the tracker. void _disarm() {
void reset() { _armed = null;
_last = null; _armedAt = null;
_lastAt = null;
} }
} }
+122
View File
@@ -0,0 +1,122 @@
/// A reusable vim normal-mode navigation key handler for non-editor panes
/// (T-406).
///
/// The passive global key path is single-chord only and can't run sequences or
/// consume events (D-82), so — exactly like the editor's command-mode handler —
/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a
/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file
/// tree, conversation, and lists share one implementation.
///
/// While a `vim.normal` scope flag is set and this region holds focus, bare and
/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed
/// the matcher against the live keymap; a fired [NavIntent] is handed to
/// `onNav` with its repeat count. Everything else under `vim.normal` is
/// swallowed (vim normal mode is inert for unbound keys), except other-modifier
/// chords (palette, quick-open, …) which bubble to the global handler. Under a
/// non-vim preset or in insert mode the region is transparent — keys pass
/// straight through.
///
/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key
/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav
/// intent here and to the editor motion in the editor — see vim.yaml.
library;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../facade.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'keymap.dart';
import 'sequence_matcher.dart';
/// Signature for a fired navigation motion: the [intent] and its repeat
/// [count] (>= 1, from a leading digit prefix like `5j`).
typedef NavHandler = void Function(NavIntent intent, int count);
class PaneKeyNav extends StatefulWidget {
const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true});
final Widget child;
/// Called when a `nav.*` motion resolves while this region has focus.
final NavHandler onNav;
/// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that
/// want to move focus here programmatically (a row tap, F6) pass their own.
final FocusNode? focusNode;
final bool autofocus;
/// Whether the region can take focus at all. False makes it a pure pass-through
/// (used when a pane temporarily routes keys elsewhere, e.g. a filter box).
final bool canRequestFocus;
@override
State<PaneKeyNav> createState() => _PaneKeyNavState();
}
class _PaneKeyNavState extends State<PaneKeyNav> {
FocusNode? _ownNode;
SequenceMatcher? _matcher;
FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav'));
/// The half-page scroll chords are the only modified chords this handler
/// claims; every other modified chord bubbles to the global shortcut path.
static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD);
static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU);
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_matcher != null) return;
final kernel = ClideKernel.of(context);
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
}
@override
void dispose() {
_ownNode?.dispose();
super.dispose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
final kernel = ClideKernel.of(context);
// Only vim normal mode drives pane navigation. Insert/visual or a non-vim
// preset → transparent, keys pass through to whatever's below.
if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
final chord = KeyChord.fromKeyEvent(event, hw);
if (chord == null) return KeyEventResult.ignored;
// Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only
// modified chords we claim (half-page scroll). Any other modified chord is
// an app shortcut (palette, quick-open) — let it bubble to the global path.
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored;
final r = _matcher!.feed(chord);
switch (r.outcome) {
case SeqOutcome.fired:
// The vim preset also binds these keys to editor.vim.* motions; in a
// pane only nav.* applies. A non-nav fired intent (e.g. a stray
// editor.vim.* with no focus guard) is swallowed, never executed here.
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count);
return KeyEventResult.handled;
case SeqOutcome.pending:
return KeyEventResult.handled;
case SeqOutcome.unmatched:
// Vim normal mode beeps on unbound keys — swallow so a bare key never
// leaks to text input or the global handler.
return KeyEventResult.handled;
}
}
@override
Widget build(BuildContext context) {
return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
/// and := unary ('&&' unary)* /// and := unary ('&&' unary)*
/// unary := '!' unary | atom /// unary := '!' unary | atom
/// atom := IDENT | '(' expr ')' /// atom := IDENT | '(' expr ')'
/// IDENT := [a-zA-Z_][a-zA-Z0-9._-]* /// IDENT := `[a-zA-Z_][a-zA-Z0-9._-]*`
/// ///
/// Identifiers resolve against a `Map<String, bool>` context. A missing /// Identifiers resolve against a `Map<String, bool>` context. A missing
/// identifier evaluates to `false` — bindings can assume any required /// identifier evaluates to `false` — bindings can assume any required
+29
View File
@@ -62,3 +62,32 @@ void stderrSink(LogRecord r) {
stderr.writeln(r); stderr.writeln(r);
if (r.stackTrace != null) stderr.writeln(r.stackTrace); if (r.stackTrace != null) stderr.writeln(r.stackTrace);
} }
/// Parse a level name (case-insensitive, trimmed) to a [LogLevel], or null if
/// it is absent/blank/unknown — so an invalid source falls through to the next
/// one in [resolveLogLevel] rather than crashing the boot.
LogLevel? parseLogLevel(String? name) {
if (name == null) return null;
final n = name.trim().toLowerCase();
if (n.isEmpty) return null;
for (final l in LogLevel.values) {
if (l.name == n) return l;
}
return null;
}
/// Resolve the effective [Logger.minLevel] at boot — the dev/prod verbosity
/// toggle (T-425). Highest precedence first:
///
/// 1. `--dart-define=CLIDE_LOG=<level>` (baked into the build)
/// 2. the `CLIDE_LOG` environment variable
/// 3. the `app.log.level` setting
/// 4. a build-mode default: `warn` in release (a shipped app stays quiet),
/// `info` in debug.
///
/// Each named source is parsed leniently; an unknown name is skipped, not
/// fatal. The build-mode flag is passed in (rather than read here) to keep
/// this Flutter-free — `main.dart` supplies `kReleaseMode`.
LogLevel resolveLogLevel({required bool isRelease, String? dartDefine, String? envVar, String? settingValue}) {
return parseLogLevel(dartDefine) ?? parseLogLevel(envVar) ?? parseLogLevel(settingValue) ?? (isRelease ? LogLevel.warn : LogLevel.info);
}
+3 -3
View File
@@ -2,9 +2,9 @@
/// output dock (T-54 / D-87) reads on open. /// output dock (T-54 / D-87) reads on open.
/// ///
/// The [Logger] only live-broadcasts to its stream; a panel that opens late /// The [Logger] only live-broadcasts to its stream; a panel that opens late
/// would see nothing. [LogRing] is a sink that keeps the last [capacity] /// would see nothing. [LogRing] is a sink that keeps the last `capacity`
/// records (drop-oldest, same shape as the D-85 event ring) plus enough /// records (drop-oldest, same shape as the D-85 event ring) plus enough
/// bookkeeping to drive the panel's filter dropdown (distinct [sources]) and /// bookkeeping to drive the panel's filter dropdown (distinct `sources`) and
/// the status-bar health badge (level counts). /// the status-bar health badge (level counts).
/// ///
/// Flutter-free (only `dart:async`/`dart:collection` + the [LogRecord] type) /// Flutter-free (only `dart:async`/`dart:collection` + the [LogRecord] type)
@@ -42,7 +42,7 @@ class LogRing {
int get length => _records.length; int get length => _records.length;
bool get isEmpty => _records.isEmpty; bool get isEmpty => _records.isEmpty;
/// Append a record (the [Logger] sink). Drops the oldest past [capacity]. /// Append a record (the [Logger] sink). Drops the oldest past `capacity`.
void add(LogRecord r) { void add(LogRecord r) {
_records.addLast(r); _records.addLast(r);
_sourceCounts.update(r.source, (n) => n + 1, ifAbsent: () => 1); _sourceCounts.update(r.source, (n) => n + 1, ifAbsent: () => 1);
@@ -1,7 +1,7 @@
/// Snapshots the kernel's live tabs into [ViewPane]s so `pane list` reflects /// Snapshots the kernel's live tabs into [ViewPane]s so `pane list` reflects
/// the panes the user actually sees in the GUI (T-219, D-6 parity / D-83). /// the panes the user actually sees in the GUI (T-219, D-6 parity / D-83).
/// ///
/// Read-at-request-time: no state is mirrored into the IPC [PaneRegistry], so /// Read-at-request-time: no state is mirrored into the IPC `PaneRegistry`, so
/// nothing can drift from the live UI. Lives in the kernel (not `lib/src/panes/`) /// nothing can drift from the live UI. Lives in the kernel (not `lib/src/panes/`)
/// because it reads Flutter-coupled kernel state; it produces the Flutter-free /// because it reads Flutter-coupled kernel state; it produces the Flutter-free
/// [ViewPane] the pane command serialises. /// [ViewPane] the pane command serialises.
+3 -6
View File
@@ -10,6 +10,7 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -20,7 +21,6 @@ export 'toolchain_paths.dart';
class Toolchain extends ChangeNotifier implements ToolchainView { class Toolchain extends ChangeNotifier implements ToolchainView {
String? _git; String? _git;
String? _pql; String? _pql;
String? _tmux;
String? _shell; String? _shell;
Map<String, String>? _gitEnv; Map<String, String>? _gitEnv;
bool _resolved = false; bool _resolved = false;
@@ -30,9 +30,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
@override @override
String get pql => _pql ?? 'pql'; String get pql => _pql ?? 'pql';
@override @override
String get tmux => _tmux ?? 'tmux'; String get shell => _shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
@override
String get shell => _shell ?? '/bin/bash';
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite). /// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
@override @override
@@ -44,7 +42,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
bool get allOk => _resolved && missing.isEmpty; bool get allOk => _resolved && missing.isEmpty;
@override @override
List<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql', if (_tmux == null) 'tmux']; List<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql'];
/// Returns a Future that completes when resolution finishes. /// Returns a Future that completes when resolution finishes.
Future<void> waitForResolution() { Future<void> waitForResolution() {
@@ -65,7 +63,6 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
void applyResolved(ResolvedPaths p) { void applyResolved(ResolvedPaths p) {
_git = p.git; _git = p.git;
_pql = p.pql; _pql = p.pql;
_tmux = p.tmux;
_shell = p.shell; _shell = p.shell;
_gitEnv = p.gitEnv; _gitEnv = p.gitEnv;
_resolved = true; _resolved = true;
+30 -17
View File
@@ -12,11 +12,10 @@ import 'dart:io';
/// Serializable result of tool resolution (crosses isolate boundary). /// Serializable result of tool resolution (crosses isolate boundary).
class ResolvedPaths { class ResolvedPaths {
const ResolvedPaths({this.git, this.pql, this.tmux, this.shell, this.gitEnv}); const ResolvedPaths({this.git, this.pql, this.shell, this.gitEnv});
final String? git; final String? git;
final String? pql; final String? pql;
final String? tmux;
final String? shell; final String? shell;
final Map<String, String>? gitEnv; final Map<String, String>? gitEnv;
} }
@@ -32,7 +31,6 @@ abstract class ToolchainView {
String get git; String get git;
String get pql; String get pql;
String get tmux;
String get shell; String get shell;
Map<String, String>? get gitEnv; Map<String, String>? get gitEnv;
bool get resolved; bool get resolved;
@@ -50,9 +48,7 @@ class _StaticToolchain implements ToolchainView {
@override @override
String get pql => _paths.pql ?? 'pql'; String get pql => _paths.pql ?? 'pql';
@override @override
String get tmux => _paths.tmux ?? 'tmux'; String get shell => _paths.shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
@override
String get shell => _paths.shell ?? '/bin/bash';
@override @override
Map<String, String>? get gitEnv => _paths.gitEnv; Map<String, String>? get gitEnv => _paths.gitEnv;
@override @override
@@ -60,7 +56,7 @@ class _StaticToolchain implements ToolchainView {
@override @override
bool get allOk => missing.isEmpty; bool get allOk => missing.isEmpty;
@override @override
List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql', if (_paths.tmux == null) 'tmux']; List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql'];
} }
/// Top-level function for compute/isolate use. Returns a plain-data /// Top-level function for compute/isolate use. Returns a plain-data
@@ -83,13 +79,17 @@ ResolvedPaths resolveToolchainPaths() {
git = _findOnPath('git'); git = _findOnPath('git');
} }
return ResolvedPaths( return ResolvedPaths(git: git, pql: _findOnPath('pql'), shell: _resolveShell(), gitEnv: gitEnv);
git: git, }
pql: _findOnPath('pql'),
tmux: _findOnPath('tmux'), /// The user's interactive shell. POSIX honours `$SHELL`; Windows has
shell: _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'), /// no such convention — prefer PowerShell 7 (`pwsh`), fall back to
gitEnv: gitEnv, /// Windows PowerShell (present on every supported Windows).
); String? _resolveShell() {
if (Platform.isWindows) {
return _findOnPath('pwsh') ?? _findOnPath('powershell');
}
return _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
} }
/// Locate the dugite-bundled git binary in trusted install locations /// Locate the dugite-bundled git binary in trusted install locations
@@ -104,6 +104,10 @@ ResolvedPaths resolveToolchainPaths() {
/// ///
/// Returns null if no dugite is found; caller falls back to PATH git. /// Returns null if no dugite is found; caller falls back to PATH git.
String? _resolveDugiteGit() { String? _resolveDugiteGit() {
// dugite-native's Windows layout differs (cmd\git.exe, mingw64
// libexec) and isn't wired up yet — PATH git serves Windows until
// the bundle work lands.
if (Platform.isWindows) return null;
final candidates = <String>[]; final candidates = <String>[];
final envDir = Platform.environment['CLIDE_DUGITE_DIR']; final envDir = Platform.environment['CLIDE_DUGITE_DIR'];
if (envDir != null && envDir.isNotEmpty) { if (envDir != null && envDir.isNotEmpty) {
@@ -116,10 +120,19 @@ String? _resolveDugiteGit() {
} }
String? _findOnPath(String name) { String? _findOnPath(String name) {
for (final dir in _expandedPath().split(':')) { final sep = Platform.isWindows ? ';' : ':';
for (final dir in _expandedPath().split(sep)) {
if (dir.isEmpty) continue; if (dir.isEmpty) continue;
final f = File('$dir/$name'); if (Platform.isWindows) {
if (f.existsSync()) return f.path; // PATHEXT-style probe — a bare `pql` on PATH is really pql.exe.
for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) {
final f = File('$dir\\$name$ext');
if (f.existsSync()) return f.path;
}
} else {
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
} }
return null; return null;
} }
+201
View File
@@ -0,0 +1,201 @@
/// Crash-diagnostic watchdog (T-435, under the T-425 observability epic).
///
/// The Windows freeze leaves no evidence partly because it is a *whole-process*
/// stall: a main-isolate `Timer` heartbeat would freeze WITH the main isolate
/// and tell us nothing. So the watchdog runs in a DEDICATED isolate that:
///
/// - appends + fsyncs a heartbeat every ~500ms, so the last heartbeat on disk
/// bounds a freeze to ~500ms ("it was alive at T, dead by T+0.5s"); and
/// - every ~2s samples this process's resource counts — threads, open
/// handles/fds, child/ConPTY-host processes, RSS — and appends + fsyncs
/// them. A monotonically climbing child/thread/handle count is the leak
/// signature the soak couldn't reproduce on CI but a real freeze would show.
///
/// Output is JSON-lines in `logDirectory()/clide-watchdog.log`, matching
/// [FileLogSink] so it greps/parses the same way, bounded by the same
/// truncate-on-cap scheme as `IsolateCrumbFile`. Everything is synchronous and
/// swallows its own errors — the watchdog must never add a second hang or take
/// the app down.
///
/// Flutter-free (dart:io / dart:isolate / dart:convert + a thin FFI sampler on
/// Windows) so the entry point is `Isolate.spawn`-able and it unit-tests under
/// `dart test`.
library;
import 'dart:convert';
import 'dart:io';
import 'watchdog_windows.dart';
/// One resource sample of the current process. A field of `-1` means "not
/// available on this platform or the probe failed" — never an error.
class ResourceSample {
const ResourceSample({this.threads = -1, this.handles = -1, this.children = -1, this.rssBytes = -1});
/// Live OS thread count (culprit #2: blocked-FFI isolate threads piling up).
final int threads;
/// Open handle count (Windows) / open fd count (POSIX).
final int handles;
/// Child / ConPTY-host process count (the orphan-accumulation leak signature).
final int children;
/// Resident set size in bytes.
final int rssBytes;
Map<String, Object?> toJson() => {
if (threads >= 0) 'threads': threads,
if (handles >= 0) 'handles': handles,
if (children >= 0) 'children': children,
if (rssBytes >= 0) 'rssMB': (rssBytes / (1024 * 1024)).round(),
};
}
/// Samples the CURRENT process's resource counts. Cheap, synchronous, never
/// throws (returns `-1` fields on failure).
abstract class ResourceSampler {
ResourceSample sample();
/// The backend for the running OS — `/proc` on POSIX, a thin Win32 FFI
/// snapshot on Windows.
static ResourceSampler forPlatform() => Platform.isWindows ? WindowsResourceSampler() : PosixResourceSampler();
}
/// POSIX sampler — reads `/proc/self`. Runs (and is tested) on the Linux CI box.
class PosixResourceSampler implements ResourceSampler {
@override
ResourceSample sample() => ResourceSample(threads: _threads(), handles: _fdCount(), children: _childCount(), rssBytes: _rss());
int _threads() {
try {
for (final line in File('/proc/self/status').readAsLinesSync()) {
if (line.startsWith('Threads:')) return int.parse(line.split(RegExp(r'\s+'))[1]);
}
} catch (_) {}
return -1;
}
int _fdCount() {
try {
return Directory('/proc/self/fd').listSync().length;
} catch (_) {
return -1;
}
}
int _childCount() {
// Sum each thread's direct-children list (`/proc/<pid>/task/<tid>/children`,
// Linux 5.3+). Best-effort: absent file / old kernel → 0 from that thread.
try {
var n = 0;
for (final task in Directory('/proc/self/task').listSync()) {
final f = File('${task.path}/children');
if (!f.existsSync()) continue;
final s = f.readAsStringSync().trim();
if (s.isNotEmpty) n += s.split(RegExp(r'\s+')).length;
}
return n;
} catch (_) {
return -1;
}
}
int _rss() {
try {
return ProcessInfo.currentRss;
} catch (_) {
return -1;
}
}
}
/// The watchdog's on-disk writer: bounded, synchronously-fsynced JSON-lines
/// (heartbeats + samples). Separated from the loop so it unit-tests in
/// isolation. Mirrors `IsolateCrumbFile`'s truncate-on-cap bound.
class WatchdogFile {
WatchdogFile(String? path, {int capBytes = 256 * 1024}) : _capBytes = capBytes {
if (path == null) return;
try {
final f = File(path);
f.parent.createSync(recursive: true);
final raf = f.openSync(mode: FileMode.append);
_raf = raf;
_size = raf.lengthSync();
} catch (_) {
_raf = null;
}
}
final int _capBytes;
RandomAccessFile? _raf;
int _size = 0;
bool get enabled => _raf != null;
void heartbeat() => _write({'ts': _now(), 'evt': 'hb'});
void sample(ResourceSample s) => _write({'ts': _now(), 'evt': 'sample', 'pid': pid, ...s.toJson()});
String _now() => DateTime.now().toUtc().toIso8601String();
void _write(Map<String, Object?> json) {
final raf = _raf;
if (raf == null) return;
try {
if (_size >= _capBytes) {
raf.truncateSync(0);
raf.setPositionSync(0);
_size = 0;
}
final bytes = utf8.encode('${jsonEncode(json)}\n');
raf.writeFromSync(bytes);
raf.flushSync();
_size += bytes.length;
} catch (_) {}
}
void close() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
/// The watchdog loop. Extracted from [watchdogEntry] so a test can bound it
/// with [maxTicks]; production passes null and the loop runs until the isolate
/// is killed at shutdown. Heartbeats fire every [hbIntervalMs], samples every
/// [sampleIntervalMs]; a short sleep between keeps the cadence without spinning.
void runWatchdog(WatchdogFile file, ResourceSampler sampler, {required int hbIntervalMs, required int sampleIntervalMs, int? maxTicks}) {
if (!file.enabled) return;
final sw = Stopwatch()..start();
// Seed both "last" markers a full interval in the past so the first tick
// emits an immediate heartbeat + sample (a baseline at startup).
var lastHb = -hbIntervalMs;
var lastSample = -sampleIntervalMs;
var ticks = 0;
while (maxTicks == null || ticks < maxTicks) {
final e = sw.elapsedMilliseconds;
if (e - lastHb >= hbIntervalMs) {
file.heartbeat();
lastHb = e;
}
if (e - lastSample >= sampleIntervalMs) {
file.sample(sampler.sample());
lastSample = e;
}
ticks++;
if (maxTicks != null && ticks >= maxTicks) break;
sleep(const Duration(milliseconds: 25));
}
file.close();
}
/// Top-level entry for `Isolate.spawn`. Args are a sendable tuple — the log
/// path (not a Logger; isolates can't share one) and the two intervals in ms.
void watchdogEntry((String, int, int) msg) {
final (logPath, hbMs, sampleMs) = msg;
runWatchdog(WatchdogFile(logPath), ResourceSampler.forPlatform(), hbIntervalMs: hbMs, sampleIntervalMs: sampleMs);
}
+141
View File
@@ -0,0 +1,141 @@
// coverage:ignore-file
//
// Windows-only resource sampler for the watchdog (T-435). All of it is Win32
// FFI through kernel32/psapi, so it cannot execute on the Linux CI runner that
// produces the coverage report (PosixResourceSampler is used there). It is
// validated only when the app actually runs on Windows — which is acceptable
// because it is a DIAGNOSTIC that reads, never mutates, and is exhaustively
// defensive: every probe is wrapped so any failure yields a `-1` field rather
// than an exception, and the toolhelp snapshot handle is always closed. A
// missing sample is fine; a sampler that throws or leaks would not be.
library;
import 'dart:ffi' as ffi;
import 'dart:io' show ProcessInfo;
import 'package:ffi/ffi.dart';
import 'watchdog.dart';
const int _kTh32csSnapprocess = 0x00000002;
final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll');
final ffi.DynamicLibrary _psapi = ffi.DynamicLibrary.open('psapi.dll');
final _getCurrentProcess = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(), ffi.Pointer<ffi.Void> Function()>('GetCurrentProcess');
final _getCurrentProcessId = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetCurrentProcessId');
final _createToolhelp32Snapshot = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(ffi.Uint32, ffi.Uint32), ffi.Pointer<ffi.Void> Function(int, int)>(
'CreateToolhelp32Snapshot',
);
final _process32First = _k32
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
'Process32First',
);
final _process32Next = _k32
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
'Process32Next',
);
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('CloseHandle');
final _getProcessHandleCount = _psapi
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>)>(
'GetProcessHandleCount',
);
/// Win32 `PROCESSENTRY32` (ANSI). szExeFile is `CHAR[MAX_PATH]`.
final class _ProcessEntry32 extends ffi.Struct {
@ffi.Uint32()
external int dwSize;
@ffi.Uint32()
external int cntUsage;
@ffi.Uint32()
external int th32ProcessID;
@ffi.IntPtr()
external int th32DefaultHeapID;
@ffi.Uint32()
external int th32ModuleID;
@ffi.Uint32()
external int cntThreads;
@ffi.Uint32()
external int th32ParentProcessID;
@ffi.Int32()
external int pcPriClassBase;
@ffi.Uint32()
external int dwFlags;
@ffi.Array<ffi.Uint8>(260)
external ffi.Array<ffi.Uint8> szExeFile;
}
/// Samples the current process via a single toolhelp snapshot (thread count +
/// ConPTY-host children) plus GetProcessHandleCount and ProcessInfo.currentRss.
class WindowsResourceSampler implements ResourceSampler {
@override
ResourceSample sample() {
final (threads, children) = _snapshotThreadsAndHosts();
return ResourceSample(threads: threads, children: children, handles: _handleCount(), rssBytes: _rss());
}
/// One toolhelp snapshot → (this process's thread count, count of its direct
/// conhost/OpenConsole children). Both `-1`/unavailable on any failure.
(int, int) _snapshotThreadsAndHosts() {
var threads = -1;
var conhosts = 0;
var sawAny = false;
ffi.Pointer<ffi.Void>? snap;
final entry = calloc<_ProcessEntry32>();
try {
final myPid = _getCurrentProcessId();
snap = _createToolhelp32Snapshot(_kTh32csSnapprocess, 0);
entry.ref.dwSize = ffi.sizeOf<_ProcessEntry32>();
var ok = _process32First(snap, entry);
while (ok != 0) {
sawAny = true;
if (entry.ref.th32ProcessID == myPid) threads = entry.ref.cntThreads;
if (entry.ref.th32ParentProcessID == myPid) {
final name = _exeName(entry.ref.szExeFile).toLowerCase();
if (name == 'conhost.exe' || name == 'openconsole.exe') conhosts++;
}
ok = _process32Next(snap, entry);
}
} catch (_) {
// any FFI failure → unavailable, not a crash
} finally {
if (snap != null) {
try {
_closeHandle(snap);
} catch (_) {}
}
calloc.free(entry);
}
return (threads, sawAny ? conhosts : -1);
}
int _handleCount() {
final out = calloc<ffi.Uint32>();
try {
final ok = _getProcessHandleCount(_getCurrentProcess(), out);
return ok != 0 ? out.value : -1;
} catch (_) {
return -1;
} finally {
calloc.free(out);
}
}
int _rss() {
try {
return ProcessInfo.currentRss;
} catch (_) {
return -1;
}
}
String _exeName(ffi.Array<ffi.Uint8> arr) {
final bytes = <int>[];
for (var i = 0; i < 260; i++) {
final b = arr[i];
if (b == 0) break;
bytes.add(b);
}
return String.fromCharCodes(bytes);
}
}
+57 -6
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:isolate';
import 'package:clide/app.dart'; import 'package:clide/app.dart';
import 'package:clide/test_app.dart'; import 'package:clide/test_app.dart';
@@ -39,6 +40,7 @@ import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart'; import 'package:clide/src/daemon/git_commands.dart';
import 'package:clide/src/daemon/image_commands.dart'; import 'package:clide/src/daemon/image_commands.dart';
import 'package:clide/src/daemon/log_commands.dart';
import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart';
import 'package:clide/src/daemon/status_command.dart'; import 'package:clide/src/daemon/status_command.dart';
import 'package:clide/src/daemon/ui_command.dart'; import 'package:clide/src/daemon/ui_command.dart';
@@ -51,7 +53,8 @@ import 'package:clide/src/git/client.dart';
import 'package:clide/src/cli/argv_dispatch.dart'; import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/ipc/envelope.dart'; import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/mcp_server.dart'; import 'package:clide/src/ipc/mcp_server.dart';
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath; import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory;
import 'package:clide/src/pty/pty_log.dart';
import 'package:clide/src/ipc/server.dart'; import 'package:clide/src/ipc/server.dart';
import 'package:clide/src/panes/event_sink.dart'; import 'package:clide/src/panes/event_sink.dart';
import 'package:clide/src/panes/registry.dart'; import 'package:clide/src/panes/registry.dart';
@@ -89,6 +92,11 @@ Future<void> main() async {
// at the last project instead so the daemon targets the real repo from the // at the last project instead so the daemon targets the real repo from the
// first request. (T-352) // first request. (T-352)
Directory startupWorkRoot = resolveWorkspaceRoot(Directory.current); Directory startupWorkRoot = resolveWorkspaceRoot(Directory.current);
// Crash-survivable logging (T-425): resolve the dev/prod verbosity once and
// attach a FileLogSink as the leading sink so a freeze leaves on-disk
// breadcrumbs. Desktop-only — the sink uses dart:io.
LogLevel bootLogLevel = kReleaseMode ? LogLevel.warn : LogLevel.info;
List<LogSink> bootLogSinks = const [];
if (!kIsWeb) { if (!kIsWeb) {
final bootSettings = SettingsStore(appDir: appDir); final bootSettings = SettingsStore(appDir: appDir);
await bootSettings.load(); await bootSettings.load();
@@ -97,6 +105,21 @@ Future<void> main() async {
lastProject: bootSettings.get<String>('app.lastProject'), lastProject: bootSettings.get<String>('app.lastProject'),
isGitRepo: (d) => Directory('${d.path}/.git').existsSync(), isGitRepo: (d) => Directory('${d.path}/.git').existsSync(),
); );
bootLogLevel = resolveLogLevel(
isRelease: kReleaseMode,
dartDefine: const String.fromEnvironment('CLIDE_LOG'),
envVar: Platform.environment['CLIDE_LOG'],
settingValue: bootSettings.get<String>('app.log.level'),
);
bootLogSinks = [FileLogSink(dir: Directory(logDirectory())).call];
// Crash-diagnostic watchdog in its own isolate (T-435): heartbeats +
// resource samples that survive a frozen main isolate. Non-fatal — a
// leak-detector that breaks startup is worse than a missing one. The OS
// reaps the isolate on exit; every line is fsynced, so abrupt death loses
// nothing.
try {
await Isolate.spawn(watchdogEntry, ('${logDirectory()}/clide-watchdog.log', 500, 2000));
} catch (_) {}
} }
// Resolve toolchain + boot daemon inline — same as Linux. // Resolve toolchain + boot daemon inline — same as Linux.
@@ -119,6 +142,12 @@ Future<void> main() async {
// The filter-state cache, captured post-boot so `ui.filter` can read a // The filter-state cache, captured post-boot so `ui.filter` can read a
// box's current value back — the observe-half of D-6 (T-270). // box's current value back — the observe-half of D-6 (T-270).
FilterStateCache? kernelFilterStates; FilterStateCache? kernelFilterStates;
// The kernel Logger, captured in the factory so a post-boot project switch
// can rebuild the dispatcher with PTY breadcrumbs wired (T-434).
Logger? kernelLog;
// The kernel settings store, captured post-boot so `clide log level` can
// persist app.log.level (T-433).
SettingsStore? kernelSettings;
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per // IPC socket server (T-99 / T-124, per D-70/71/72). One server per
// workspace; restarted when the active project switches because the // workspace; restarted when the active project switches because the
// socket path is workspace-derived. The local DaemonClient connects // socket path is workspace-derived. The local DaemonClient connects
@@ -228,15 +257,33 @@ Future<void> main() async {
Toolchain tc, Toolchain tc,
Directory workRoot, Directory workRoot,
LayoutArrangement arrangement, LayoutArrangement arrangement,
PanelRegistry panels, PanelRegistry panels, {
) { Logger? log,
}) {
final dispatcher = DaemonDispatcher(); final dispatcher = DaemonDispatcher();
final eventSink = _BusEventSink(events); final eventSink = _BusEventSink(events);
final paneRegistry = PaneRegistry(events: eventSink); // FFI breadcrumbs (T-434): route PTY crumbs to the kernel Logger (source
// 'conpty', an eager FileLogSink source) and a sendable crumb file the
// reader/waiter isolates open themselves. Verbose (per-syscall) crumbs only
// when the log level is debug/trace.
final ptyLog = (log == null || kIsWeb)
? PtyLog.none
: PtyLog(
onCrumb: (m) => log.trace('conpty', m),
crumbPath: '${logDirectory()}/clide-pty.crumbs.log',
verbose: log.minLevel.index <= LogLevel.debug.index,
);
final paneRegistry = PaneRegistry(events: eventSink, ptyLog: ptyLog);
// D-6 parity (T-219, D-83): make the tabs the user sees in the GUI // D-6 parity (T-219, D-83): make the tabs the user sees in the GUI
// visible to `pane list` by snapshotting the kernel PanelRegistry + // visible to `pane list` by snapshotting the kernel PanelRegistry +
// LayoutArrangement at request time — no mirrored state to drift. // LayoutArrangement at request time — no mirrored state to drift.
registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement)); registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement));
// `clide log level [<level>]` — the live verbosity toggle's CLI half (T-433,
// D-6 parity with the output-dock Level chip). Persists via the kernel
// settings, captured post-boot.
if (log != null) {
registerLogCommands(dispatcher, log, (name) async => await kernelSettings?.set<String>('app.log.level', name));
}
// Trusted read-only roots beyond the workspace: the global Claude // Trusted read-only roots beyond the workspace: the global Claude
// config dir (~/.claude), so the reader can open user-scope skill / // config dir (~/.claude), so the reader can open user-scope skill /
// agent / command markdown the Config tab surfaces (D-80, T-195). // agent / command markdown the Config tab surfaces (D-80, T-195).
@@ -341,14 +388,17 @@ Future<void> main() async {
preloadNamespaces: _tier0Namespaces, preloadNamespaces: _tier0Namespaces,
autoStartDaemonClient: false, autoStartDaemonClient: false,
toolchain: toolchain, toolchain: toolchain,
minLogLevel: bootLogLevel,
additionalSinks: bootLogSinks,
daemonClientFactory: kIsWeb daemonClientFactory: kIsWeb
? null ? null
: (log, events, arrangement, panels) { : (log, events, arrangement, panels) {
daemonBus = events; daemonBus = events;
kernelArrangement = arrangement; kernelArrangement = arrangement;
kernelPanels = panels; kernelPanels = panels;
kernelLog = log;
final workRoot = startupWorkRoot; final workRoot = startupWorkRoot;
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels); final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels, log: log);
// Build the client at the workspace's socket path. The // Build the client at the workspace's socket path. The
// server is started below (swapBackend) which the // server is started below (swapBackend) which the
// client will then auto-connect to via its reconnect // client will then auto-connect to via its reconnect
@@ -373,7 +423,7 @@ Future<void> main() async {
final arrangement = kernelArrangement; final arrangement = kernelArrangement;
final panels = kernelPanels; final panels = kernelPanels;
if (bus == null || arrangement == null || panels == null) return; if (bus == null || arrangement == null || panels == null) return;
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels); final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels, log: kernelLog);
await swapBackend(dispatcher, teardown, Directory(path)); await swapBackend(dispatcher, teardown, Directory(path));
}, },
); );
@@ -383,6 +433,7 @@ Future<void> main() async {
kernelReaderNav = services.readerNav; kernelReaderNav = services.readerNav;
kernelMessages = services.messages; kernelMessages = services.messages;
kernelFilterStates = services.filterStates; kernelFilterStates = services.filterStates;
kernelSettings = services.settings;
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54) // Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
// shows socket-side logs alongside kernel/extension ones. // shows socket-side logs alongside kernel/extension ones.
ipcLog.addSink(services.logRing.add); ipcLog.addSink(services.logRing.add);
+37
View File
@@ -0,0 +1,37 @@
/// Registers `log.level` — the live dev/prod verbosity toggle (T-433).
///
/// With no arg it reports the running [Logger]'s minimum level; with
/// `level=<name>` it sets the Logger AND persists `app.log.level` so the
/// choice survives a restart (resolved at boot by `resolveLogLevel`). The UI
/// half is the output dock's Level chip — D-6 parity. Flutter-free + trivially
/// testable: the handler takes the Logger and a persist callback.
library;
import 'package:clide/kernel/src/log.dart';
import '../ipc/envelope.dart';
import 'dispatcher.dart';
/// Persists the chosen level name (the owner wires this to
/// `settings.set('app.log.level', name)`).
typedef LogLevelPersist = Future<void> Function(String levelName);
void registerLogCommands(DaemonDispatcher d, Logger log, LogLevelPersist persist) {
d.register('log.level', (req) async {
final raw = req.args['level'];
if (raw == null) {
// Read: report the current level + the vocabulary.
return IpcResponse.ok(id: req.id, data: {'level': log.minLevel.name, 'levels': LogLevel.values.map((l) => l.name).toList()});
}
final level = parseLogLevel(raw is String ? raw : raw.toString());
if (level == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(code: 64, kind: 'bad_arg', message: 'unknown log level: $raw', hint: 'one of: ${LogLevel.values.map((l) => l.name).join(', ')}'),
);
}
log.minLevel = level;
await persist(level.name);
return IpcResponse.ok(id: req.id, data: {'level': level.name});
});
}
+65 -5
View File
@@ -4,21 +4,47 @@ import 'dart:io';
/// Resolve the per-workspace Unix-domain socket path served by the /// Resolve the per-workspace Unix-domain socket path served by the
/// running clide app. Per D-70: /// running clide app. Per D-70:
/// ///
/// Linux: `$XDG_RUNTIME_DIR/clide/<hash>.sock` /// Linux: `$XDG_RUNTIME_DIR/clide/<hash>.sock`
/// macOS: `$HOME/Library/Caches/clide/<hash>.sock` /// macOS: `$HOME/Library/Caches/clide/<hash>.sock`
/// Windows: `%LOCALAPPDATA%\clide\<hash>.sock` (AF_UNIX — supported
/// by winsock since Windows 10 1803 and by dart:io)
/// ///
/// The C `clide` client and any other consumer derive the same path /// The C `clide` client and any other consumer derive the same path
/// from the same workspace root, so server + client always agree /// from the same workspace root, so server + client always agree
/// without configuration. /// without configuration.
String workspaceSocketPath(String workspaceRoot) { String workspaceSocketPath(String workspaceRoot) {
final dir = socketDirectory(); final dir = socketDirectory();
return '$dir/${_hash(workspaceRoot)}.sock'; return '$dir/${_hash(canonicalWorkspaceKey(workspaceRoot))}.sock';
}
/// Canonical form of the workspace root used as the FNV hash input.
///
/// On Windows one directory has many spellings — either slash kind,
/// any letter case (NTFS is case-insensitive and getcwd preserves
/// whatever the shell typed) — so the server and the C client could
/// derive different hashes for the same workspace. Backslash +
/// ASCII-lower-case is the canonical spelling; the C client applies
/// the same byte-level fold (which is why this is NOT Unicode
/// `toLowerCase()` — the fold must be reproducible over raw UTF-8
/// bytes in C). POSIX paths pass through untouched.
String canonicalWorkspaceKey(String workspaceRoot) {
if (!Platform.isWindows) return workspaceRoot;
final folded = workspaceRoot.replaceAll('/', r'\');
final units = folded.codeUnits.map((u) => (u >= 0x41 && u <= 0x5a) ? u + 0x20 : u).toList();
return String.fromCharCodes(units);
} }
/// Parent directory that holds every per-workspace socket for this /// Parent directory that holds every per-workspace socket for this
/// user. Created with `0700` on bind (see D-71). Exposed separately /// user. Created with `0700` on bind (see D-71; on Windows the
/// so the server can prepare/perm-fix the directory before binding. /// per-user ACL on `%LOCALAPPDATA%` is the equivalent gate). Exposed
/// separately so the server can prepare/perm-fix the directory before
/// binding.
String socketDirectory() { String socketDirectory() {
if (Platform.isWindows) {
final local = Platform.environment['LOCALAPPDATA'];
final base = (local != null && local.isNotEmpty) ? local : '${Platform.environment['USERPROFILE'] ?? r'C:\'}\\AppData\\Local';
return '$base\\clide';
}
if (Platform.isMacOS) { if (Platform.isMacOS) {
final home = Platform.environment['HOME'] ?? '/tmp'; final home = Platform.environment['HOME'] ?? '/tmp';
return '$home/Library/Caches/clide'; return '$home/Library/Caches/clide';
@@ -28,6 +54,40 @@ String socketDirectory() {
return '$base/clide'; return '$base/clide';
} }
/// Persistent per-platform directory for crash-survivable logs (T-425).
///
/// Linux: `$XDG_STATE_HOME/clide/logs` (else `$HOME/.local/state/...`)
/// macOS: `$HOME/Library/Logs/clide`
/// Windows: `%LOCALAPPDATA%\clide\logs`
///
/// Unlike [socketDirectory] — which intentionally lives in an EPHEMERAL
/// runtime dir (`$XDG_RUNTIME_DIR`, `~/Library/Caches`) that the OS may wipe
/// on logout/reboot — this is a DURABLE location. The whole point of the
/// FileLogSink is that a freeze's last breadcrumbs survive the power-cycle, so
/// the log dir must outlive a reboot.
///
/// `CLIDE_LOG_DIR` overrides everything: CI points it at a workspace dir
/// outside the build tree so a wedged run's logs can be uploaded as an
/// artifact (T-436), and tests redirect it to a temp dir.
/// [env] defaults to [Platform.environment]; injectable for tests.
String logDirectory([Map<String, String>? env]) {
final e = env ?? Platform.environment;
final override = e['CLIDE_LOG_DIR'];
if (override != null && override.isNotEmpty) return override;
if (Platform.isWindows) {
final local = e['LOCALAPPDATA'];
final base = (local != null && local.isNotEmpty) ? local : '${e['USERPROFILE'] ?? r'C:\'}\\AppData\\Local';
return '$base\\clide\\logs';
}
if (Platform.isMacOS) {
final home = e['HOME'] ?? '/tmp';
return '$home/Library/Logs/clide';
}
final state = e['XDG_STATE_HOME'];
final base = (state != null && state.isNotEmpty) ? state : '${e['HOME'] ?? '/tmp'}/.local/state';
return '$base/clide/logs';
}
/// FNV-1a 64-bit hash of [s] as a 16-char lower-case hex string. /// FNV-1a 64-bit hash of [s] as a 16-char lower-case hex string.
/// The C client (T-126) reproduces the same algorithm byte-for-byte /// The C client (T-126) reproduces the same algorithm byte-for-byte
/// so server + client always agree on socket path. Not cryptographic /// so server + client always agree on socket path. Not cryptographic
+5 -1
View File
@@ -403,8 +403,12 @@ class IpcServer {
// -- internals ------------------------------------------------------------ // -- internals ------------------------------------------------------------
/// `chmod` via `chmod(1)` because dart:io doesn't expose the /// `chmod` via `chmod(1)` because dart:io doesn't expose the
/// syscall on unix. Cheap; only runs at start/stop. /// syscall on unix. Cheap; only runs at start/stop. No-op on
/// Windows: POSIX modes don't exist there, and the socket lives
/// under `%LOCALAPPDATA%`, whose per-user ACL already provides the
/// user-only gate D-71 wants.
Future<void> _chmod(String path, int modeBits) async { Future<void> _chmod(String path, int modeBits) async {
if (Platform.isWindows) return;
final octal = modeBits.toRadixString(8).padLeft(3, '0'); final octal = modeBits.toRadixString(8).padLeft(3, '0');
final r = await Process.run('chmod', [octal, path]); final r = await Process.run('chmod', [octal, path]);
if (r.exitCode != 0) { if (r.exitCode != 0) {
+18 -5
View File
@@ -1,6 +1,6 @@
/// [PaneRegistry] — backend-side state for all live panes. /// [PaneRegistry] — backend-side state for all live panes.
/// ///
/// Owns the [NativePty] per pane, generates `p_N` ids, and forwards /// Owns the [PtySession] per pane, generates `p_N` ids, and forwards
/// pty output + lifecycle changes as IPC events via a [DaemonEventSink]. /// pty output + lifecycle changes as IPC events via a [DaemonEventSink].
/// Pane commands (pane.spawn / list / write / resize / close) resolve /// Pane commands (pane.spawn / list / write / resize / close) resolve
/// against this registry; extension UIs subscribe to the emitted events. /// against this registry; extension UIs subscribe to the emitted events.
@@ -12,16 +12,21 @@ import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
import '../ipc/envelope.dart'; import '../ipc/envelope.dart';
import '../pty/native_pty.dart'; import '../pty/pty_log.dart';
import '../pty/pty_session.dart';
import 'event_sink.dart'; import 'event_sink.dart';
import 'pane.dart'; import 'pane.dart';
class PaneRegistry { class PaneRegistry {
PaneRegistry({required this.events}); PaneRegistry({required this.events, this.ptyLog = PtyLog.none});
final DaemonEventSink events; final DaemonEventSink events;
/// Breadcrumb hook handed to every PTY this registry spawns (T-434). Default
/// no-op; production wires it to the kernel Logger + a crumb file.
final PtyLog ptyLog;
final Map<String, Pane> _panes = {}; final Map<String, Pane> _panes = {};
final Map<String, NativePty> _sessions = {}; final Map<String, PtySession> _sessions = {};
final Map<String, StreamSubscription<Uint8List>> _subs = {}; final Map<String, StreamSubscription<Uint8List>> _subs = {};
int _nextId = 1; int _nextId = 1;
@@ -55,7 +60,15 @@ class PaneRegistry {
...?env, ...?env,
}; };
final session = NativePty.start(executable: executable, arguments: arguments, columns: cols, rows: rows, workingDirectory: cwd, environment: fullEnv); final session = startPtySession(
executable: executable,
arguments: arguments,
columns: cols,
rows: rows,
workingDirectory: cwd,
environment: fullEnv,
log: ptyLog,
);
final pane = Pane(id: id, kind: kind, pid: session.pid, argv: argv, cwd: cwd, title: title); final pane = Pane(id: id, kind: kind, pid: session.pid, argv: argv, cwd: cwd, title: title);
_panes[id] = pane; _panes[id] = pane;
_sessions[id] = session; _sessions[id] = session;
+1 -1
View File
@@ -41,7 +41,7 @@ const Map<String, String> clidePtyEnvDefaults = {
'COLORTERM': 'truecolor', 'COLORTERM': 'truecolor',
// Encourages 24-bit emission from tooling that checks this: // Encourages 24-bit emission from tooling that checks this:
'CLICOLOR_FORCE': '1', 'CLICOLOR_FORCE': '1',
// tmux inherits these when clide spawns tmux; safe to propagate. // UTF-8 locale for the child and anything it spawns; safe to propagate.
'LANG': 'en_US.UTF-8', 'LANG': 'en_US.UTF-8',
'LC_ALL': 'en_US.UTF-8', 'LC_ALL': 'en_US.UTF-8',
}; };
+51 -15
View File
@@ -25,8 +25,11 @@ import 'dart:typed_data';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'errors.dart'; import 'errors.dart';
import 'pty_log.dart';
import 'pty_size.dart';
import '../ipc/errno_mapping.dart' show PosixErrno; import '../ipc/errno_mapping.dart' show PosixErrno;
import 'ffi/libc.dart' as libc; import 'ffi/libc.dart' as libc;
import 'pty_session.dart';
// -- structs ---------------------------------------------------------------- // -- structs ----------------------------------------------------------------
@@ -137,8 +140,9 @@ const _kWnohang = 1;
// -- NativePty -------------------------------------------------------------- // -- NativePty --------------------------------------------------------------
/// A pseudo-terminal backed by forkpty() via Dart FFI. /// A pseudo-terminal backed by forkpty() via Dart FFI.
class NativePty { class NativePty implements PtySession {
final int _fd; final int _fd;
@override
final int pid; final int pid;
final _out = StreamController<Uint8List>.broadcast(); final _out = StreamController<Uint8List>.broadcast();
bool _dead = false; bool _dead = false;
@@ -150,11 +154,18 @@ class NativePty {
ReceivePort? _readerPort; ReceivePort? _readerPort;
Completer<void>? _readerExited; Completer<void>? _readerExited;
NativePty._(this._fd, this.pid); /// Breadcrumb file path + verbosity threaded into the reader isolate (T-434).
/// Plain values so they survive `Isolate.spawn`.
final String? _crumbPath;
final bool _verbose;
NativePty._(this._fd, this.pid, this._crumbPath, this._verbose);
/// Byte stream of data produced by the child. /// Byte stream of data produced by the child.
@override
Stream<Uint8List> get output => _out.stream; Stream<Uint8List> get output => _out.stream;
@override
bool get isClosed => _dead; bool get isClosed => _dead;
/// Spawn a new PTY running [executable] with [arguments]. /// Spawn a new PTY running [executable] with [arguments].
@@ -172,7 +183,9 @@ class NativePty {
required int rows, required int rows,
String? workingDirectory, String? workingDirectory,
Map<String, String> environment = const {}, Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) { }) {
log.crumb('native: start exe=$executable');
// Resolve bare command names via PATH (posix_spawn requires an absolute // Resolve bare command names via PATH (posix_spawn requires an absolute
// or relative path — posix_spawnp would search PATH for us but we want // or relative path — posix_spawnp would search PATH for us but we want
// resolution to be visible/debuggable from Dart). // resolution to be visible/debuggable from Dart).
@@ -291,8 +304,10 @@ class NativePty {
} }
// ---- Spawn ------------------------------------------------------- // ---- Spawn -------------------------------------------------------
log.crumb('native: posix_spawn enter');
final spawnRc = _posixSpawn(pidOut, exeN, fa, attr, argvN, envpN); final spawnRc = _posixSpawn(pidOut, exeN, fa, attr, argvN, envpN);
final pid = pidOut.value; final pid = pidOut.value;
log.crumb('native: posix_spawn -> rc=$spawnRc pid=$pid');
_faDestroy(fa); _faDestroy(fa);
_spawnattrDestroy(attr); _spawnattrDestroy(attr);
@@ -306,14 +321,14 @@ class NativePty {
// ---- Set initial winsize on the master --------------------------- // ---- Set initial winsize on the master ---------------------------
final ws = calloc<_Winsize>() final ws = calloc<_Winsize>()
..ref.wsRow = rows ..ref.wsRow = clampPtyDimension(rows)
..ref.wsCol = columns; ..ref.wsCol = clampPtyDimension(columns);
_ioctl(masterFd, _kTiocsWinsz, ws); _ioctl(masterFd, _kTiocsWinsz, ws);
calloc.free(ws); calloc.free(ws);
freeAllInputs(); freeAllInputs();
final pty = NativePty._(masterFd, pid); final pty = NativePty._(masterFd, pid, log.crumbPath, log.verbose);
pty._spawnReader(); pty._spawnReader();
return pty; return pty;
} }
@@ -344,7 +359,7 @@ class NativePty {
} }
}); });
try { try {
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd)); _readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd, _crumbPath, _verbose));
} catch (e) { } catch (e) {
// Surface the spawn failure instead of leaving the PTY in a // Surface the spawn failure instead of leaving the PTY in a
// half-alive state where output never flows but isClosed=false. // half-alive state where output never flows but isClosed=false.
@@ -358,8 +373,13 @@ class NativePty {
} }
/// Isolate entry — polls then reads until EOF/error/fd-closed. /// Isolate entry — polls then reads until EOF/error/fd-closed.
static void _readLoop((SendPort, int) msg) { static void _readLoop((SendPort, int, String?, bool) msg) {
final (port, fd) = msg; final (port, fd, crumbPath, verbose) = msg;
// The reader runs in a SPAWNED isolate with no Logger; it opens its own
// append handle so a wedge in read()/poll() leaves its last crumb on disk
// even if the main isolate is frozen too (T-434).
final crumbs = IsolateCrumbFile(crumbPath, 'pty.reader');
crumbs.crumb('reader started fd=$fd');
final dl = ffi.DynamicLibrary.process(); final dl = ffi.DynamicLibrary.process();
final rd = dl.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('read'); final rd = dl.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('read');
final poll = dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<_Pollfd>, ffi.Uint32, ffi.Int32), int Function(ffi.Pointer<_Pollfd>, int, int)>('poll'); final poll = dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<_Pollfd>, ffi.Uint32, ffi.Int32), int Function(ffi.Pointer<_Pollfd>, int, int)>('poll');
@@ -369,30 +389,43 @@ class NativePty {
pfd.ref.fd = fd; pfd.ref.fd = fd;
pfd.ref.events = libc.pollin; pfd.ref.events = libc.pollin;
var reason = 'eof';
try { try {
while (true) { while (true) {
final ready = poll(pfd, 1, 100); final ready = poll(pfd, 1, 100);
if (ready < 0) break; if (ready < 0) {
reason = 'poll<0';
break;
}
if (ready == 0) continue; if (ready == 0) continue;
// Slave closed (POLLHUP / POLLERR / POLLNVAL) with no buffered // Slave closed (POLLHUP / POLLERR / POLLNVAL) with no buffered
// bytes left to read — caller loop exits and we send EOF. // bytes left to read — caller loop exits and we send EOF.
if (pfd.ref.revents & libc.pollAnyErr != 0 && pfd.ref.revents & libc.pollin == 0) { if (pfd.ref.revents & libc.pollAnyErr != 0 && pfd.ref.revents & libc.pollin == 0) {
reason = 'pollhup';
break; break;
} }
if (verbose) crumbs.crumb('read enter fd=$fd');
final n = rd(fd, buf.cast(), 65536); final n = rd(fd, buf.cast(), 65536);
if (n <= 0) break; if (verbose) crumbs.crumb('read -> n=$n');
if (n <= 0) {
reason = 'read<=0 ($n)';
break;
}
port.send(Uint8List.fromList(buf.asTypedList(n))); port.send(Uint8List.fromList(buf.asTypedList(n)));
} }
} finally { } finally {
calloc.free(pfd); calloc.free(pfd);
malloc.free(buf); malloc.free(buf);
} }
crumbs.crumb('reader exiting ($reason)');
crumbs.close();
port.send(null); port.send(null);
} }
/// Write bytes to the child's stdin. Loops on short writes; throws /// Write bytes to the child's stdin. Loops on short writes; throws
/// [PtyException] (with errno) on failure. Returns the total bytes /// [PtyException] (with errno) on failure. Returns the total bytes
/// written, which is always [bytes.length] on success. /// written, which is always [bytes.length] on success.
@override
int write(List<int> bytes) { int write(List<int> bytes) {
if (_dead || bytes.isEmpty) return 0; if (_dead || bytes.isEmpty) return 0;
final buf = malloc<ffi.Uint8>(bytes.length); final buf = malloc<ffi.Uint8>(bytes.length);
@@ -420,11 +453,12 @@ class NativePty {
/// Resize the terminal. Silently no-ops if the fd is already /// Resize the terminal. Silently no-ops if the fd is already
/// closed; flips [_dead] on EBADF so subsequent calls short-circuit. /// closed; flips [_dead] on EBADF so subsequent calls short-circuit.
@override
void resize({required int cols, required int rows}) { void resize({required int cols, required int rows}) {
if (_dead) return; if (_dead) return;
final ws = calloc<_Winsize>() final ws = calloc<_Winsize>()
..ref.wsRow = rows ..ref.wsRow = clampPtyDimension(rows)
..ref.wsCol = cols; ..ref.wsCol = clampPtyDimension(cols);
final rc = _ioctl(_fd, _kTiocsWinsz, ws); final rc = _ioctl(_fd, _kTiocsWinsz, ws);
calloc.free(ws); calloc.free(ws);
if (rc < 0 && libc.errno == PosixErrno.ebadf) { if (rc < 0 && libc.errno == PosixErrno.ebadf) {
@@ -435,10 +469,11 @@ class NativePty {
_nativeKill(pid, libc.sigwinch); _nativeKill(pid, libc.sigwinch);
} }
/// Send a signal to the child. /// Send a signal to the child. Null means SIGHUP.
bool kill([int signal = libc.sighup]) { @override
bool kill([int? signal]) {
if (_dead) return false; if (_dead) return false;
return _nativeKill(pid, signal) == 0; return _nativeKill(pid, signal ?? libc.sighup) == 0;
} }
void _reap() { void _reap() {
@@ -462,6 +497,7 @@ class NativePty {
/// closing it before the isolate exits creates a window where the /// closing it before the isolate exits creates a window where the
/// fd number could be reused and the isolate would briefly poll /// fd number could be reused and the isolate would briefly poll
/// the wrong file. /// the wrong file.
@override
Future<void> close() async { Future<void> close() async {
if (_dead) return; if (_dead) return;
_dead = true; _dead = true;
+6 -4
View File
@@ -1,8 +1,10 @@
/// PTY subsystem — spawn child processes under a PTY via posix_openpt() /// PTY subsystem — spawn child processes under a PTY and expose their
/// + posix_spawn(), expose their master fd as a byte stream. Desktop /// output as a byte stream. POSIX uses posix_openpt() + posix_spawn();
/// IDE's pane model (terminal / Claude / future tmux wrappers) rides on /// Windows uses ConPTY. Desktop IDE's pane model (terminal / Claude /
/// this. /// future PTY-backed panes) rides on this.
library; library;
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv; export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
export 'native_pty.dart' show NativePty; export 'native_pty.dart' show NativePty;
export 'pty_session.dart' show PtySession, startPtySession;
export 'windows_pty.dart' show WindowsPty;
+116
View File
@@ -0,0 +1,116 @@
/// Breadcrumb logging for the PTY backends (T-434, under the T-425 epic).
///
/// The Windows freeze hypothesis is a wedged FFI call — a reader isolate
/// blocked forever in `ReadFile`, a waiter stuck in `WaitForSingleObject`,
/// `Isolate.kill` unable to interrupt either (dart-lang/sdk#46680). To NAME
/// the wedge after a power-cycle, each backend drops a breadcrumb before and
/// after every risky syscall. There are two delivery paths because the
/// reader/waiter run in SPAWNED isolates that cannot see the main isolate's
/// [Logger] — only sendable values cross `Isolate.spawn`:
///
/// - Main isolate → [PtyLog.crumb], a callback the kernel wires to its
/// Logger (source `pty`/`conpty`, an eager FileLogSink source, so each
/// crumb is fsynced).
/// - Spawned isolates → [IsolateCrumbFile], opened from a plain file path
/// (sendable) so the isolate writes with its OWN append handle and
/// flushSync per line. That is the whole point: a reader wedged in
/// `ReadFile` leaves its last "ReadFile enter" crumb on disk even though
/// the main isolate (and its Logger) may be frozen too.
///
/// Default is fully no-op: callers that pass nothing ([PtyLog.none]) get
/// exactly today's behaviour and zero I/O. Everything swallows its own errors
/// — a logging failure must never perturb the PTY it is observing.
library;
import 'dart:convert';
import 'dart:io';
/// Main-isolate breadcrumb hook handed to a PTY backend.
class PtyLog {
const PtyLog({this.onCrumb, this.crumbPath, this.verbose = false});
/// Called on the main isolate for each lifecycle/syscall breadcrumb. The
/// kernel wires this to `(m) => logger.trace('pty', m)`.
final void Function(String message)? onCrumb;
/// File path the SPAWNED reader/waiter isolates open for their own crumbs.
/// A String (not a closure/Logger) so it survives `Isolate.spawn`. Null
/// disables isolate crumbs.
final String? crumbPath;
/// When true, the high-frequency per-syscall crumbs fire too (debug/trace
/// level). When false, only low-frequency lifecycle crumbs are written, so
/// production wiring stays cheap.
final bool verbose;
/// The no-op default — zero I/O, today's behaviour.
static const none = PtyLog();
/// Emit a main-isolate breadcrumb. Never throws.
void crumb(String message) {
final cb = onCrumb;
if (cb == null) return;
try {
cb(message);
} catch (_) {}
}
}
/// An append-only breadcrumb file for use INSIDE a spawned isolate, where no
/// [Logger] is reachable. Opens [path] once and flushSync per line so a wedge
/// leaves its last crumb on disk. Bounded: truncates back to empty once it
/// passes [capBytes] (we only ever need the tail before a wedge), so a chatty
/// session can't grow it without limit. Every operation swallows its own error.
class IsolateCrumbFile {
IsolateCrumbFile(String? path, this.source, {int capBytes = 256 * 1024}) : _capBytes = capBytes {
if (path == null) return;
try {
final f = File(path);
// Create the parent dir ourselves — a standalone caller (the soak probe)
// may point us at a dir nothing else has made yet. In the app the
// FileLogSink already created logDirectory(), so this is a no-op there.
f.parent.createSync(recursive: true);
final raf = f.openSync(mode: FileMode.append);
_raf = raf;
_size = raf.lengthSync();
} catch (_) {
_raf = null; // a disk problem must never perturb the reader/waiter
}
}
final String source;
final int _capBytes;
RandomAccessFile? _raf;
int _size = 0;
bool get enabled => _raf != null;
/// Append one breadcrumb line, fsynced immediately.
void crumb(String message) {
final raf = _raf;
if (raf == null) return;
try {
if (_size >= _capBytes) {
// Reset to empty: truncate AND rewind. truncateSync alone leaves the
// write position at the old high-water mark, so the next write would
// land past the hole and the file would keep growing (sparse) instead
// of shrinking — rewind to 0 so we actually reclaim the space.
raf.truncateSync(0);
raf.setPositionSync(0);
_size = 0;
}
final bytes = utf8.encode('${DateTime.now().toUtc().toIso8601String()} [$source] $message\n');
raf.writeFromSync(bytes);
raf.flushSync();
_size += bytes.length;
} catch (_) {}
}
void close() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
+75
View File
@@ -0,0 +1,75 @@
/// Platform-neutral PTY session contract + factory.
///
/// The pane registry (and anything else that spawns PTY children)
/// programs against [PtySession]; [startPtySession] picks the
/// platform backend — `posix_openpt` + `posix_spawn` on Linux/macOS
/// ([NativePty]), ConPTY on Windows ([WindowsPty]). Both backends
/// share the same lifecycle: spawn → byte stream out → write/resize
/// in → EOF on child exit → close() reaps.
library;
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'native_pty.dart';
import 'pty_log.dart';
import 'windows_pty.dart';
abstract interface class PtySession {
/// OS process id of the spawned child.
int get pid;
/// Byte stream of data produced by the child.
Stream<Uint8List> get output;
bool get isClosed;
/// Write bytes to the child's stdin. Returns the bytes written.
int write(List<int> bytes);
/// Resize the terminal.
void resize({required int cols, required int rows});
/// Signal the child. [signal] is a POSIX signal number; backends
/// without signals (Windows) treat any value as terminate. Null
/// means the backend's default hang-up behaviour.
bool kill([int? signal]);
/// Kill the child and release resources.
Future<void> close();
}
/// Spawn a child under a PTY using the platform backend.
///
/// [environment] must be the complete environment — it goes straight
/// to the child. Merge `Platform.environment` before calling.
PtySession startPtySession({
required String executable,
List<String> arguments = const [],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) {
if (Platform.isWindows) {
return WindowsPty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
return NativePty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
+15
View File
@@ -0,0 +1,15 @@
/// Minimum dimension handed to any PTY backend.
///
/// A 1-column ConPTY makes the Windows conhost spin emitting CRLF forever
/// (microsoft/terminal#19922), and a 0 in either axis is invalid on both
/// platforms. Every backend clamps its spawn + resize through this, so a
/// degenerate size from the UI — a pane measured at zero width during a
/// transient layout pass — can never wedge a child. The floor (2) is below
/// any real terminal, so the clamp is invisible in normal use.
library;
/// Smallest column/row count a PTY backend will accept.
const int minPtyDimension = 2;
/// Clamp a column or row count up to [minPtyDimension].
int clampPtyDimension(int value) => value < minPtyDimension ? minPtyDimension : value;
+737
View File
@@ -0,0 +1,737 @@
/// Native PTY on Windows via ConPTY (`CreatePseudoConsole`).
///
/// Mirrors the POSIX [NativePty] lifecycle (see `native_pty.dart`):
/// spawn a child attached to a pseudo-console, surface its output as
/// a byte stream, accept writes / resizes / kills, reap on close.
///
/// The Win32 sequence:
///
/// 1. Two anonymous pipes — one ConPTY reads child input from, one
/// it writes rendered VT output to.
/// 2. `CreatePseudoConsole(size, inRead, outWrite)` → `HPCON`. The
/// conpty-side ends (`inRead` / `outWrite`) must stay open for
/// the pseudo console's whole lifetime: on current Windows 11
/// the conpty host runs IN-PROCESS and uses these very handles
/// (the old "conhost dups them, close immediately" advice from
/// the EchoCon sample era silently breaks output — the freed
/// handle slot gets recycled and conhost writes land wherever
/// it now points, observed empirically as output appearing on
/// the parent's console).
/// 3. `CreateProcessW` with `EXTENDED_STARTUPINFO_PRESENT`, the
/// `HPCON` attached via `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE`,
/// and `STARTF_USESTDHANDLES` with NULL std handles — without
/// that a console parent's std handles leak into the child and
/// its stdout bypasses the conpty entirely (also empirical; the
/// conpty handshake still fires, which makes it look attached).
/// 4. A reader isolate blocks on `ReadFile(outRead)`; a waiter
/// isolate blocks on `WaitForSingleObject(hProcess, INFINITE)`.
/// On child exit the waiter reports back and the main isolate
/// calls `ClosePseudoConsole` and closes the conpty-side pipe
/// ends — that breaks the output pipe, so the reader drains
/// whatever is still buffered, sees `ERROR_BROKEN_PIPE`, and
/// sends EOF.
///
/// Requires Windows 10 1809+ (first ConPTY release). All symbols
/// live in kernel32.dll. Errors carry `GetLastError()` in
/// [PtyException.errno] (a Win32 error code, not a POSIX errno).
library;
import 'dart:async';
import 'dart:ffi' as ffi;
import 'dart:io' show File, Platform;
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'errors.dart';
import 'pty_log.dart';
import 'pty_session.dart';
import 'pty_size.dart';
// coverage:ignore-start
//
// Everything from here to the `resolveExecutable` helper below is the
// Windows-only ConPTY FFI path: Win32 structs, kernel32 bindings, and the
// `WindowsPty` session that calls CreatePseudoConsole / CreateProcessW /
// WaitForSingleObject. The kernel32 symbols all resolve through one
// `DynamicLibrary.open('kernel32.dll')` handle (below), which has no Linux
// equivalent — so any method that touches a binding cannot even be ENTERED
// on the ubuntu-latest runner that produces the coverage report, and the
// syscall sites are genuinely uncoverable off-Windows.
//
// Honest caveat: this span is excluded at FILE granularity, but it is not
// 100% syscall. A few fragments are pure input->output and COULD be unit-
// tested on Linux if extracted into free helpers (the way the three pure
// helpers below already were): the _Coord / _StartupInfoExW struct packing
// and write()'s empty/length guard. They stay entangled here only because
// they share a method with a kernel32 binding; T-431 tracks pulling them
// (and the POSIX marshalling in native_pty.dart) into testable helpers and
// shrinking this ignore span to the raw syscalls.
//
// The pure, platform-agnostic spawn helpers (resolveExecutable / quoteArg /
// composeEnvironmentBlock) sit AFTER the ignore-end below and ARE covered on
// every platform by test/pty/windows_pty_args_test.dart. The FFI path's
// BEHAVIOUR — not its line coverage — is what the Windows runner validates:
// windows.yml spawns real ConPTY children (start/write/resize/kill/errors)
// but collects no coverage, so there is intentionally no line-coverage
// metric for this span anywhere. End-to-end leak/handle correctness is
// proven by the Windows VM soak (tools/windows-verify/).
// -- structs ----------------------------------------------------------------
/// Win32 `COORD` — passed BY VALUE to Create/ResizePseudoConsole.
final class _Coord extends ffi.Struct {
@ffi.Int16()
external int x;
@ffi.Int16()
external int y;
}
/// Win32 `STARTUPINFOEXW`. Field names follow the Win32 struct so the
/// layout is checkable against `<processthreadsapi.h>`; Dart FFI derives
/// offsets from declaration order + C alignment rules, which match MSVC
/// here (cb is followed by 4 bytes of padding before the first pointer).
final class _StartupInfoExW extends ffi.Struct {
@ffi.Uint32()
external int cb;
external ffi.Pointer<ffi.Void> lpReserved;
external ffi.Pointer<ffi.Void> lpDesktop;
external ffi.Pointer<ffi.Void> lpTitle;
@ffi.Uint32()
external int dwX;
@ffi.Uint32()
external int dwY;
@ffi.Uint32()
external int dwXSize;
@ffi.Uint32()
external int dwYSize;
@ffi.Uint32()
external int dwXCountChars;
@ffi.Uint32()
external int dwYCountChars;
@ffi.Uint32()
external int dwFillAttribute;
@ffi.Uint32()
external int dwFlags;
@ffi.Uint16()
external int wShowWindow;
@ffi.Uint16()
external int cbReserved2;
external ffi.Pointer<ffi.Void> lpReserved2;
external ffi.Pointer<ffi.Void> hStdInput;
external ffi.Pointer<ffi.Void> hStdOutput;
external ffi.Pointer<ffi.Void> hStdError;
external ffi.Pointer<ffi.Void> lpAttributeList;
}
/// Win32 `PROCESS_INFORMATION`.
final class _ProcessInformation extends ffi.Struct {
external ffi.Pointer<ffi.Void> hProcess;
external ffi.Pointer<ffi.Void> hThread;
@ffi.Uint32()
external int dwProcessId;
@ffi.Uint32()
external int dwThreadId;
}
// -- FFI bindings -----------------------------------------------------------
final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll');
typedef _Handle = ffi.Pointer<ffi.Void>;
final _createPipe = _k32
.lookupFunction<
ffi.Int32 Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer<ffi.Void>, ffi.Uint32),
int Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer<ffi.Void>, int)
>('CreatePipe');
final _createPseudoConsole = _k32
.lookupFunction<
ffi.Int32 Function(_Coord, _Handle, _Handle, ffi.Uint32, ffi.Pointer<_Handle>),
int Function(_Coord, _Handle, _Handle, int, ffi.Pointer<_Handle>)
>('CreatePseudoConsole');
final _resizePseudoConsole = _k32.lookupFunction<ffi.Int32 Function(_Handle, _Coord), int Function(_Handle, _Coord)>('ResizePseudoConsole');
final _closePseudoConsole = _k32.lookupFunction<ffi.Void Function(_Handle), void Function(_Handle)>('ClosePseudoConsole');
final _initAttrList = _k32
.lookupFunction<
ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Uint32, ffi.Uint32, ffi.Pointer<ffi.IntPtr>),
int Function(ffi.Pointer<ffi.Void>, int, int, ffi.Pointer<ffi.IntPtr>)
>('InitializeProcThreadAttributeList');
final _updateAttr = _k32
.lookupFunction<
ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Uint32, ffi.IntPtr, ffi.Pointer<ffi.Void>, ffi.IntPtr, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>),
int Function(ffi.Pointer<ffi.Void>, int, int, ffi.Pointer<ffi.Void>, int, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>)
>('UpdateProcThreadAttribute');
final _deleteAttrList = _k32.lookupFunction<ffi.Void Function(ffi.Pointer<ffi.Void>), void Function(ffi.Pointer<ffi.Void>)>('DeleteProcThreadAttributeList');
final _createProcessW = _k32
.lookupFunction<
ffi.Int32 Function(
ffi.Pointer<Utf16>,
ffi.Pointer<Utf16>,
ffi.Pointer<ffi.Void>,
ffi.Pointer<ffi.Void>,
ffi.Int32,
ffi.Uint32,
ffi.Pointer<ffi.Void>,
ffi.Pointer<Utf16>,
ffi.Pointer<_StartupInfoExW>,
ffi.Pointer<_ProcessInformation>,
),
int Function(
ffi.Pointer<Utf16>,
ffi.Pointer<Utf16>,
ffi.Pointer<ffi.Void>,
ffi.Pointer<ffi.Void>,
int,
int,
ffi.Pointer<ffi.Void>,
ffi.Pointer<Utf16>,
ffi.Pointer<_StartupInfoExW>,
ffi.Pointer<_ProcessInformation>,
)
>('CreateProcessW');
final _writeFile = _k32
.lookupFunction<
ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint8>, ffi.Uint32, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>),
int Function(_Handle, ffi.Pointer<ffi.Uint8>, int, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>)
>('WriteFile');
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(_Handle), int Function(_Handle)>('CloseHandle');
final _getLastError = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetLastError');
final _terminateProcess = _k32.lookupFunction<ffi.Int32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('TerminateProcess');
final _getExitCodeProcess = _k32.lookupFunction<ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint32>), int Function(_Handle, ffi.Pointer<ffi.Uint32>)>(
'GetExitCodeProcess',
);
// Constants — duplicated from <processthreadsapi.h> / <winbase.h>.
const int _kExtendedStartupinfoPresent = 0x00080000;
const int _kCreateUnicodeEnvironment = 0x00000400;
const int _kProcThreadAttributePseudoconsole = 0x00020016;
const int _kInfinite = 0xffffffff;
const int _kErrorBrokenPipe = 109;
const int _kStartfUseStdHandles = 0x00000100;
// -- WindowsPty -------------------------------------------------------------
/// A pseudo-terminal backed by ConPTY via Dart FFI.
class WindowsPty implements PtySession {
WindowsPty._(this._hpc, this._hProcess, this._hThread, this._inWrite, this._outRead, this._conptyInRead, this._conptyOutWrite, this.pid);
/// HPCON — owned until [close] / child exit.
ffi.Pointer<ffi.Void> _hpc;
final ffi.Pointer<ffi.Void> _hProcess;
final ffi.Pointer<ffi.Void> _hThread;
/// Our end of the child-stdin pipe (we write, ConPTY reads).
final ffi.Pointer<ffi.Void> _inWrite;
/// Our end of the child-stdout pipe (ConPTY writes, we read).
final ffi.Pointer<ffi.Void> _outRead;
/// The conpty-side pipe ends. Open for the HPCON's lifetime — the
/// in-process conpty host uses them directly; released together
/// with it in [_closeConsole]. Closing our `_conptyOutWrite` copy
/// is also what finally breaks the pipe for the reader's EOF.
final ffi.Pointer<ffi.Void> _conptyInRead;
final ffi.Pointer<ffi.Void> _conptyOutWrite;
@override
final int pid;
final _out = StreamController<Uint8List>.broadcast();
bool _dead = false;
bool _handlesReleased = false;
Future<void>? _readerReady;
Isolate? _readerIsolate;
ReceivePort? _readerPort;
Completer<void>? _readerExited;
ReceivePort? _waiterPort;
/// Breadcrumb file path + verbosity threaded into the reader/waiter isolates
/// (T-434). Plain values so they survive `Isolate.spawn`.
String? _crumbPath;
bool _verbose = false;
@override
Stream<Uint8List> get output => _out.stream;
@override
bool get isClosed => _dead;
/// Spawn a new ConPTY running [executable] with [arguments].
///
/// [environment] must be the complete environment — it becomes the
/// child's whole environment block. Merge `Platform.environment`
/// before calling.
static WindowsPty start({
required String executable,
List<String> arguments = const [],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) {
log.crumb('conpty: start exe=$executable');
executable = resolveExecutable(executable, environment);
// ---- Pipes + pseudo console ---------------------------------------
final ha = calloc<_Handle>();
final hb = calloc<_Handle>();
if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) {
final err = _getLastError();
calloc.free(ha);
calloc.free(hb);
throw PtyException('CreatePipe', 'stdin pipe creation failed', errno: err);
}
final inRead = ha.value;
final inWrite = hb.value;
if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) {
final err = _getLastError();
_closeHandle(inRead);
_closeHandle(inWrite);
calloc.free(ha);
calloc.free(hb);
throw PtyException('CreatePipe', 'stdout pipe creation failed', errno: err);
}
final outRead = ha.value;
final outWrite = hb.value;
calloc.free(ha);
calloc.free(hb);
final size = calloc<_Coord>()
..ref.x = clampPtyDimension(columns)
..ref.y = clampPtyDimension(rows);
final hpcOut = calloc<_Handle>();
log.crumb('conpty: CreatePseudoConsole enter');
final hr = _createPseudoConsole(size.ref, inRead, outWrite, 0, hpcOut);
log.crumb('conpty: CreatePseudoConsole -> hr=$hr');
calloc.free(size);
if (hr != 0) {
_closeHandle(inRead);
_closeHandle(inWrite);
_closeHandle(outRead);
_closeHandle(outWrite);
calloc.free(hpcOut);
throw PtyException('CreatePseudoConsole', 'HRESULT 0x${(hr & 0xffffffff).toRadixString(16)}');
}
final hpc = hpcOut.value;
calloc.free(hpcOut);
// inRead / outWrite deliberately stay open — the in-process conpty
// uses them for its whole lifetime (see the library docstring).
// _closeConsole() releases them together with the HPCON.
// ---- Attribute list (attaches the HPCON to the child) -------------
final sizeOut = calloc<ffi.IntPtr>();
_initAttrList(ffi.nullptr, 1, 0, sizeOut); // sizing call; "fails" with ERROR_INSUFFICIENT_BUFFER by design
final attrBytes = sizeOut.value;
final attrList = calloc<ffi.Uint8>(attrBytes).cast<ffi.Void>();
void freeAttrs() {
calloc.free(attrList);
calloc.free(sizeOut);
}
void bail(String op, String message) {
final err = _getLastError();
freeAttrs();
_closePseudoConsole(hpc);
_closeHandle(inRead);
_closeHandle(outWrite);
_closeHandle(inWrite);
_closeHandle(outRead);
throw PtyException(op, message, errno: err);
}
if (_initAttrList(attrList, 1, 0, sizeOut) == 0) {
bail('InitializeProcThreadAttributeList', 'attribute list init failed');
}
// The HPCON itself is lpValue — the attribute machinery stores the
// pointer, it does NOT copy through it. Passing a pointer-to-slot
// here "succeeds" but hands the child a garbage console and ConPTY
// silently produces no output. (Matches the EchoCon sample.)
if (_updateAttr(attrList, 0, _kProcThreadAttributePseudoconsole, hpc, ffi.sizeOf<_Handle>(), ffi.nullptr, ffi.nullptr) == 0) {
_deleteAttrList(attrList);
bail('UpdateProcThreadAttribute', 'attaching HPCON failed');
}
// ---- Marshal command line + environment + cwd ----------------------
// App name stays null so CreateProcessW does its own first-token
// parse (which also gives .bat/.cmd their cmd.exe host); the
// executable is pre-resolved to an absolute path above so no PATH
// ambiguity is left at this point.
final cmdLine = [executable, ...arguments].map(quoteArg).join(' ').toNativeUtf16(allocator: malloc);
final envBlock = composeEnvironmentBlock(environment).toNativeUtf16(allocator: malloc);
final cwdN = workingDirectory == null ? ffi.nullptr : workingDirectory.toNativeUtf16(allocator: malloc);
// STARTF_USESTDHANDLES with NULL std handles (calloc zeroes them):
// the console subsystem then assigns conpty-backed handles at
// client connect instead of leaking the parent's (docstring §3).
final si = calloc<_StartupInfoExW>()
..ref.cb = ffi.sizeOf<_StartupInfoExW>()
..ref.dwFlags = _kStartfUseStdHandles
..ref.lpAttributeList = attrList;
final pi = calloc<_ProcessInformation>();
log.crumb('conpty: CreateProcessW enter');
final ok = _createProcessW(
ffi.nullptr,
cmdLine,
ffi.nullptr,
ffi.nullptr,
0,
_kExtendedStartupinfoPresent | _kCreateUnicodeEnvironment,
envBlock.cast(),
cwdN.cast(),
si,
pi,
);
final spawnErr = ok == 0 ? _getLastError() : 0;
log.crumb('conpty: CreateProcessW -> ok=$ok err=$spawnErr');
_deleteAttrList(attrList);
freeAttrs();
malloc.free(cmdLine);
malloc.free(envBlock);
if (cwdN != ffi.nullptr) malloc.free(cwdN.cast<ffi.Uint8>());
calloc.free(si);
if (ok == 0) {
calloc.free(pi);
_closePseudoConsole(hpc);
_closeHandle(inRead);
_closeHandle(outWrite);
_closeHandle(inWrite);
_closeHandle(outRead);
throw PtyException('CreateProcessW', 'spawn of $executable failed', errno: spawnErr);
}
final hProcess = pi.ref.hProcess;
final hThread = pi.ref.hThread;
final childPid = pi.ref.dwProcessId;
calloc.free(pi);
final pty = WindowsPty._(hpc, hProcess, hThread, inWrite, outRead, inRead, outWrite, childPid)
.._crumbPath = log.crumbPath
.._verbose = log.verbose;
log.crumb('conpty: spawned pid=$childPid');
pty._spawnReader();
pty._spawnWaiter();
return pty;
}
// -- I/O --------------------------------------------------------------
void _spawnReader() {
_readerReady = _spawnReaderAsync();
}
Future<void> _spawnReaderAsync() async {
final rp = ReceivePort();
_readerPort = rp;
_readerExited = Completer<void>();
rp.listen((msg) {
if (msg == null) {
if (!_out.isClosed) _out.close();
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
_reap();
} else {
if (!_out.isClosed) _out.add(msg as Uint8List);
}
});
try {
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _outRead.address, _crumbPath, _verbose));
} catch (e) {
_dead = true;
if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e'));
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
}
}
/// Isolate entry — blocking ReadFile until the ConPTY side closes.
static void _readLoop((SendPort, int, String?, bool) msg) {
final (port, handleAddr, crumbPath, verbose) = msg;
// This isolate is the prime suspect for the freeze: ReadFile blocks
// forever if the ConPTY host never closes the pipe, and Isolate.kill
// can't interrupt the FFI (dart-lang/sdk#46680). It opens its OWN append
// handle so its last "ReadFile enter" crumb survives even a frozen main
// isolate — the breadcrumb that NAMES the wedge after a power-cycle (T-434).
final crumbs = IsolateCrumbFile(crumbPath, 'conpty.reader');
crumbs.crumb('reader started handle=$handleAddr');
final handle = ffi.Pointer<ffi.Void>.fromAddress(handleAddr);
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
final readFile = k32
.lookupFunction<
ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint8>, ffi.Uint32, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>),
int Function(_Handle, ffi.Pointer<ffi.Uint8>, int, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>)
>('ReadFile');
final buf = malloc<ffi.Uint8>(65536);
final nRead = calloc<ffi.Uint32>();
var reason = 'eof';
try {
while (true) {
// Blocks until data, broken pipe (ConPTY closed), or invalid
// handle (close() already released it).
if (verbose) crumbs.crumb('ReadFile enter');
final ok = readFile(handle, buf, 65536, nRead, ffi.nullptr);
if (verbose) crumbs.crumb('ReadFile -> ok=$ok n=${nRead.value}');
if (ok == 0) {
reason = 'broken-pipe/invalid';
break;
}
final n = nRead.value;
if (n == 0) {
reason = 'n=0';
break;
}
port.send(Uint8List.fromList(buf.asTypedList(n)));
}
} finally {
calloc.free(nRead);
malloc.free(buf);
}
crumbs.crumb('reader exiting ($reason)');
crumbs.close();
port.send(null);
}
/// Watches for child exit so the pseudo console can be torn down —
/// without ClosePseudoConsole the output pipe never breaks and the
/// reader would block forever on an exited child.
void _spawnWaiter() {
final wp = ReceivePort();
_waiterPort = wp;
wp.listen((_) {
wp.close();
_waiterPort = null;
_closeConsole();
});
Isolate.spawn(_waitLoop, (wp.sendPort, _hProcess.address, _crumbPath)).catchError((Object e) {
// Fall back to close()-driven teardown; the child just won't be
// auto-reaped on self-exit.
wp.close();
_waiterPort = null;
return Isolate.current; // satisfies the Future<Isolate> type; unused
});
}
static void _waitLoop((SendPort, int, String?) msg) {
final (port, handleAddr, crumbPath) = msg;
final crumbs = IsolateCrumbFile(crumbPath, 'conpty.waiter');
crumbs.crumb('waiter started; WaitForSingleObject(INFINITE) enter');
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
final wait = k32.lookupFunction<ffi.Uint32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('WaitForSingleObject');
final r = wait(ffi.Pointer<ffi.Void>.fromAddress(handleAddr), _kInfinite);
crumbs.crumb('WaitForSingleObject -> $r (child exited)');
crumbs.close();
port.send(null);
}
@override
int write(List<int> bytes) {
if (_dead || bytes.isEmpty) return 0;
final buf = malloc<ffi.Uint8>(bytes.length);
final nWritten = calloc<ffi.Uint32>();
try {
for (var i = 0; i < bytes.length; i++) {
buf[i] = bytes[i];
}
var written = 0;
while (written < bytes.length) {
final ok = _writeFile(_inWrite, buf + written, bytes.length - written, nWritten, ffi.nullptr);
if (ok == 0) {
final err = _getLastError();
if (err == _kErrorBrokenPipe) _dead = true;
throw PtyException('WriteFile', 'write to ConPTY failed', errno: err);
}
if (nWritten.value == 0) break;
written += nWritten.value;
}
return written;
} finally {
malloc.free(buf);
calloc.free(nWritten);
}
}
@override
void resize({required int cols, required int rows}) {
if (_dead || _hpc == ffi.nullptr) return;
final size = calloc<_Coord>()
..ref.x = clampPtyDimension(cols)
..ref.y = clampPtyDimension(rows);
_resizePseudoConsole(_hpc, size.ref);
calloc.free(size);
}
/// Windows has no signals — any [signal] terminates the child.
@override
bool kill([int? signal]) {
if (_dead || _handlesReleased) return false;
return _terminateProcess(_hProcess, 1) != 0;
}
/// Close the HPCON and the conpty-side pipe ends, once. With every
/// write end of the output pipe gone the reader drains what's left
/// and EOFs.
void _closeConsole() {
final hpc = _hpc;
if (hpc == ffi.nullptr) return;
_hpc = ffi.nullptr;
_closePseudoConsole(hpc);
_closeHandle(_conptyInRead);
_closeHandle(_conptyOutWrite);
}
void _reap() {
if (_dead) return;
_dead = true;
_closeConsole();
_releaseHandles();
}
void _releaseHandles() {
if (_handlesReleased) return;
_handlesReleased = true;
final code = calloc<ffi.Uint32>();
_getExitCodeProcess(_hProcess, code);
calloc.free(code);
_closeHandle(_inWrite);
_closeHandle(_outRead);
_closeHandle(_hThread);
_closeHandle(_hProcess);
}
/// Kill the child and release resources.
///
/// Order matters, mirroring the POSIX close(): terminate the child,
/// break the output pipe (ClosePseudoConsole), wait for the reader
/// to EOF so nothing touches the handles after we close them.
@override
Future<void> close() async {
if (_dead) return;
_dead = true;
await _readerReady;
_terminateProcess(_hProcess, 1);
_closeConsole();
if (_readerExited != null) {
await _readerExited!.future.timeout(const Duration(milliseconds: 500), onTimeout: () {});
}
_readerIsolate?.kill(priority: Isolate.immediate);
_readerIsolate = null;
_readerPort?.close();
_readerPort = null;
_waiterPort?.close();
_waiterPort = null;
_releaseHandles();
if (!_out.isClosed) await _out.close();
}
// coverage:ignore-end
// -- spawn helpers ------------------------------------------------------
/// Resolve a bare command name against the environment's PATH +
/// PATHEXT (mirrors what the POSIX side does with `:`-split PATH —
/// visible/debuggable resolution instead of CreateProcess magic).
///
/// [exists] overrides the on-disk probe so the resolution logic is
/// unit-testable off-Windows; production passes the default. Public for
/// that reason — not part of the backend's external contract.
static String resolveExecutable(String executable, Map<String, String> environment, {bool Function(String path)? exists}) {
final fileExists = exists ?? ((String path) => File(path).existsSync());
final pathext = (environment['PATHEXT'] ?? Platform.environment['PATHEXT'] ?? '.COM;.EXE;.BAT;.CMD').split(';').where((e) => e.isNotEmpty).toList();
final hasKnownExt = pathext.any((e) => executable.toLowerCase().endsWith(e.toLowerCase()));
Iterable<String> candidates(String base) sync* {
if (hasKnownExt) {
yield base;
} else {
yield base;
for (final ext in pathext) {
yield '$base$ext';
}
}
}
if (executable.contains('\\') || executable.contains('/')) {
for (final c in candidates(executable)) {
if (fileExists(c)) return c;
}
return executable;
}
final path = environment['PATH'] ?? Platform.environment['PATH'] ?? '';
for (final dir in path.split(';')) {
if (dir.isEmpty) continue;
for (final c in candidates('$dir\\$executable')) {
if (fileExists(c)) return c;
}
}
return executable;
}
/// Quote one argument per MSVCRT command-line parsing rules. Public so the
/// quoting rules can be unit-tested off-Windows; not an external contract.
static String quoteArg(String arg) {
if (arg.isNotEmpty && !arg.contains(RegExp(r'[ \t"\n\v]'))) return arg;
final b = StringBuffer('"');
var backslashes = 0;
for (final ch in arg.runes) {
final c = String.fromCharCode(ch);
if (c == r'\') {
backslashes++;
continue;
}
if (c == '"') {
b.write(r'\' * (backslashes * 2 + 1));
b.write('"');
backslashes = 0;
continue;
}
if (backslashes > 0) {
b.write(r'\' * backslashes);
backslashes = 0;
}
b.write(c);
}
b.write(r'\' * (backslashes * 2));
b.write('"');
return b.toString();
}
/// Compose the body of a CREATE_UNICODE_ENVIRONMENT block: `K=V\0...\0`
/// with one trailing NUL, entries sorted case-insensitively by key per
/// CreateProcess docs. The caller nativizes via `toNativeUtf16`, whose own
/// terminator completes the required double-NUL ending (which also keeps an
/// empty environment block valid). Public for unit testing.
static String composeEnvironmentBlock(Map<String, String> environment) {
final entries = environment.entries.toList()..sort((a, b) => a.key.toUpperCase().compareTo(b.key.toUpperCase()));
// NUL via fromCharCode — an inline NUL escape in a string literal
// is invisible in review and trips up text tooling.
final nul = String.fromCharCode(0);
final joined = entries.map((e) => '${e.key}=${e.value}$nul').join();
return '$joined$nul';
}
}
+1 -1
View File
@@ -146,7 +146,7 @@ class _BottomRail extends StatelessWidget {
return Container( return Container(
color: tokens.chromeBackground, color: tokens.chromeBackground,
child: ClideIconRail( child: ClideIconRail(
items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: resolveTabTitle(ctx, t))], items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: resolveTabTitle(ctx, t), iconColor: t.iconColor)],
activeId: activeId, activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id), onSelect: (id) => kernel.panels.activateTab(slot, id),
), ),
+108 -17
View File
@@ -4,6 +4,8 @@
/// of app.dart (T-394). /// of app.dart (T-394).
library; library;
import 'dart:async';
import 'package:clide/builtin/menubar/menubar.dart'; import 'package:clide/builtin/menubar/menubar.dart';
import 'package:clide/builtin/welcome/src/welcome_view.dart'; import 'package:clide/builtin/welcome/src/welcome_view.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
@@ -25,19 +27,39 @@ class RootShellState extends State<RootShell> {
late final FocusNode _keyFocus; late final FocusNode _keyFocus;
final MenuBarController _menuBar = MenuBarController(); final MenuBarController _menuBar = MenuBarController();
// Detects double-tapped bare modifiers (e.g. double-Shift → quick-open, // Detects double-tapped bare modifiers (e.g. double-Shift → quick-open,
// JetBrains "Search Everywhere"). Bare modifiers never resolve as a single // JetBrains "Search Everywhere"). Fed from a HardwareKeyboard handler, not
// chord, so this is the only path that handles them (T-341). // the focus tree: a focused editor consumes the chorded key of `Shift+;`,
// so the gesture must observe every event to know a press wasn't bare
// (T-341, T-409).
final ModifierTapTracker _modTap = ModifierTapTracker(); final ModifierTapTracker _modTap = ModifierTapTracker();
// Global multi-chord matcher for window/tab commands (ctrl+w h, gt …) (T-404).
// The passive KeyboardListener can't run sequences or consume the second
// chord (a focused editor/pane swallows it), so this lives at the
// HardwareKeyboard level where returning true consumes the event before focus
// dispatch. It only engages for chords that START a multi-chord binding in the
// active keymap, so single-chord presets (default/vscode/jetbrains) are
// untouched.
late final SequenceMatcher _globalSeq;
Timer? _seqTimeout;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_keyFocus = FocusNode()..requestFocus(); _keyFocus = FocusNode()..requestFocus();
widget.services.textZoom.addListener(_onZoom); widget.services.textZoom.addListener(_onZoom);
_globalSeq = SequenceMatcher(
keymap: () => widget.services.keymap.keymap ?? Keymap(const []),
context: () => widget.services.keymap.scope,
captureCounts: false,
);
HardwareKeyboard.instance.addHandler(_onRawKey);
} }
@override @override
void dispose() { void dispose() {
HardwareKeyboard.instance.removeHandler(_onRawKey);
_seqTimeout?.cancel();
widget.services.textZoom.removeListener(_onZoom); widget.services.textZoom.removeListener(_onZoom);
_menuBar.dispose(); _menuBar.dispose();
_keyFocus.dispose(); _keyFocus.dispose();
@@ -156,26 +178,95 @@ class RootShellState extends State<RootShell> {
void _onKey(KeyEvent event) { void _onKey(KeyEvent event) {
if (_handleMenuMnemonic(event)) return; if (_handleMenuMnemonic(event)) return;
// Double-tapped bare modifier (e.g. double-Shift → quick-open). Handle
// it here because a bare modifier never forms a single chord — an
// intervening non-modifier key breaks the gesture (T-341).
if (event is KeyDownEvent) {
final mod = KeyChord.modifierForLogicalKey(event.logicalKey);
if (mod != null) {
if (_modTap.tap(mod, DateTime.now()) != null) {
final seq = [KeyChord.bareModifier(mod), KeyChord.bareModifier(mod)];
final tapIntent = widget.services.keymap.resolveSequence(seq);
if (tapIntent != null) _dispatchIntent(tapIntent);
}
return; // a bare modifier resolves nothing else
}
_modTap.reset();
}
final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance); final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance);
if (intent == null) return; if (intent == null) return;
_dispatchIntent(intent); _dispatchIntent(intent);
} }
/// Double-tapped bare modifier (e.g. double-Shift → quick-open). Observed
/// at the HardwareKeyboard level — before focus dispatch and regardless of
/// who consumes the event — so a chorded key the focused editor swallows
/// (the `;` of `Shift+;`) still dirties the press (T-341, T-409). Fires on
/// the second clean *release*; never consumes anything.
bool _onRawKey(KeyEvent event) {
// Global window/tab sequences (ctrl+w h, gt …) get first claim — handled
// here so a focused editor/pane can't swallow the second chord (T-404).
if (_handleGlobalSequence(event)) return true;
if (event is KeyDownEvent) {
var mod = KeyChord.modifierForLogicalKey(event.logicalKey);
// A modifier pressed while a non-modifier is already held (rolled
// `a`+Shift) is a chord, not a tap.
if (mod != null && _nonModifierHeld()) mod = null;
_modTap.down(mod);
} else if (event is KeyUpEvent) {
final mod = _modTap.up(KeyChord.modifierForLogicalKey(event.logicalKey), DateTime.now());
if (mod != null) {
final seq = [KeyChord.bareModifier(mod), KeyChord.bareModifier(mod)];
final tapIntent = widget.services.keymap.resolveSequence(seq);
if (tapIntent != null) _dispatchIntent(tapIntent);
}
}
return false;
}
bool _nonModifierHeld() => HardwareKeyboard.instance.logicalKeysPressed.any((k) => KeyChord.modifierForLogicalKey(k) == null);
/// Feed one key into the global multi-chord matcher (T-404). Returns true to
/// CONSUME the event (suppressing focus dispatch) while a sequence is being
/// built or completes; false leaves the normal single-chord [_onKey] path
/// untouched. Only KeyDown events drive it — a held key must not re-fire a
/// window command.
bool _handleGlobalSequence(KeyEvent event) {
if (event is! KeyDownEvent) return false;
final chord = KeyChord.fromKeyEvent(event, HardwareKeyboard.instance);
if (chord == null) return false;
final km = widget.services.keymap.keymap;
if (km == null) return false;
final scope = widget.services.keymap.scope;
// Not mid-sequence: only START on a MODIFIED chord that's a sequence prefix
// (ctrl+w …). Bare-key sequences (gg, dd) are editor/pane-local — the
// focused widget owns them, so a global grab would steal the first chord
// before the editor ever saw it. Once pending, the bare second chord (the
// `h` of `ctrl+w h`) is consumed normally. Single-chord presets are
// untouched (no prefix → no engage).
if (!_globalSeq.hasPending) {
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
if (!modified || !km.match([chord], scope).isPrefix) return false;
}
final r = _globalSeq.feed(chord);
switch (r.outcome) {
case SeqOutcome.pending:
_armSeqTimeout();
return true;
case SeqOutcome.fired:
_cancelSeqTimeout();
_dispatchIntent(r.intent!);
return true;
case SeqOutcome.unmatched:
// The sequence broke — drop the buffer and let this lone key through to
// normal handling (the abandoned prefix, e.g. a bare ctrl+w, simply
// does nothing rather than firing late).
_cancelSeqTimeout();
return false;
}
}
/// After a pending prefix, fire its buffered exact match (bare ctrl+w →
/// editor.close) if no completing chord arrives in time — the d-vs-dd timeout
/// (D-82), applied globally.
void _armSeqTimeout() {
_seqTimeout?.cancel();
_seqTimeout = Timer(const Duration(milliseconds: 400), () {
final r = _globalSeq.flush();
if (r.outcome == SeqOutcome.fired) _dispatchIntent(r.intent!);
});
}
void _cancelSeqTimeout() {
_seqTimeout?.cancel();
_seqTimeout = null;
}
void _dispatchIntent(Intent intent) { void _dispatchIntent(Intent intent) {
// Try the focused context first so feature widgets (palette, editor, …) // Try the focused context first so feature widgets (palette, editor, …)
// get a chance to handle their own intents; fall back to the app root's // get a chance to handle their own intents; fall back to the app root's
+33 -7
View File
@@ -31,6 +31,7 @@ import 'kernel/kernel.dart';
import 'src/pty/ffi/libc.dart' as libc; import 'src/pty/ffi/libc.dart' as libc;
import 'src/daemon/pane_commands.dart'; import 'src/daemon/pane_commands.dart';
import 'src/ipc/envelope.dart'; import 'src/ipc/envelope.dart';
import 'src/ipc/paths.dart' show logDirectory;
import 'src/panes/event_sink.dart'; import 'src/panes/event_sink.dart';
import 'src/panes/registry.dart'; import 'src/panes/registry.dart';
import 'src/daemon/dispatcher.dart'; import 'src/daemon/dispatcher.dart';
@@ -59,6 +60,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_attachCrashLogging();
WidgetsBinding.instance.addPostFrameCallback((_) => _runTests()); WidgetsBinding.instance.addPostFrameCallback((_) => _runTests());
Timer(_timeout, () { Timer(_timeout, () {
_say('timeout reached — exiting'); _say('timeout reached — exiting');
@@ -66,6 +68,28 @@ class _ClideTestAppState extends State<ClideTestApp> {
}); });
} }
/// Opt-in crash evidence for testmode (T-436): when `CLIDE_LOG_DIR` is set
/// (CI / a manual Windows repro), tee this harness's logger to a
/// FileLogSink and spawn the watchdog, so a wedged testmode run leaves the
/// same log + watchdog files the real app would. `_say` breadcrumbs each
/// test through the logger, so they land in the file too. Off by default —
/// normal `make run-testmode` keeps the stderr-only path, no isolate.
void _attachCrashLogging() {
final dir = Platform.environment['CLIDE_LOG_DIR'];
if (dir == null || dir.isEmpty) return;
final logDir = logDirectory();
try {
_logger.addSink(FileLogSink(dir: Directory(logDir)).call);
} catch (_) {}
unawaited(_spawnWatchdog(logDir));
}
Future<void> _spawnWatchdog(String logDir) async {
try {
await Isolate.spawn(watchdogEntry, ('$logDir/clide-watchdog.log', 500, 2000));
} catch (_) {}
}
Future<void> _runTests() async { Future<void> _runTests() async {
const workspace = String.fromEnvironment('CLIDE_PROJECT'); const workspace = String.fromEnvironment('CLIDE_PROJECT');
const category = String.fromEnvironment('CLIDE_TESTMODE'); const category = String.fromEnvironment('CLIDE_TESTMODE');
@@ -113,27 +137,29 @@ class _ClideTestAppState extends State<ClideTestApp> {
_say('--- toolchain ---'); _say('--- toolchain ---');
_log('toolchain.git', tc.git); _log('toolchain.git', tc.git);
_log('toolchain.pql', tc.pql); _log('toolchain.pql', tc.pql);
_log('toolchain.tmux', tc.tmux);
_log('toolchain.shell', tc.shell); _log('toolchain.shell', tc.shell);
_log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', ')); _log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', '));
_say(''); _say('');
await _testExists('git', tc.git); await _testExists('git', tc.git);
await _testExists('pql', tc.pql); await _testExists('pql', tc.pql);
await _testExists('tmux', tc.tmux);
await _testExists('shell', tc.shell); await _testExists('shell', tc.shell);
_say(''); _say('');
await _testExec('git --version', tc.git, ['--version'], workDir); await _testExec('git --version', tc.git, ['--version'], workDir);
await _testExec('pql --version', tc.pql, ['--version'], workDir); await _testExec('pql --version', tc.pql, ['--version'], workDir);
await _testExec('tmux -V', tc.tmux, ['-V'], workDir); // PowerShell has no --version flag; ask for the version variable
await _testExec('shell --version', tc.shell, ['--version'], workDir); // through the same -c path the passthrough tests use.
await _testExec('shell --version', tc.shell, Platform.isWindows ? ['-c', r'$PSVersionTable.PSVersion.ToString()'] : ['--version'], workDir);
_say(''); _say('');
// Shell passthrough — use the resolved shell, not a hardcoded path // Shell passthrough — use the resolved shell, not a hardcoded path
await _testExec('shell -c git', tc.shell, ['-c', '${tc.git} --version'], workDir); // (-c works for POSIX shells and as PowerShell's -Command alias).
await _testExec('shell -c pql', tc.shell, ['-c', '${tc.pql} --version'], workDir); // PowerShell needs the & call operator to run a quoted path; POSIX
await _testExec('shell -c tmux', tc.shell, ['-c', '${tc.tmux} -V'], workDir); // shells take the bare path.
String shellCall(String exe, String args) => Platform.isWindows ? "& '$exe' $args" : '$exe $args';
await _testExec('shell -c git', tc.shell, ['-c', shellCall(tc.git, '--version')], workDir);
await _testExec('shell -c pql', tc.shell, ['-c', shellCall(tc.pql, '--version')], workDir);
await _testExec('shell -c git (bare)', tc.shell, ['-c', 'git --version'], workDir); await _testExec('shell -c git (bare)', tc.shell, ['-c', 'git --version'], workDir);
_say(''); _say('');
+5 -5
View File
@@ -5,15 +5,15 @@
/// re-derive the same four things: a [LayerLink] + [CompositedTransformFollower] /// re-derive the same four things: a [LayerLink] + [CompositedTransformFollower]
/// (or a hand-rolled `Positioned`), a full-screen tap-away barrier, the /// (or a hand-rolled `Positioned`), a full-screen tap-away barrier, the
/// `Overlay.insert` / `OverlayEntry` bookkeeping, and post-frame focus capture. /// `Overlay.insert` / `OverlayEntry` bookkeeping, and post-frame focus capture.
/// This widget owns all of it; callers supply the trigger ([anchor]) and the /// This widget owns all of it; callers supply the trigger (`anchor`) and the
/// floating content ([overlayBuilder]). Modal, centred dialogs stay on the /// floating content (`overlayBuilder`). Modal, centred dialogs stay on the
/// kernel `DialogRouter` — this is for anchored, non-modal popovers. /// kernel `DialogRouter` — this is for anchored, non-modal popovers.
library; library;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Primary placement of the floating panel relative to the [anchor]. /// Primary placement of the floating panel relative to the `anchor`.
enum ClideAnchorSide { below, above, left, right } enum ClideAnchorSide { below, above, left, right }
/// Cross-axis alignment of the panel's edge to the anchor's edge. /// Cross-axis alignment of the panel's edge to the anchor's edge.
@@ -41,8 +41,8 @@ class ClideOverlayController extends ChangeNotifier {
void toggle() => _open ? close() : open(); void toggle() => _open ? close() : open();
} }
/// Wraps [anchor] with a [CompositedTransformTarget] and, while [controller] is /// Wraps `anchor` with a [CompositedTransformTarget] and, while [controller] is
/// open, inserts an [OverlayEntry] built from [overlayBuilder], positioned /// open, inserts an [OverlayEntry] built from `overlayBuilder`, positioned
/// relative to the anchor (or centred when [centered]). /// relative to the anchor (or centred when [centered]).
class ClideAnchoredOverlay extends StatefulWidget { class ClideAnchoredOverlay extends StatefulWidget {
const ClideAnchoredOverlay({ const ClideAnchoredOverlay({
+4 -4
View File
@@ -4,9 +4,9 @@
/// list of one: there is no separate single-card path, which keeps the model /// list of one: there is no separate single-card path, which keeps the model
/// uniform and reliable. /// uniform and reliable.
/// ///
/// - **Collapsed** (default): a one-line ticker — the [label], the echoed /// - **Collapsed** (default): a one-line ticker — the `label`, the echoed
/// [collapsedSummary] (the run's latest content line), a fixed-width [counter] /// `collapsedSummary` (the run's latest content line), a fixed-width `counter`
/// ("3 steps"), and the aggregate [status] (spinner / check / cross). The /// ("3 steps"), and the aggregate `status` (spinner / check / cross). The
/// whole row is the toggle. /// whole row is the toggle.
/// - **Expanded**: a framed inner canvas wrapping the item cards; clicking the /// - **Expanded**: a framed inner canvas wrapping the item cards; clicking the
/// frame BACKGROUND (padding, the gaps between items, the gutter — anywhere an /// frame BACKGROUND (padding, the gaps between items, the gutter — anywhere an
@@ -16,7 +16,7 @@
/// ///
/// Chrome is consistent across every collapser: the chevron is hard against the /// Chrome is consistent across every collapser: the chevron is hard against the
/// LEFT edge, the status icon hard against the RIGHT edge, the counter sits in a /// LEFT edge, the status icon hard against the RIGHT edge, the counter sits in a
/// fixed-width slot just inboard of it, and [color] drives the border + the /// fixed-width slot just inboard of it, and `color` drives the border + the
/// chevron/label tint so each instance keeps its visual identity through one /// chevron/label tint so each instance keeps its visual identity through one
/// widget. The inner item cards are content (they keep their OWN per-item status /// widget. The inner item cards are content (they keep their OWN per-item status
/// + stripe); the aggregate status/count/title shown here are computed by the /// + stripe); the aggregate status/count/title shown here are computed by the
+10 -2
View File
@@ -4,11 +4,16 @@ import 'package:clide/widgets/src/clide_tappable.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class ClideIconRailItem { class ClideIconRailItem {
const ClideIconRailItem({required this.id, required this.icon, required this.tooltip}); const ClideIconRailItem({required this.id, required this.icon, required this.tooltip, this.iconColor});
final String id; final String id;
final ClideIconPainter icon; final ClideIconPainter icon;
final String tooltip; final String tooltip;
/// Brand/identity tint for this tab's icon (e.g. the Claude accent on the
/// Claude tab, T-418). Shown full-strength when active/hovered and slightly
/// dimmed when idle; null keeps the normal state colours.
final Color? iconColor;
} }
class ClideIconRail extends StatelessWidget { class ClideIconRail extends StatelessWidget {
@@ -60,7 +65,10 @@ class _RailButton extends StatelessWidget {
onTap: onTap, onTap: onTap,
tooltip: item.tooltip, tooltip: item.tooltip,
builder: (ctx, hovered, _) { builder: (ctx, hovered, _) {
final color = active final tint = item.iconColor;
final color = tint != null
? (active || hovered ? tint : tint.withValues(alpha: 0.7))
: active
? tokens.globalForeground ? tokens.globalForeground
: hovered : hovered
? tokens.sidebarForeground ? tokens.sidebarForeground
+1 -1
View File
@@ -1,5 +1,5 @@
/// Full-screen zoom + pan overlay (T-252 / D-78). A reusable primitive: it /// Full-screen zoom + pan overlay (T-252 / D-78). A reusable primitive: it
/// takes any [child] and shows it over the [DialogRouter]'s dimmed backdrop /// takes any `child` and shows it over the [DialogRouter]'s dimmed backdrop
/// (the host supplies the backdrop + outside-click dismiss). The image card is /// (the host supplies the backdrop + outside-click dismiss). The image card is
/// its first consumer; canvas / graph / diff previews can adopt it later. /// its first consumer; canvas / graph / diff previews can adopt it later.
/// ///
+2 -2
View File
@@ -1,6 +1,6 @@
/// Horizontal marquee (T-150). Shows [child] statically when it fits the /// Horizontal marquee (T-150). Shows `child` statically when it fits the
/// available width; when it's wider, scrolls it leftward in a seamless /// available width; when it's wider, scrolls it leftward in a seamless
/// loop (a second copy follows after [gap]). Clips to its box. Used by /// loop (a second copy follows after `gap`). Clips to its box. Used by
/// the status-bar slot so a long pane status doesn't get truncated. /// the status-bar slot so a long pane status doesn't get truncated.
/// ///
/// Own-the-stack: a `Ticker`-driven `SingleChildScrollView`, no package. /// Own-the-stack: a `Ticker`-driven `SingleChildScrollView`, no package.
+3 -3
View File
@@ -1,12 +1,12 @@
/// Behaviour-only wrapper every pane uses (T-150). No chrome — that's /// Behaviour-only wrapper every pane uses (T-150). No chrome — that's
/// [ClidePaneChrome]'s job. ClidePane handles cross-pane uniformity: /// [ClidePaneChrome]'s job. ClidePane handles cross-pane uniformity:
/// surfacing the pane's [statusWidget] to the bottom status bar while the /// surfacing the pane's `statusWidget` to the bottom status bar while the
/// pane is focused, via [FocusTracker.setStatusWidget]. /// pane is focused, via [FocusTracker.setStatusWidget].
/// ///
/// The widget lives with the pane; ClidePane only conveys it to the /// The widget lives with the pane; ClidePane only conveys it to the
/// shared slot while this pane is the shown one (its contribution is /// shared slot while this pane is the shown one (its contribution is
/// focused and, for multi-pane contributions, it's the [active] sub-tab), /// focused and, for multi-pane contributions, it's the `active` sub-tab),
/// and re-conveys whenever [statusWidget] changes. A backgrounded pane /// and re-conveys whenever `statusWidget` changes. A backgrounded pane
/// keeps its content locally and re-conveys on regaining focus. /// keeps its content locally and re-conveys on regaining focus.
library; library;
+2 -2
View File
@@ -2,11 +2,11 @@
/// ///
/// The host owns the text parsing + completion (where the `@`/`/` token is, how /// The host owns the text parsing + completion (where the `@`/`/` token is, how
/// to filter, how to rewrite the text on select); `ClideTypeahead` owns the /// to filter, how to rewrite the text on select); `ClideTypeahead` owns the
/// anchored overlay + the suggestion list. It is driven by [suggestions] /// anchored overlay + the suggestion list. It is driven by `suggestions`
/// non-empty shows the popover above the field, empty hides it. Unlike a menu, /// non-empty shows the popover above the field, empty hides it. Unlike a menu,
/// it does NOT capture focus or install a tap-away barrier: the text field keeps /// it does NOT capture focus or install a tap-away barrier: the text field keeps
/// focus (you're still typing), and the host closes it on text change / blur / /// focus (you're still typing), and the host closes it on text change / blur /
/// Esc. Pass [navController] to drive the highlight from the field's own key /// Esc. Pass `navController` to drive the highlight from the field's own key
/// handler (the slash typeahead does this while the EditableText keeps focus); /// handler (the slash typeahead does this while the EditableText keeps focus);
/// omit it for a mouse-only list (the @-mention). /// omit it for a mouse-only list (the @-mention).
library; library;
+1 -1
View File
@@ -16,7 +16,7 @@ typedef MultitabEntryCallback<T> = void Function(MultitabEntry<T> entry);
/// ///
/// The widget is generic and domain-free: it never knows what's /// The widget is generic and domain-free: it never knows what's
/// inside a tab. Hosts pick `T` and decide what add / close mean /// inside a tab. Hosts pick `T` and decide what add / close mean
/// (e.g. spawning or killing a tmux session for the Claude pane). /// (e.g. spawning or killing a Claude session for the Claude pane).
/// ///
/// See `docs/design/multitab-pane.md` for the design rationale. /// See `docs/design/multitab-pane.md` for the design rationale.
class MultitabPane<T> extends StatelessWidget { class MultitabPane<T> extends StatelessWidget {
+156 -41
View File
@@ -7,7 +7,9 @@
* root, same definition the Flutter app uses on boot). * root, same definition the Flutter app uses on boot).
* 2. Hashes that path with FNV-1a 64-bit and resolves the per- * 2. Hashes that path with FNV-1a 64-bit and resolves the per-
* workspace socket path per D-70 (Linux: $XDG_RUNTIME_DIR/clide/ * workspace socket path per D-70 (Linux: $XDG_RUNTIME_DIR/clide/
* <hash>.sock; macOS: $HOME/Library/Caches/clide/<hash>.sock). * <hash>.sock; macOS: $HOME/Library/Caches/clide/<hash>.sock;
* Windows: %LOCALAPPDATA%\clide\<hash>.sock AF_UNIX works on
* Windows 10 1803+ via afunix.h).
* 3. Connects, sends `{"v":1,"type":"request","id":"<pid>", * 3. Connects, sends `{"v":1,"type":"request","id":"<pid>",
* "cmd":"_argv","args":{"argv":[...]}}` (the server runs * "cmd":"_argv","args":{"argv":[...]}}` (the server runs
* parseArgv on it per T-125), reads the JSON-line response, * parseArgv on it per T-125), reads the JSON-line response,
@@ -15,21 +17,40 @@
* exits with the response's exit code. * exits with the response's exit code.
* *
* Design notes: * Design notes:
* - No third-party deps. Standard POSIX + a minimal JSON writer * - No third-party deps. Standard POSIX / Win32 + a minimal JSON
* (string-escape only we never PARSE JSON, just emit argv into * writer (string-escape only we never PARSE JSON, just emit argv
* it; the response is read whole then printed as-is to stdout). * into it; the response is read whole then printed as-is).
* - The argvIpcRequest translator lives in Dart (T-125). We just * - The argvIpcRequest translator lives in Dart (T-125). We just
* ship argv across the wire under a sentinel cmd `_argv`; the * ship argv across the wire under a sentinel cmd `_argv`; the
* server unpacks it. * server unpacks it.
* - Workspace-root discovery: we look for `.git` (dir OR file * - Workspace-root discovery: we look for `.git` (dir OR file
* submodules use a file). If we don't find one walking upward, * submodules use a file). If we don't find one walking upward,
* exit with EX_USAGE. * exit with EX_USAGE.
* - Windows hashes the CANONICAL workspace key: backslash
* separators + ASCII-lower-cased UTF-8 bytes, matching
* `canonicalWorkspaceKey` in lib/src/ipc/paths.dart. NTFS is
* case-insensitive, so the same workspace can be spelled many
* ways; both sides fold to one spelling before hashing.
* *
* Build: `make clide-cli` (see Makefile). Pure C99, builds with * Build: `make clide-cli` (see Makefile). Pure C99: gcc / clang / cc
* any gcc / clang / cc. * on POSIX, MSVC cl (+ ws2_32.lib) on Windows.
*/ */
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <afunix.h>
#include <windows.h>
#include <process.h>
#else
#define _POSIX_C_SOURCE 200809L #define _POSIX_C_SOURCE 200809L
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/un.h>
#include <unistd.h>
#endif
#include <ctype.h> #include <ctype.h>
#include <errno.h> #include <errno.h>
#include <inttypes.h> #include <inttypes.h>
@@ -37,11 +58,6 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/un.h>
#include <unistd.h>
#ifdef __APPLE__ #ifdef __APPLE__
#include <TargetConditionals.h> #include <TargetConditionals.h>
@@ -52,6 +68,32 @@
#define EX_OSERR 71 #define EX_OSERR 71
#define EX_UNAVAILABLE 69 #define EX_UNAVAILABLE 69
/* -- tiny platform shim ------------------------------------------------- */
#ifdef _WIN32
typedef SOCKET sock_t;
#define NET_INVALID INVALID_SOCKET
static int net_read(sock_t s, char *buf, int n) { return recv(s, buf, n, 0); }
static int net_write(sock_t s, const char *buf, int n) { return send(s, buf, n, 0); }
static void net_close(sock_t s) { closesocket(s); }
static int net_errno(void) { return WSAGetLastError(); }
static const char *net_strerror(int e) {
static char msg[256];
snprintf(msg, sizeof(msg), "winsock error %d", e);
return msg;
}
#define clide_getpid _getpid
#else
typedef int sock_t;
#define NET_INVALID (-1)
static int net_read(sock_t s, char *buf, int n) { return (int)read(s, buf, (size_t)n); }
static int net_write(sock_t s, const char *buf, int n) { return (int)write(s, buf, (size_t)n); }
static void net_close(sock_t s) { close(s); }
static int net_errno(void) { return errno; }
static const char *net_strerror(int e) { return strerror(e); }
#define clide_getpid getpid
#endif
static const uint64_t FNV_OFFSET = 0xcbf29ce484222325ULL; static const uint64_t FNV_OFFSET = 0xcbf29ce484222325ULL;
static const uint64_t FNV_PRIME = 0x100000001b3ULL; static const uint64_t FNV_PRIME = 0x100000001b3ULL;
@@ -65,6 +107,64 @@ static void fnv1a64_hex(const char *s, char out[17]) {
snprintf(out, 17, "%016" PRIx64, h); snprintf(out, 17, "%016" PRIx64, h);
} }
#ifdef _WIN32
/* Walk CWD upward looking for `.git` using the wide API (the path can
* contain anything; ANSI getcwd would mangle non-ACP characters), then
* emit the CANONICAL UTF-8 key: backslashes + ASCII-folded lower case.
* Mirrors `canonicalWorkspaceKey` in lib/src/ipc/paths.dart. */
static int find_workspace_root(const char *start, char *out, size_t out_size) {
(void)start; /* CWD-only on Windows; start override is unused. */
wchar_t cwd[4096];
DWORD n = GetCurrentDirectoryW(4096, cwd);
if (n == 0 || n >= 4096) return -1;
while (1) {
size_t len = wcslen(cwd);
wchar_t probe[4200];
_snwprintf(probe, 4200, (len > 0 && cwd[len - 1] == L'\\') ? L"%s.git" : L"%s\\.git", cwd);
probe[4199] = L'\0';
if (GetFileAttributesW(probe) != INVALID_FILE_ATTRIBUTES) {
int r = WideCharToMultiByte(CP_UTF8, 0, cwd, -1, out, (int)out_size, NULL, NULL);
if (r <= 0) return -1;
/* Canonical fold: '/' -> '\', ASCII upper -> lower. UTF-8
* continuation bytes have the high bit set, so the ASCII
* fold never touches multi-byte sequences. */
for (char *p = out; *p; p++) {
if (*p == '/') *p = '\\';
else if (*p >= 'A' && *p <= 'Z') *p = (char)(*p + 32);
}
return 0;
}
/* Climb one. `C:\foo` -> `C:\`; stop once the drive/UNC root
* itself has been probed. */
wchar_t *slash = wcsrchr(cwd, L'\\');
if (!slash) return -1;
if (len <= 3 && cwd[1] == L':') return -1; /* at "X:\" already */
if (slash == cwd + 2 && cwd[1] == L':') {
cwd[3] = L'\0'; /* keep the root's backslash: "X:\" */
} else if (slash == cwd) {
return -1;
} else {
*slash = L'\0';
}
}
}
/* `%LOCALAPPDATA%\clide\<hash>.sock` */
static int socket_path_for(const char *workspace_root, char *out, size_t out_size) {
char hash[17];
fnv1a64_hex(workspace_root, hash);
const char *local = getenv("LOCALAPPDATA");
if (!local || !*local) {
const char *prof = getenv("USERPROFILE");
if (!prof || !*prof) return -1;
return snprintf(out, out_size, "%s\\AppData\\Local\\clide\\%s.sock", prof, hash);
}
return snprintf(out, out_size, "%s\\clide\\%s.sock", local, hash);
}
#else /* !_WIN32 */
/* Walk `start` upward looking for an entry named `.git`. Writes the /* Walk `start` upward looking for an entry named `.git`. Writes the
* containing directory into `out` (PATH_MAX). Returns 0 on success, * containing directory into `out` (PATH_MAX). Returns 0 on success,
* -1 if no .git was found before /. */ * -1 if no .git was found before /. */
@@ -112,25 +212,39 @@ static int socket_path_for(const char *workspace_root, char *out, size_t out_siz
#endif #endif
} }
/* Open a UNIX-domain stream socket connected to `path`. Returns fd #endif /* _WIN32 */
* on success, -1 on failure (errno set). */
static int connect_unix(const char *path) { /* Open a UNIX-domain stream socket connected to `path`. Returns the
int fd = socket(AF_UNIX, SOCK_STREAM, 0); * socket on success, NET_INVALID on failure (net_errno() set). */
if (fd < 0) return -1; static sock_t connect_unix(const char *path) {
#ifdef _WIN32
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return NET_INVALID;
SOCKADDR_UN addr;
#else
struct sockaddr_un addr; struct sockaddr_un addr;
#endif
sock_t fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd == NET_INVALID) return NET_INVALID;
memset(&addr, 0, sizeof(addr)); memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX; addr.sun_family = AF_UNIX;
if (strlen(path) >= sizeof(addr.sun_path)) { if (strlen(path) >= sizeof(addr.sun_path)) {
close(fd); net_close(fd);
#ifndef _WIN32
errno = ENAMETOOLONG; errno = ENAMETOOLONG;
return -1; #endif
return NET_INVALID;
} }
strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
int saved = errno; int saved = net_errno();
close(fd); net_close(fd);
#ifndef _WIN32
errno = saved; errno = saved;
return -1; #else
WSASetLastError(saved);
#endif
return NET_INVALID;
} }
return fd; return fd;
} }
@@ -165,11 +279,11 @@ static void json_escape(const char *s, char *out, size_t out_size) {
/* Build the request envelope and write it to `out`. Returns 0 on /* Build the request envelope and write it to `out`. Returns 0 on
* success, -1 if any input was too large. */ * success, -1 if any input was too large. */
static int build_request(int argc, char **argv, pid_t pid, char *out, size_t out_size) { static int build_request(int argc, char **argv, long long pid, char *out, size_t out_size) {
/* Compute argv array size: each arg gets its own escaped JSON. */ /* Compute argv array size: each arg gets its own escaped JSON. */
int n = snprintf(out, out_size, int n = snprintf(out, out_size,
"{\"type\":\"request\",\"v\":1,\"id\":\"c%lld\",\"cmd\":\"_argv\",\"args\":{\"argv\":[", "{\"type\":\"request\",\"v\":1,\"id\":\"c%lld\",\"cmd\":\"_argv\",\"args\":{\"argv\":[",
(long long)pid); pid);
if (n < 0 || (size_t)n >= out_size) return -1; if (n < 0 || (size_t)n >= out_size) return -1;
for (int i = 0; i < argc; i++) { for (int i = 0; i < argc; i++) {
char esc[4096]; char esc[4096];
@@ -181,15 +295,17 @@ static int build_request(int argc, char **argv, pid_t pid, char *out, size_t out
return (n < 0 || (size_t)n >= out_size) ? -1 : 0; return (n < 0 || (size_t)n >= out_size) ? -1 : 0;
} }
/* Read one line (terminated by \n) from fd into out. Returns 0 on /* Read one line (terminated by \n) from the socket into out. Returns
* success, -1 on EOF / error. The trailing \n is stripped. */ * 0 on success, -1 on EOF / error. The trailing \n is stripped. */
static int read_line(int fd, char *out, size_t out_size) { static int read_line(sock_t fd, char *out, size_t out_size) {
size_t i = 0; size_t i = 0;
while (i + 1 < out_size) { while (i + 1 < out_size) {
char c; char c;
ssize_t r = read(fd, &c, 1); int r = net_read(fd, &c, 1);
if (r <= 0) { if (r <= 0) {
#ifndef _WIN32
if (r < 0 && errno == EINTR) continue; if (r < 0 && errno == EINTR) continue;
#endif
return -1; return -1;
} }
if (c == '\n') { if (c == '\n') {
@@ -263,32 +379,31 @@ int main(int argc, char **argv) {
return EX_SOFTWARE; return EX_SOFTWARE;
} }
int fd = connect_unix(sock_path); sock_t fd = connect_unix(sock_path);
if (fd < 0) { if (fd == NET_INVALID) {
fprintf(stderr, "clide: cannot connect to %s: %s\n", sock_path, strerror(errno)); fprintf(stderr, "clide: cannot connect to %s: %s\n", sock_path, net_strerror(net_errno()));
return EX_UNAVAILABLE; return EX_UNAVAILABLE;
} }
/* Build + send request. Worst-case envelope sizing: argv totals /* Build + send request. Worst-case envelope sizing: argv totals
* plus JSON overhead. 64 KB envelope handles 4 KB args * 16. */ * plus JSON overhead. 64 KB envelope handles 4 KB args * 16. */
char req[65536]; char req[65536];
if (build_request(argc - 1, argv + 1, getpid(), req, sizeof(req)) != 0) { if (build_request(argc - 1, argv + 1, (long long)clide_getpid(), req, sizeof(req)) != 0) {
fprintf(stderr, "clide: request payload too large\n"); fprintf(stderr, "clide: request payload too large\n");
close(fd); net_close(fd);
return EX_USAGE; return EX_USAGE;
} }
if (write(fd, req, strlen(req)) != (ssize_t)strlen(req)) { if (net_write(fd, req, (int)strlen(req)) != (int)strlen(req)) {
fprintf(stderr, "clide: write failed: %s\n", strerror(errno)); fprintf(stderr, "clide: write failed: %s\n", net_strerror(net_errno()));
close(fd); net_close(fd);
return EX_OSERR; return EX_OSERR;
} }
/* Read the response — one JSON line. */ /* Read the response — one JSON line. */
char resp[65536]; char resp[65536];
if (read_line(fd, resp, sizeof(resp)) != 0) { if (read_line(fd, resp, sizeof(resp)) != 0) {
fprintf(stderr, "clide: response read failed: %s\n", fprintf(stderr, "clide: response read failed: %s\n", net_strerror(net_errno()));
errno ? strerror(errno) : "short read"); net_close(fd);
close(fd);
return EX_OSERR; return EX_OSERR;
} }
@@ -325,14 +440,14 @@ int main(int argc, char **argv) {
fputc('\n', stdout); fputc('\n', stdout);
fflush(stdout); fflush(stdout);
} }
close(fd); net_close(fd);
return 0; return 0;
} }
} }
close(fd); net_close(fd);
return 0; return 0;
} }
close(fd); net_close(fd);
const char *code_v = json_value(resp, "code", &code_len); const char *code_v = json_value(resp, "code", &code_len);
const char *msg_v = json_value(resp, "message", &msg_len); const char *msg_v = json_value(resp, "message", &msg_len);
int exit_code = code_v ? (int)strtol(code_v, NULL, 10) : EX_SOFTWARE; int exit_code = code_v ? (int)strtol(code_v, NULL, 10) : EX_SOFTWARE;
+8 -8
View File
@@ -5,10 +5,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "93.0.0" version: "99.0.0"
alchemist: alchemist:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -21,10 +21,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.1" version: "12.1.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -93,10 +93,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: coverage name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.15.0" version: "1.15.1"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -547,10 +547,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.1.0" version: "15.2.0"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -13,7 +13,7 @@ description: >-
subsystem handlers (pane, files, editor, git, pql), and the subsystem handlers (pane, files, editor, git, pql), and the
extension framework. extension framework.
publish_to: none publish_to: none
version: 2.4.0 version: 2.5.0
repository: https://github.com/postmeridiem/clide repository: https://github.com/postmeridiem/clide
# Short user-facing tagline (the welcome subtitle, web meta # Short user-facing tagline (the welcome subtitle, web meta
# description, etc.). Baked into lib/src/build_info.g.dart by # description, etc.). Baked into lib/src/build_info.g.dart by
+90
View File
@@ -236,6 +236,58 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('ctrl+w o fires a window command via the global matcher, not editor.close (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
expect(f.services.arrangement.isInFocusMode, isFalse);
// ctrl+w (chord) then a BARE o → panel.focusMode. The second chord is
// consumed at the hardware level, so a focused pane can't swallow it.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyW);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
expect(f.services.arrangement.isInFocusMode, isTrue);
});
testWidgets('bare ctrl+w closes the editor after the ambiguity timeout (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
f.services.arrangement.openEditor();
expect(f.services.arrangement.editorOpen, isTrue);
// ctrl+w with no completing chord: pends, then the timeout flushes the
// exact bare-ctrl+w binding (editor.close from the contributions layer).
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyW);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump(const Duration(milliseconds: 450));
expect(f.services.arrangement.editorOpen, isFalse);
});
testWidgets('a bare-key sequence prefix (g) is not grabbed by the global matcher (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
f.services.arrangement.openEditor();
// `g` is a prefix (gg) but bare → editor/pane-local. The global matcher must
// NOT consume it or fire a window command; the editor stays open.
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.pump();
expect(f.services.arrangement.isInFocusMode, isFalse);
expect(f.services.arrangement.editorOpen, isTrue);
});
testWidgets('window control buttons render and tap as no-ops in tests', (tester) async { testWidgets('window control buttons render and tap as no-ops in tests', (tester) async {
await pumpApp(tester); await pumpApp(tester);
// _RightHatContent renders ClideTappable window buttons on non-macOS; // _RightHatContent renders ClideTappable window buttons on non-macOS;
@@ -354,6 +406,44 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('double-tapped bare Shift opens quick-open (T-341)', (tester) async {
await pumpApp(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(f.services.quickOpen.isOpen, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('typing colons (Shift+;) never triggers quick-open (T-409)', (tester) async {
await pumpApp(tester);
// Two rapid `:` keystrokes — the chorded `;` dirties each Shift press.
for (var i = 0; i < 2; i++) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.semicolon);
await tester.sendKeyUpEvent(LogicalKeyboardKey.semicolon);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
}
await tester.pump();
expect(f.services.quickOpen.isOpen, isFalse);
expect(tester.takeException(), isNull);
});
testWidgets('a bare Shift tap followed by a Shift chord does not fire (T-409)', (tester) async {
await pumpApp(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); // clean tap arms
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.semicolon); // chord — old code fired on the down
await tester.sendKeyUpEvent(LogicalKeyboardKey.semicolon);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(f.services.quickOpen.isOpen, isFalse);
expect(tester.takeException(), isNull);
});
testWidgets('file.closeWorkspace command closes the active project', (tester) async { testWidgets('file.closeWorkspace command closes the active project', (tester) async {
final repo = Directory.current.path; final repo = Directory.current.path;
await tester.runAsync(() async => f.services.project.open(repo)); await tester.runAsync(() async => f.services.project.open(repo));
@@ -76,6 +76,16 @@ void main() {
expect(groupConversation(const [], FoldLevel.tools), isEmpty); expect(groupConversation(const [], FoldLevel.tools), isEmpty);
}); });
test('a Workflow run stays first-class even at L3, never folded (T-416)', () {
// At every fold level the Workflow tool-use owns its own card so the live
// run card can render — it must not fold into a generic Activity cluster.
for (final level in FoldLevel.values) {
final groups = groupConversation([_tool('1', 'Workflow'), _result('1')], level);
expect(groups.first, isA<StickyItem>(), reason: '$level');
expect((groups.first as StickyItem).item, isA<AssistantToolUse>(), reason: '$level');
}
});
test('an image card stays first-class even at L3 (everything)', () { test('an image card stays first-class even at L3 (everything)', () {
final img = ImageMessage(uuid: 'i${_n++}', timestamp: _ts, isSidechain: false, path: '/abs/shot.png'); final img = ImageMessage(uuid: 'i${_n++}', timestamp: _ts, isSidechain: false, path: '/abs/shot.png');
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), img], FoldLevel.everything); final groups = groupConversation([_tool('1', 'Bash'), _result('1'), img], FoldLevel.everything);
@@ -0,0 +1,62 @@
/// Direct tests for ActivityTabView (T-415): the USAGE block renders parsed
/// /usage values; the empty state still shows under the control strip.
library;
import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage;
import 'package:clide/builtin/claude/src/meta_sidebar/activity_tab.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
testWidgets('renders the USAGE block from parsed /usage values', (tester) async {
const usage = ClaudeUsage(session: '15% used · resets Jun 12, 3:39pm', week: '53% used · resets Jun 15, 6:59pm', weekSonnet: '0% used');
await tester.pumpWidget(harness(f, const ActivityTabView(stats: ClaudeStats(), primaryStatus: null, config: null, usage: usage)));
await tester.pump();
expect(find.text('USAGE'), findsOneWidget);
expect(find.text('15% used · resets Jun 12, 3:39pm'), findsOneWidget);
expect(find.text('53% used · resets Jun 15, 6:59pm'), findsOneWidget);
expect(find.text('0% used'), findsOneWidget);
});
testWidgets('no stats and no usage → the control strip plus the placeholder', (tester) async {
await tester.pumpWidget(harness(f, const ActivityTabView(stats: ClaudeStats(), primaryStatus: null, config: null)));
await tester.pump();
expect(find.text('SESSION'), findsOneWidget); // controls always present
expect(find.text('No activity recorded yet.'), findsOneWidget);
expect(find.text('USAGE'), findsNothing);
});
testWidgets('renders a WORKFLOWS row per live run with its done/total count (T-416)', (tester) async {
var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words');
run = run.foldEvent({
'subtype': 'task_progress',
'tool_use_id': 'x1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'},
],
});
await tester.pumpWidget(harness(f, ActivityTabView(stats: const ClaudeStats(), primaryStatus: null, config: null, workflows: {'x1': run})));
await tester.pump();
expect(find.text('WORKFLOWS'), findsOneWidget);
expect(find.text('parallel-words'), findsOneWidget);
expect(find.text('1/2 agents'), findsOneWidget);
});
testWidgets('no workflows → no WORKFLOWS section', (tester) async {
await tester.pumpWidget(harness(f, const ActivityTabView(stats: ClaudeStats(), primaryStatus: null, config: null)));
await tester.pump();
expect(find.text('WORKFLOWS'), findsNothing);
});
}

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