Compare commits
+26
-27
@@ -7,6 +7,13 @@
|
||||
# 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:
|
||||
@@ -16,17 +23,18 @@ on:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit + widget + golden + a11y
|
||||
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: dart pub get
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: ci/test.sh
|
||||
- run: ci/test_a11y.sh
|
||||
- run: ci/coverage_gate.sh
|
||||
- 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)
|
||||
@@ -37,10 +45,9 @@ jobs:
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
|
||||
- run: dart pub get
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: flutter pub get
|
||||
- uses: coactions/setup-xvfb@v1
|
||||
with: { run: ci/test_integration.sh }
|
||||
with: { run: make test-integration }
|
||||
|
||||
startup-bundle:
|
||||
name: bundle smoke (xvfb 5s)
|
||||
@@ -51,24 +58,16 @@ jobs:
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
|
||||
- run: dart pub get
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: ci/smoke_bundle.sh
|
||||
- run: flutter pub get
|
||||
- run: make smoke-bundle
|
||||
|
||||
e2e:
|
||||
name: daemon subprocess + web WASM smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: dart pub get
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
|
||||
- run: ci/test_e2e.sh
|
||||
# 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)
|
||||
@@ -77,7 +76,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: dart pub get
|
||||
- run: flutter pub get
|
||||
- name: dart doc --validate-links (fail on warning)
|
||||
run: |
|
||||
set -o pipefail
|
||||
|
||||
@@ -26,3 +26,12 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:09', '2026-06-10 13:27:09', NULL, '1d5185ae9c9676bd70d0e02e3a5e79a1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '90dca94aa700c143290b2b1afaca09ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '9b9081edc77f0e9a4b689bbc191771db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', '06FB3DQEMTDHF8SV27AKAB8JHW', '2026-06-10 13:27:05', '2026-06-12 01:11:12', NULL, '17f1c884268a172f803f407a2ad47c8c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DN94MBCTYJW17ZCYVSXE0', '2026-06-10 13:27:09', '2026-06-12 01:11:17', NULL, 'a67368ff15089dce57838840632fe07e', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-12 01:11:22', NULL, '0ec8ff6c455136e45fbb1d06a2a690f1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-12 01:11:26', NULL, '3c985828c591c88d492eb261c6d26d33', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DMF20SYFDT6WX2RFBQXKW', '2026-06-12 01:11:31', '2026-06-12 01:11:31', NULL, '81fd318a43138a3bef82e31f928ff0b2', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKMXSVCE98K1H76N00TYCQR', '2026-06-12 03:16:35', '2026-06-12 03:16:35', NULL, '28d9785a1f9696b6b579a1dbb9fdb889', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN09R21H3AWR2Q2ZTSGNSW', '2026-06-12 03:16:40', '2026-06-12 03:16:40', NULL, '7e00348c10432c65b03f9dce36520e48', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', '06FBKN2MP35NPPK1BRDYY2M428', '2026-06-12 03:16:44', '2026-06-12 03:16:44', NULL, 'a6b1c20279ac30f692a30c802cf8f3e5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN4QVFVE51MY2N0CWCVXHM', '2026-06-12 03:16:49', '2026-06-12 03:16:49', NULL, '9063328f18ba32c0737997ac9af0911a', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
|
||||
@@ -3525,3 +3525,757 @@ FOLLOW-UP SCOPE (folded in 2026-06-11):
|
||||
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', NULL, '2026-06-11 07:05:54', '2026-06-11 07:05:54', '2026-06-11 07:05:54', NULL, 'f6d9c657c6987f7927bc3ba0bc02b3a4', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'backlog', 'ready', NULL, '2026-06-11 07:05:58', '2026-06-11 07:05:58', '2026-06-11 07:05:58', NULL, '1553f134361839180feffa625a88c06d', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'ready', 'done', NULL, '2026-06-11 10:12:25', '2026-06-11 10:12:25', '2026-06-11 10:12:25', NULL, '53cfd5cf779b9a8474afe7e74efd02a3', 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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:47:15', '2026-06-11 10:47:15', '2026-06-11 10:47:15', NULL, '4ac3982fdaada5c776320c99cbf0763c', 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 ('06FB493JEW32CH0H3771TNHF7G', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:49:25', '2026-06-11 10:49:25', '2026-06-11 10:49:25', NULL, 'c4fca969ce9e6d31122706878554bbdb', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:49:33', '2026-06-11 10:49:33', '2026-06-11 10:49:33', NULL, '6180a1491ff29428974ca84c0de4ebb0', 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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'status', 'in_progress', 'done', NULL, '2026-06-11 11:27:00', '2026-06-11 11:27:00', '2026-06-11 11:27:00', NULL, 'b0cf6bb97619a79a419caf4287fe2a54', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'in_progress', 'in_progress', NULL, '2026-06-11 11:27:07', '2026-06-11 11:27:07', '2026-06-11 11:27:07', NULL, '00d5d9b5cf592f3a3fc63fbef7a8b8f2', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'in_progress', 'done', NULL, '2026-06-11 12:35:27', '2026-06-11 12:35:27', '2026-06-11 12:35:27', NULL, '3426869e05daf251e562d77373d55752', 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 ('06FB493JEW32CH0H3771TNHF7G', 'status', 'in_progress', 'in_progress', NULL, '2026-06-11 12:35:27', '2026-06-11 12:35:27', '2026-06-11 12:35:27', NULL, '4f776a2c08d43710d7dadf9bb3a4b71f', 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 ('06FB493JEW32CH0H3771TNHF7G', 'status', 'in_progress', 'done', NULL, '2026-06-11 12:57:24', '2026-06-11 12:57:24', '2026-06-11 12:57:24', NULL, 'd067abbb3829e8eb200f7f854befba2c', 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 ('06FB0TNQM7CEKQCMZAV751402G', 'status', 'backlog', 'ready', NULL, '2026-06-11 13:01:55', '2026-06-11 13:01:55', '2026-06-11 13:01:55', NULL, '122a1e8ac78fe4a4429c6e146b0e1a17', 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 ('06FB0TNQM7CEKQCMZAV751402G', 'description', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency.', 'Check for new versions on startup (or on demand via command palette). Show a non-intrusive notification when an update is available. Support in-place update without losing running Claude sessions (tmux sessions survive). Respect POLICY.md: no silent network calls on default launch path — the check should be opt-in or gated behind a setting. Consider delta updates for bandwidth efficiency.
|
||||
|
||||
─────────────────────────────────────────────
|
||||
REFINED 2026-06-11
|
||||
|
||||
## Current state (grounding)
|
||||
- Version is surfaced at runtime via `lib/src/build_info.g.dart` (`clideVersion`,
|
||||
`clideCommit`, `clideDate`, `clideRepository` = github.com/postmeridiem/clide),
|
||||
generated from pubspec by `make gen-build-info`. This is the "installed version".
|
||||
- Install layout (`make install`): Linux → bundle at `~/.local/lib/clide/`, C client
|
||||
at `~/.local/bin/clide`, desktop file + icons. macOS → `~/Applications/clide.app`
|
||||
+ `~/.local/bin/clide`. Windows: not yet shipped.
|
||||
- clide currently makes NO outbound HTTP calls anywhere in `lib/`. Self-update would
|
||||
be the FIRST one — so this is a policy-sensitive feature, not just plumbing.
|
||||
- tmux owns Claude session persistence (D-41); the app re-attaches on restart. An
|
||||
in-place update that restarts the app does NOT lose sessions — they live in tmux,
|
||||
outside the bundle.
|
||||
|
||||
## HARD CONSTRAINTS (non-negotiable)
|
||||
- **D-64 (no telemetry / no phone-home):** "No auto-update checks without user
|
||||
action." This is STRICTER than this ticket''s original "opt-in or gated behind a
|
||||
setting" wording. A background/startup check — even one a setting enabled — runs
|
||||
"without user action" at that launch and conflicts with D-64. RESOLUTION: the
|
||||
version check must be **explicitly user-initiated every time** (a command-palette
|
||||
"Check for updates…" action / an About-screen button). If we ever want a
|
||||
startup/periodic check, that needs a deliberate D-64 amendment first — flag, don''t
|
||||
assume.
|
||||
- **POLICY.md §"no network on the default launch path":** opening the app, a file,
|
||||
or typing must never trigger the fetch. The update check + download are explicit
|
||||
user actions, so they''re allowed — but must meet the §"grudging allowance"
|
||||
criteria: clear error on failure (not silent), cached result, app fully functional
|
||||
if the fetch fails.
|
||||
|
||||
## BLOCKING PREREQUISITE (likely its own ticket under T-46)
|
||||
There is no release channel to update FROM today: only 2 git tags (v2.0.0, v2.1.0)
|
||||
despite being at 2.3.3, no CI (`.github/workflows` is empty), and no published binary
|
||||
artifacts. Self-update is meaningless without:
|
||||
1. Consistent, automated release tagging (every `release vX.Y.Z` commit → a tag).
|
||||
2. CI that builds the per-platform bundles and publishes them as GitHub Releases.
|
||||
3. Each artifact accompanied by a checksum AND a signature (POLICY.md: "behavior is
|
||||
determined by the SIGNED release artifact"). An unsigned/unverified download
|
||||
would break the trust model the update is supposed to preserve.
|
||||
4. A machine-readable "latest version" source — the GitHub Releases API
|
||||
(`/repos/postmeridiem/clide/releases/latest`) is the zero-infra option; a
|
||||
committed `latest.json` manifest is the alternative.
|
||||
RECOMMENDATION: split this prerequisite into a sibling story "Release channel: CI
|
||||
build + signed GitHub Releases + version manifest" and make T-47 depend on it.
|
||||
|
||||
## DECISIONS TO MAKE (surface before building)
|
||||
1. Check source: GitHub Releases API vs a hosted `latest.json`. (Lean: Releases API —
|
||||
no extra infra, origin is already GitHub.)
|
||||
2. Signature scheme + verification: minisign/age/cosign? Where does the public key
|
||||
live (vendored in-repo, per POLICY.md provenance)?
|
||||
3. Delivery: full bundle replacement vs delta/binary-patch (original ask). Lean full
|
||||
for v1 — deltas are a bandwidth optimization, not correctness; revisit if size hurts.
|
||||
4. Apply strategy per platform: Linux is easy (swap `~/.local/lib/clide/` + the
|
||||
`~/.local/bin/clide` client atomically, then relaunch). macOS `.app` replacement +
|
||||
notarization/quarantine handling is harder. Windows out of scope until it ships.
|
||||
5. Privilege: user-local installs (`~/.local`, `~/Applications`) need no sudo — good.
|
||||
A system-wide install would; declare user-local only for v1.
|
||||
|
||||
## PROPOSED SCOPE / PHASES (each independently shippable)
|
||||
P0 (prereq, separate ticket): release channel — tags + CI + signed GitHub Releases.
|
||||
P1: "Check for updates…" command (palette + About-screen button). Explicit fetch of
|
||||
the latest release, semver-compare against `clideVersion`, non-intrusive ToastService
|
||||
notification ("clide X.Y.Z is available") with a "What''s changed" link to the release
|
||||
notes. No download yet. Clear error toast on network failure. Fully covers the D-64 /
|
||||
POLICY-compliant "notify" half of the story.
|
||||
P2: download + signature/checksum verify into a staging dir; show progress; verify before
|
||||
touching the install.
|
||||
P3: apply + relaunch (Linux first): atomic swap of bundle + client, restart the app;
|
||||
tmux sessions survive (D-41). Confirm-before-apply.
|
||||
P4 (optional): macOS apply path (.app swap + quarantine), delta updates.
|
||||
|
||||
## ACCEPTANCE (for the full story; refine per-phase ticket)
|
||||
- No network call on any default launch path (verified — grep + a test that boot makes
|
||||
no outbound connection).
|
||||
- "Check for updates" only runs on explicit user action; failure surfaces a clear
|
||||
toast, never a silent hang or degraded launch.
|
||||
- A downloaded update is signature+checksum verified before it can replace the install;
|
||||
verification failure aborts with the old version intact.
|
||||
- Applying an update and relaunching preserves running Claude/tmux sessions.
|
||||
- Version comparison is correct semver (2.3.10 > 2.3.9, pre-release handling defined).
|
||||
|
||||
## REFERENCES
|
||||
POLICY.md (network rule + grudging-allowance criteria); D-64 (no phone-home);
|
||||
D-41 (tmux session persistence); `lib/src/build_info.g.dart` (version source);
|
||||
`lib/kernel/src/toast.dart` (ToastService — the notification); settings bool pattern
|
||||
(`app.*.enabled`, `lib/kernel/src/extensions_manager.dart`); `Makefile` install target
|
||||
(per-platform layout); parent epic T-46 (cross-platform installer).', NULL, '2026-06-11 13:30:03', '2026-06-11 13:30:03', '2026-06-11 13:30:03', NULL, 'cf37ae15ac8dbf59eb23e98fef32427f', 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 ('06FB0TNQM7CEKQCMZAV751402G', 'status', 'ready', 'backlog', NULL, '2026-06-11 13:30:47', '2026-06-11 13:30:47', '2026-06-11 13:30:47', NULL, '2c566e731dfce3b9e111c1c1c50ec642', 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 ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'description', NULL, 'Eliminate the out-of-repo pql dependency friction: clide ships its own pinned pql and owns first-open workspace setup. Spans four decisions — D-92 (bundle pql), D-93 (zero clide dirs in-repo), D-94 (workspace modes), D-95 (onboarding + read-mode). Outcome: a fresh clone/install of clide works with no separate pql install or version coordination; the repo''s only tool dirs are .git/ and .pql/. Three epics: T-355 bundle, T-356 footprint, T-357 onboarding.', NULL, '2026-06-11 13:37:57', '2026-06-11 13:37:57', '2026-06-11 13:37:57', NULL, '02cfffe1db3519f397186ca4cc80d736', 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 ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'description', NULL, 'Vendor a version-pinned pql binary per shipped platform under native/<platform>/, following the dugite pattern (D-59/D-63): BUILD.md provenance (upstream commit SHA, build command, toolchain, sha256), assets/licenses.yaml entry (D-42/D-65). Add a bundled-first resolver mirroring _resolveDugiteGit() in lib/kernel/src/toolchain_paths.dart: CLIDE_PQL_BIN env override -> binary next to the executable -> system pql on PATH (currently pql is PATH-only via _findOnPath at line 88). SECURITY: resolve against the install dir only, never workspace-relative — a planted ./native/pql is a code-exec vector (the T-98 dugite lesson). Bundled copy must not self-update. Add a soft version-floor check that surfaces an out-of-date override/PATH pql in the Problems panel. Switch CI to run the in-tree binary instead of assuming pql on the runner.', NULL, '2026-06-11 13:38:05', '2026-06-11 13:38:05', '2026-06-11 13:38:05', NULL, '7965e1306d3c064cee76f6537d5ad237', 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 ('06FBDSM0PRGYR61R0NWYAT9VDC', 'description', NULL, 'Move clide''s project-scoped state out of the in-repo .clide/ dir into user scope, keyed by a workspace-path hash (reuse the FNV-1a convention from D-70''s IPC socket path). Affected today: SettingsStore project file (_projectFile -> .clide/settings.yaml in lib/kernel/src/settings.dart) and theme_persistence.dart (project.theme). Provide a one-time migration that relocates an existing .clide/settings.yaml to user scope and removes the dir. Drop .clide/ from the gitignore-at-install set (only .pql/ remains). If shared/committed clide config is ever needed, it goes as clide-owned keys in .pql/config.yaml, not a new dir. Note: the open extension-DB question (governance/questions/process.md Q on .clide/clide.db) now assumes a user-scope DB. Accept D-70''s trade-off: moving/renaming a repo re-keys it and resets personal layout.', NULL, '2026-06-11 13:38:13', '2026-06-11 13:38:13', '2026-06-11 13:38:13', NULL, '08f32bc29f3cfce247957d66415d85e4', 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 ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'description', NULL, 'Add a first-open, idempotent workspace prep flow that reconciles state (virgin / pql-user / partially-init / fully-init / previously-declined) rather than blindly running pql init. Two ordered gates: (1) non-git folder -> OFFER git init, default NO, with a guard that warns when a parent .git would create a nested repo; or pick another folder. (2) pql provisioning, now that pql ships bundled (T-355): config+index is the mandatory floor (the files/query/ignore engine); the planning layer (decisions/tickets + changelog hooks, D-67) is a CONTEXTUAL opt-in offered at first open of the Decisions/Tickets surface, with disclosure. The modal must disclose everything it writes (.gitignore entries, pql config, and — opt-in only — git hooks). Handle the friction.md gotcha: pql init writes to .git/hooks and ignores an existing core.hooksPath; do not clobber it. Writable repo w/o .pql = invalid-until-init. Unwritable repo (read-only mount / no perms) -> degrade to read mode: file tree + editor + D-79 grep stay live, pql surfaces dark behind a banner (depends on T-358 modes). Remember a decline in user scope keyed by repo path; provide an explicit ''initialize workspace'' command; no re-nagging.', NULL, '2026-06-11 13:38:23', '2026-06-11 13:38:23', '2026-06-11 13:38:23', NULL, '47e2d75302ec95305ff1cacd2afc2a26', 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 ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'description', NULL, 'Introduce a ''modes'' capability in the extension manifest (lib/extension/src/manifest.dart) as an open vocabulary: edit and read now, with remote/ssh/webui reserved (D-94). The extension host (lib/extension/src/host.dart) activates an extension only when the active workspace mode is in its declared set; an undeclared extension defaults to edit-only. Then classify the builtins: read-mode-safe = editor (view), files (tree + D-79 grep), git (status/log/diff viewing), terminal, claude; goes dark = pql (search/query/backlinks), decisions, tickets, graph; partial = problems (keep non-pql diagnostics, drop the pql.doctor row). This is the substrate read-mode degrade (T-357) gates on, and the seam the SSH-remote question (Q-23) is expected to resolve into.', NULL, '2026-06-11 13:38:31', '2026-06-11 13:38:31', '2026-06-11 13:38:31', NULL, 'ba0a16d581d6c81fd0cc39ffab15887d', 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 ('06FB2T11GCV1EV07DYD5BZENTM', 'status', 'ready', 'in_progress', NULL, '2026-06-11 14:18:34', '2026-06-11 14:18:34', '2026-06-11 14:18:34', NULL, 'a2a75bcb471d6eba8a215d56922c6337', 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 ('06FB2T11GCV1EV07DYD5BZENTM', 'status', 'in_progress', 'in_progress', NULL, '2026-06-11 14:59:53', '2026-06-11 14:59:53', '2026-06-11 14:59:53', NULL, '96bef4f722871cb468f67b55d6447acc', 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 ('06FB2T11GCV1EV07DYD5BZENTM', 'status', 'in_progress', 'done', NULL, '2026-06-11 17:09:12', '2026-06-11 17:09:12', '2026-06-11 17:09:12', NULL, '591cc2576194fd9400b42aa6c932f0a8', 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 ('06FBHBGHNEQTAEPGNJKN42C1E8', 'description', NULL, 'Thirteen parallel subsystem reviewers + adversarial verification over ~58k LOC produced ~14 high-severity and ~35 medium findings plus systemic patterns and a feature backlog. Source: fable-ous.md (committed alongside this epic). Children are filed individually so they can land independently; feature proposals went to governance/questions as Q-records, not tickets.
|
||||
|
||||
Acceptance: all dragon (high-severity) findings resolved or formally rejected with a D-record; scorpions shipped or moved to follow-up work with rationale; rat-extermination and systemic-pattern batches closed; god-file split plans written into their tickets.', NULL, '2026-06-11 21:54:44', '2026-06-11 21:54:44', '2026-06-11 21:54:44', NULL, 'f4f3f8de34ddcffcedfa3dda86cddd7f', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'description', NULL, 'lib/src/pty/native_pty.dart:444-450 — on child EOF, _reap() sets _dead = true but never closes _fd; a later close() short-circuits at `if (_dead) return;` (line ~460) so _nativeClose(_fd) (line ~477) never runs. Every terminal/Claude pane whose child exits on its own leaks an fd and a pty device for the life of the app. Two independent reviewers confirmed.
|
||||
|
||||
Fix: close the master fd in the natural-exit path (or let close() proceed to fd teardown when dead). Add a test asserting the fd is released after child EOF.', NULL, '2026-06-11 21:54:56', '2026-06-11 21:54:56', '2026-06-11 21:54:56', NULL, '446dac267b474cc3c72291e1d34ed8d1', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'description', NULL, 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy.
|
||||
|
||||
Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', NULL, '2026-06-11 21:55:07', '2026-06-11 21:55:07', '2026-06-11 21:55:07', NULL, '9a3d2aecfc09ce6c6afa183e80e718f4', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'description', NULL, 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
|
||||
|
||||
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', NULL, '2026-06-11 21:55:21', '2026-06-11 21:55:21', '2026-06-11 21:55:21', NULL, '3e4a3ab07c224bc9c498c4ac168fcb42', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'description', NULL, 'lib/src/editor/registry.dart:215-219 returns absolute paths verbatim — no .. normalization, no path_safety call — an unconfined read AND write primitive over IPC while files.read is carefully guarded.
|
||||
|
||||
Fix: route editor.open/editor.save through path_safety like files.*. Tests: traversal and absolute-escape attempts rejected for both verbs. Longer-term the confinement should move to the dispatch layer (see the systemic ticket filed with this epic).', NULL, '2026-06-11 21:55:33', '2026-06-11 21:55:33', '2026-06-11 21:55:33', NULL, '9814d1e2ac630b792799349b073e29cd', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'description', NULL, 'lib/src/search/replace_engine.dart:124-143 — the include/exclude glob filters the user typed are accepted but never applied; replace will happily rewrite files outside the filter.
|
||||
|
||||
Fix: apply the same glob filtering the search side uses before rewriting. Test: replace with an include glob touches only matching files; exclude glob is honored.', NULL, '2026-06-11 21:55:44', '2026-06-11 21:55:44', '2026-06-11 21:55:44', NULL, '6f7302d4cf04632c7c336131552dddc8', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'description', NULL, 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
|
||||
|
||||
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', NULL, '2026-06-11 21:55:56', '2026-06-11 21:55:56', '2026-06-11 21:55:56', NULL, 'eb7a89483e85f7c069567c56ee80fdb9', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'description', NULL, 'lib/builtin/terminal/src/terminal_pane.dart:131-137 calls ClideKernel.of(context) from dispose() — illegal ancestor lookup, swallowed by catch (_) — so pane.close is never sent and the backend PTY + daemon pane leak on every closed terminal pane. The same idiom leaks the settings listener in every disposed ClaudePane (lib/builtin/claude/src/claude_pane.dart:460-466).
|
||||
|
||||
Fix: cache the kernel ref in didChangeDependencies, delete the catch-alls. Combined with the PTY natural-exit fd leak this is a two-stage leak pipeline. Tests: closing a terminal pane sends pane.close; disposing a ClaudePane removes its settings listener.', NULL, '2026-06-11 21:56:09', '2026-06-11 21:56:09', '2026-06-11 21:56:09', NULL, '92693bb428217e84d75d240b5da07bec', 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 ('06FBHBWE2W1226T58CX37E50HC', 'description', NULL, 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
|
||||
|
||||
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', NULL, '2026-06-11 21:56:19', '2026-06-11 21:56:19', '2026-06-11 21:56:19', NULL, '25954a8b7b5f77985dff44c8e78f5ee9', 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 ('06FB0TNQM5W6VN98RQM6S22X28', 'description', 'The bottom status-bar context slot for the Claude pane (model · permission-mode · context, T-145/T-150) frequently renders empty and doesn''t update. The slot is fed by the active pane''s status widget via the focus service (PaneContextStatusItem -> ClidePane.statusWidget in claude_pane.dart), sourced from StreamJsonSession.statusStream (model/permissionMode from the ''system/init'' event; cost/contextWindow from ''result'' events in stream_json_session.dart).
|
||||
|
||||
Repro: open clide; the status line is often blank and stays blank until/unless a turn completes (or never populates).
|
||||
|
||||
Likely suspects to investigate:
|
||||
- status only published while the pane is the focused contribution (active==true) — if focus isn''t on the Claude pane, the slot clears.
|
||||
- statusStream may not emit until the first ''result''/''init'' event; a resumed session (--resume) may not re-emit init, so model/mode never arrive.
|
||||
- _statusWidget returns null when _status.isEmpty AND no skills, so an unstarted/!init session shows nothing.
|
||||
- focus-slot wiring (FocusTracker) may not re-publish on pane (re)build / session rebind.
|
||||
|
||||
Acceptance: the Claude status line shows model · mode · context promptly after a session starts/resumes and stays current across turns and focus changes; add a test covering the resumed-session (no fresh init) case.', 'The bottom status-bar context slot for the Claude pane (model · permission-mode · context, T-145/T-150) frequently renders empty and doesn''t update. The slot is fed by the active pane''s status widget via the focus service (PaneContextStatusItem -> ClidePane.statusWidget in claude_pane.dart), sourced from StreamJsonSession.statusStream (model/permissionMode from the ''system/init'' event; cost/contextWindow from ''result'' events in stream_json_session.dart).
|
||||
|
||||
Repro: open clide; the status line is often blank and stays blank until/unless a turn completes (or never populates).
|
||||
|
||||
Likely suspects to investigate:
|
||||
- status only published while the pane is the focused contribution (active==true) — if focus isn''t on the Claude pane, the slot clears.
|
||||
- statusStream may not emit until the first ''result''/''init'' event; a resumed session (--resume) may not re-emit init, so model/mode never arrive.
|
||||
- _statusWidget returns null when _status.isEmpty AND no skills, so an unstarted/!init session shows nothing.
|
||||
- focus-slot wiring (FocusTracker) may not re-publish on pane (re)build / session rebind.
|
||||
|
||||
Acceptance: the Claude status line shows model · mode · context promptly after a session starts/resumes and stays current across turns and focus changes; add a test covering the resumed-session (no fresh init) case.
|
||||
|
||||
Root cause found and verified by the 2026-06-11 Fable review (fable-ous.md, epic T-359): statusStream is a plain broadcast controller — the system/init event fires while spawn() is still awaiting a 256KB transcript-tail read, before the pane ever subscribes (lib/builtin/claude/src/claude_pane.dart:322, lib/builtin/claude/src/session_orchestrator.dart:222-225). Fix: seed from session.status on bind, or make the stream replay-latest (see the ValueStream systemic ticket under T-359).', NULL, '2026-06-11 21:56:28', '2026-06-11 21:56:28', '2026-06-11 21:56:28', NULL, '8bddda6f828eeb567dfaed51eef82762', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'description', NULL, 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
|
||||
|
||||
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', NULL, '2026-06-11 21:56:40', '2026-06-11 21:56:40', '2026-06-11 21:56:40', NULL, 'e519966a59d3a52cdc28d598967010ce', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'description', NULL, 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
|
||||
|
||||
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
|
||||
|
||||
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', NULL, '2026-06-11 21:56:59', '2026-06-11 21:56:59', '2026-06-11 21:56:59', NULL, 'acbde4172213adbf84599ca1575e2bb6', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'description', NULL, 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish.
|
||||
|
||||
Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state.
|
||||
|
||||
Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', NULL, '2026-06-11 21:57:12', '2026-06-11 21:57:12', '2026-06-11 21:57:12', NULL, '7ffe4c0c1c435020f5b0ef564088b997', 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 ('06FBHC46SEHH8NQY481VMGK66R', 'description', NULL, 'The three a11y gate tests hand-enumerate their subjects and have measurably drifted from lib/: the contrast gate checks fewer themes than lib/main.dart:443-454 actually loads (catppuccin is silently unvalidated — the same theme list also drifted between main.dart and the testmode harness), and the i18n gate checks 4 of 8 namespaces. The gates stay green while covering less — worst kind of drift.
|
||||
|
||||
Fix (pattern: hand-enumerated lists drift; export the truth): make lib/ export one canonical const each for bundled themes, a11y gate subjects, and i18n namespaces; the gates and the testmode harness iterate those exports instead of their own lists. Add a meta-assertion where feasible (e.g. namespace list derived from the assets dir at test time) so a new theme/namespace cannot ship unvalidated.
|
||||
|
||||
Acceptance: gates fail if a newly added theme/namespace is not covered; catppuccin contrast-checked; all 8 i18n namespaces checked; testmode harness consumes the same exported theme list.', NULL, '2026-06-11 21:57:26', '2026-06-11 21:57:26', '2026-06-11 21:57:26', NULL, 'c8af63caa7c66890bf983ca0745ba220', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'description', NULL, 'lib/src/ipc/server.dart:151-180 — D-72 promises serial dispatch, but the async onData handler never pauses the subscription, so pipelined requests interleave; the shared StringBuffer framing can also drop or double lines when chunks split mid-frame, and per-chunk utf8 decode corrupts multi-byte characters split across chunks.
|
||||
|
||||
Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once.
|
||||
|
||||
Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', NULL, '2026-06-11 21:57:40', '2026-06-11 21:57:40', '2026-06-11 21:57:40', NULL, '4463b6a7be217a811ec7d0a34f90d40e', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'description', NULL, 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
|
||||
|
||||
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
|
||||
|
||||
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', NULL, '2026-06-11 21:57:52', '2026-06-11 21:57:52', '2026-06-11 21:57:52', NULL, 'e3a6ef892b21e588590ce513c7e4e40d', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'description', NULL, 'lib/builtin/claude/src/session_orchestrator.dart:191-249 — spawn() does check-then-act on the sessions map across two awaits (transcript-tail read, process start). Two concurrent spawns for the same session id both pass the check; the loser''s live claude process is orphaned, never killed, never observed.
|
||||
|
||||
Fix: hold a Map<String, Future<ManagedSession>> — first caller installs the future synchronously, later callers await the same future; remove the entry on failure.
|
||||
|
||||
Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', NULL, '2026-06-11 21:58:03', '2026-06-11 21:58:03', '2026-06-11 21:58:03', NULL, '8dc37c08e1f62b6b8c3cdf80e32eb4be', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'description', NULL, 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id.
|
||||
|
||||
Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session.
|
||||
|
||||
Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', NULL, '2026-06-11 21:58:16', '2026-06-11 21:58:16', '2026-06-11 21:58:16', NULL, 'ce8c670160f5180e3eb35263c28d4f78', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'description', NULL, 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
|
||||
|
||||
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
|
||||
|
||||
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', NULL, '2026-06-11 21:58:33', '2026-06-11 21:58:33', '2026-06-11 21:58:33', NULL, '1f0f584e4c35a60c69b6ffdbe41caad9', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'description', NULL, 'lib/kernel/src/extensions_manager.dart:133-192 — a throw mid-contribution leaves earlier contributions mounted while the extension records as failed; a retry then double-applies them. Also: disabling an extension ignores extensions that depend on it, and contribution registries silently clobber on id collision. Benign among curated builtins; hazardous the day Tier-6 Lua extensions (T-8) land.
|
||||
|
||||
Fix: make activation transactional — collect contributions, mount only after the extension activates cleanly, and unwind mounted ones on failure; disable refuses (or cascades, pick one and record it) when dependents are active; registries reject or namespace duplicate ids with a logged diagnostic.
|
||||
|
||||
Acceptance: test that an extension throwing mid-activation leaves zero contributions mounted and can retry cleanly; disable-with-dependents behaves per the chosen rule; duplicate contribution id surfaces an error instead of clobbering.', NULL, '2026-06-11 21:58:49', '2026-06-11 21:58:49', '2026-06-11 21:58:49', NULL, 'fef851b7cad9f7a3307e04de1168792a', 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 ('06FBHCG84F4SPFW111CC4K26A8', 'description', NULL, 'The owned terminal fork fixes what it trips over but has no vttest-style conformance suite. Verified debt:
|
||||
|
||||
- HTS (set tab stop) is a no-op: calls isSetAt instead of setAt (lib/src/terminal/src/terminal.dart:423).
|
||||
- DECCKM (cursor-keys application mode) is tracked but never consumed when encoding arrow-key input.
|
||||
- Legacy X10/X11 mouse reporting rows are off-by-one AND the existing test enshrines the bug — fix both together.
|
||||
- CPR (cursor position report) replies 0-based where every real terminal is 1-based.
|
||||
- Scrollback is maintained but structurally unreachable: ViewportOffset.zero() is pinned on every build (lib/src/terminal/src/terminal_view.dart:224), so no scrolling UI path exists. (Blocks the semantic-terminal feature idea; fix is a prerequisite there.)
|
||||
|
||||
Approach: fix each item with a focused conformance test (vttest-style expectations); consider starting a small conformance_test.dart suite that future escape-sequence work extends. Coordinate with T-123 (parser split) and T-369 (SGR crash) which touch the same area.
|
||||
|
||||
Acceptance: each of the five items has a test that fails on the old behavior and passes after; mouse test corrected, not deleted.', NULL, '2026-06-11 21:59:05', '2026-06-11 21:59:05', '2026-06-11 21:59:05', NULL, 'c47893702c6e206fb78da5e04c8ffdbe', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'description', NULL, 'lib/widgets/src/clide_markdown.dart:408-410 — hard line breaks and image nodes both fall through to empty text spans: words on either side of a hard break glue together, and images vanish entirely (no placeholder, no alt text).
|
||||
|
||||
Fix: emit a newline span for hard breaks; render images as at least an alt-text placeholder chip (full image rendering can be a follow-up — note the existing feedback that live-pane images go through clide image show).
|
||||
|
||||
Acceptance: golden/widget test for hard-break line splitting; image node renders alt text; no regression in existing markdown goldens.', NULL, '2026-06-11 21:59:20', '2026-06-11 21:59:20', '2026-06-11 21:59:20', NULL, 'a839cd055a09b586f3693fabed212aa4', 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 ('06FBHCM1RBAF72SCZBKRTXJSYC', 'description', NULL, 'Three compounding costs on every streamed token:
|
||||
|
||||
1. The whole markdown document re-parses inside build on each streaming delta, including sync existsSync() calls in the parse path (clide_markdown.dart) — sync I/O on the UI isolate per frame.
|
||||
2. The conversation view re-derives its full item list O(n) on every controller notification.
|
||||
3. Token streaming re-encodes the full reply text per delta (lib/builtin/claude/src/stream_json_session.dart:410-431) — O(n²) churn over a long reply.
|
||||
|
||||
Fix directions: cache parsed markdown per card keyed by content hash and only re-parse the tail/dirty card; move file-existence link checks off the build path (async + cache); make the session accumulate deltas in a StringBuffer instead of string concat re-encode; derive conversation items incrementally. Profile before/after with a long synthetic reply.
|
||||
|
||||
Acceptance: a 1000-delta synthetic stream does no existsSync in build (assert via injected fs seam or profiling harness), and per-delta work is bounded (no full-document re-parse); existing rendering tests green.', NULL, '2026-06-11 21:59:37', '2026-06-11 21:59:37', '2026-06-11 21:59:37', NULL, '2ad44f254878d377bb9b54512a5e4947', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'description', NULL, 'lib/builtin/terminal/src/terminal_pane.dart:69 — the shell spawns with Directory.current as cwd. Launched from a desktop entry, that is $HOME, not the open workspace; after a project switch it is whatever the process started in. SpawnSpec.cwd already exists in the PTY layer.
|
||||
|
||||
Fix: pass the active workspace root as the spawn cwd (and re-derive it on project switch for new panes).
|
||||
|
||||
Acceptance: test that a terminal pane''s SpawnSpec.cwd equals the workspace root, not Directory.current.', NULL, '2026-06-11 21:59:50', '2026-06-11 21:59:50', '2026-06-11 21:59:50', NULL, '87e1c5e8220dd5fa5eb9d7b44335ed6a', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'description', NULL, 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
|
||||
|
||||
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
|
||||
|
||||
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.', NULL, '2026-06-11 22:00:05', '2026-06-11 22:00:05', '2026-06-11 22:00:05', NULL, 'b97d86a5de4f9b7435f678fb44bcb25b', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'description', NULL, 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
|
||||
|
||||
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
|
||||
|
||||
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.', NULL, '2026-06-11 22:00:22', '2026-06-11 22:00:22', '2026-06-11 22:00:22', NULL, 'e968c74a8637487134343841ae96ddfa', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'description', NULL, 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
|
||||
|
||||
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
|
||||
|
||||
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.', NULL, '2026-06-11 22:00:37', '2026-06-11 22:00:37', '2026-06-11 22:00:37', NULL, '89d8eb48c066c985f1fb44270b0a95da', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'description', NULL, 'Verified-dead code worth one sweep (coverage denominator benefits too):
|
||||
|
||||
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
|
||||
- ToolCheck — zero callers.
|
||||
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
|
||||
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
|
||||
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
|
||||
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
|
||||
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
|
||||
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
|
||||
|
||||
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.', NULL, '2026-06-11 22:01:02', '2026-06-11 22:01:02', '2026-06-11 22:01:02', NULL, 'eda1bd3f4f7db6bfe206a1c0c39e8dae', 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 ('06FBHD098CV2N73823KX4Z99P4', 'description', NULL, 'Broadcast streams that carry STATE (not events) drop the current value for late subscribers. This one shape caused T-274 (status bar blank — root cause appended there), the meta sidebar''s manual compensation, and the prompt-stream''s initialData workaround.
|
||||
|
||||
Fix: write one small ValueStream<T> wrapper (a broadcast stream that replays the latest value to each new subscriber, plus a .value getter) in the kernel; retrofit statusStream, busyStream, and pendingPromptStream in the claude builtin; delete the per-site workarounds it obsoletes. No third-party dep (rxdart) — prefer-zero-deps; the wrapper is ~30 LOC.
|
||||
|
||||
Acceptance: unit tests for the wrapper (late subscriber gets latest value; no value yet = no synthetic emit unless seeded); T-274 repro covered: subscribing after the init event still yields the status. Closing this should make T-274 fixable in one line at the call site.', NULL, '2026-06-11 22:01:18', '2026-06-11 22:01:18', '2026-06-11 22:01:18', NULL, '8d7c4939861881d0de4c984be2df9b7c', 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 ('06FBHD2FWTDFYA00W4QXTE41M0', 'description', NULL, 'The silent-swallow idiom turned an illegal-lookup-in-dispose into two resource leaks (T-366) and turned process-spawn failures into blank panes. Rule to apply: cleanup/teardown paths MAY swallow (with a comment saying why); spawn, read, and dispose-adjacent paths MUST log through the kernel Logger they already have access to.
|
||||
|
||||
Work: grep the tree for `catch (_)` and empty catch bodies; classify each site (cleanup vs load-bearing); add logging or rethrow on the load-bearing ones; leave a one-line justification comment on the legitimate swallows. Consider an analysis_options lint (avoid_catches_without_on_clauses or a custom assist) only if it can be enabled without suppressions — per repo policy no lint suppressions without approval.
|
||||
|
||||
Acceptance: zero unjustified empty catches on spawn/read/dispose paths; each remaining swallow carries a why-comment; any new logging visible in the kernel log during testmode.', NULL, '2026-06-11 22:01:37', '2026-06-11 22:01:37', '2026-06-11 22:01:37', NULL, 'bac36bc044fb209147b1c3d13c59b1d5', 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 ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'description', NULL, 'The single-isolate app does sync I/O inside async IPC handlers: files.read does a sync 10MB read; the replace engine reads and rewrites workspace files synchronously — while grep right next to it fans out to isolates per D-79. There is no recorded rule, so each new handler guesses.
|
||||
|
||||
Work: claim a D-record (pql decisions claim D architecture "sync I/O policy in IPC handlers") deciding the rule — suggested: async File APIs by default in handlers; offload to an isolate above N KB (align N with the D-79 grep design); sync allowed only in pure-Dart test seams. Then apply it to files.read and replace_engine, citing the new D-NNN at each site.
|
||||
|
||||
Acceptance: D-record confirmed; files.read and search.replace no longer block the UI isolate on large files (test with a multi-MB fixture asserting the event loop stays responsive, e.g. a timer keeps firing).', NULL, '2026-06-11 22:01:52', '2026-06-11 22:01:52', '2026-06-11 22:01:52', NULL, 'da3256b09a0299b9a127cb80478f6af0', 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 ('06FBHD6FQMXMQQQ877KMNCK6QC', 'description', NULL, 'Per-verb path confinement is a lottery: files.read remembered, search.replace half-remembered, editor.open/save forgot (T-363). The dispatcher registry (D-74) already co-registers a schema with every handler and the schema already knows which params are paths.
|
||||
|
||||
Fix: add a path-type marker to the schema layer (or derive from a naming convention recorded in the D-record amendment) and run path_safety confinement once in the dispatcher before the handler sees the request. Per-verb checks become defense-in-depth or get deleted.
|
||||
|
||||
Do after T-363 lands its point fix — this is the structural follow-up that prevents the next forgotten verb. Amend D-74 with the confinement rule.
|
||||
|
||||
Acceptance: a registered verb with a path param automatically rejects traversal/escape without any handler code; a regression test registers a synthetic verb and proves confinement applies; existing verbs unchanged behavior for in-workspace paths.', NULL, '2026-06-11 22:02:08', '2026-06-11 22:02:08', '2026-06-11 22:02:08', NULL, '41a21f3eaedab8c7455484862d7f5247', 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 ('06FBHD8F2NBEFZNPKSJ9W253J0', 'description', NULL, 'Verified duplication clusters, each small, together the repo''s main rot vector:
|
||||
|
||||
- Welcome screen clones FileActions'' entire open-folder flow verbatim — call FileActions instead (coordinate with T-383).
|
||||
- Command palette and quick-open are ~230-line near-twins — extract the shared list-overlay+filter core.
|
||||
- Three private tail-a-growing-file implementations in the claude builtin alone — one shared follower (coordinate with T-373, which gives it the chunked decoder).
|
||||
- Five hand-rolled _userErr helpers across handlers — one shared error-shaping helper.
|
||||
- Five copy-pasted git test sandboxes, none isolating host git config (set GIT_CONFIG_GLOBAL/HOME in the shared fixture — flaky-test risk today).
|
||||
- Two parallel ANSI flag enums that already drifted: strikethrough is stored but never painted — unify, then paint strikethrough.
|
||||
|
||||
One commit per cluster. Acceptance: each cluster has a single implementation with the call sites migrated and a test guarding the shared piece; git sandboxes isolated from host config.', NULL, '2026-06-11 22:02:25', '2026-06-11 22:02:25', '2026-06-11 22:02:25', NULL, '09ac8f7ccc448560db7318cb09d2a9a3', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'description', NULL, '16 handlers in the claude builtin return result: ok with an error field in the payload instead of an error envelope, drifting from the D-6 exit-code contract every other subsystem honors. Scripted example: clide claude.agent.set-permission-mode bogus exits 0 today, so scripts cannot detect failure.
|
||||
|
||||
Fix: sweep the claude builtin handlers; on failure return the error envelope (non-zero CLI exit) like the rest of the dispatcher. Audit callers/UI that may currently rely on ok-with-error.
|
||||
|
||||
Acceptance: clide claude.agent.set-permission-mode bogus exits non-zero; a table-driven test walks the claude verbs'' failure paths asserting error envelopes; D-6 conformance restored.', NULL, '2026-06-11 22:02:40', '2026-06-11 22:02:40', '2026-06-11 22:02:40', NULL, 'c6b3edd27b9c8d00aad3eee4bd7cc227', 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 ('06FBHDC7B2MFQZVR1K9E30FXDR', 'description', NULL, 'CLAUDE.md and README still say "tmux owns Claude session persistence (D-41)" — superseded by D-75/D-77 per docs/architecture.md. README says "Pre-v2.0 (2.0.0-dev)" while pubspec is at v2.3.3, and headlines "canvas and graph surfaces" that are a 17-line stub and a flat ListView respectively (T-7 epic was cancelled).
|
||||
|
||||
Fix: rewrite the stale paragraphs in both files to match current architecture (clide-managed stream-json sessions); fix the README version line (or derive it); demote canvas/graph to roadmap wording or drop them. The repo''s honesty is its brand; the README is the one off-brand surface.
|
||||
|
||||
Acceptance: no tmux-persistence claim outside historical D-records; README version matches pubspec; every README feature claim maps to shipped behavior.', NULL, '2026-06-11 22:02:54', '2026-06-11 22:02:54', '2026-06-11 22:02:54', NULL, 'afb2a71c491410f5ca5d313d1b9c716b', 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 ('06FBHDE5A3965GNRFS5WPZW878', 'description', NULL, 'No git tag since v2.1.0 despite five CHANGELOG releases; ci/release.sh exits 64 and still references the dissolved sidecar; the pre-push fast path''s safety argument cites "release CI on tagged versions" that does not exist; and the fast-path regex skips ALL tests for pushes touching test/, ci/, or the hook itself.
|
||||
|
||||
Work items (separable commits):
|
||||
1. Back-tag v2.2.0 through v2.3.3 at the release-cut commits (find them via the CHANGELOG version-bump commits).
|
||||
2. Rewrite ci/release.sh for the single-process architecture or delete it and fold release steps into the Makefile — either way, no stub that exits 64.
|
||||
3. Add tagging to the release ritual in .claude/skills/git-commit/SKILL.md (version bump + changelog move + tag in one documented step).
|
||||
4. Widen the pre-push fast-path regex so changes under test/, ci/, and the hook itself run the full gate.
|
||||
|
||||
This is also the blocking prerequisite the T-47 (self-update) refinement identified. Acceptance: git tag lists every released version; release.sh (or its replacement) runs end-to-end; fast-path regex covered by a hook test if feasible.', NULL, '2026-06-11 22:03:10', '2026-06-11 22:03:10', '2026-06-11 22:03:10', NULL, '50c2e5c64c6a78ac9e785fcd4a0a128d', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'description', NULL, 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
|
||||
|
||||
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
|
||||
|
||||
Target layout:
|
||||
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
|
||||
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
|
||||
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
|
||||
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
|
||||
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
|
||||
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
|
||||
|
||||
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
|
||||
|
||||
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
|
||||
|
||||
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', NULL, '2026-06-11 22:09:19', '2026-06-11 22:09:19', '2026-06-11 22:09:19', NULL, '4e73e28db0c7d196d2e6fec455c87a1a', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'description', 'Verified-dead code worth one sweep (coverage denominator benefits too):
|
||||
|
||||
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
|
||||
- ToolCheck — zero callers.
|
||||
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
|
||||
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
|
||||
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
|
||||
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
|
||||
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
|
||||
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
|
||||
|
||||
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.', 'Verified-dead code worth one sweep (coverage denominator benefits too):
|
||||
|
||||
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
|
||||
- ToolCheck — zero callers.
|
||||
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
|
||||
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
|
||||
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
|
||||
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
|
||||
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
|
||||
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
|
||||
|
||||
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
|
||||
|
||||
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', NULL, '2026-06-11 22:09:30', '2026-06-11 22:09:30', '2026-06-11 22:09:30', NULL, '96c700c252728e81f7477b0f18e68192', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'description', NULL, 'lib/builtin/claude/src/claude_meta_sidebar.dart is 1192 LOC. Split plan (verified against the file 2026-06-12). Public API (ClaudeMetaSidebar widget + SidebarTab enum) stays in the root file — tests and extension.dart need zero import changes.
|
||||
|
||||
Inventory: consts _labelColumnWidth/_rowPitch (43-44), SidebarTab enum (46), ClaudeMetaSidebar (48-79), _ClaudeMetaSidebarState monolith (81-638: lifecycle, stats polling, team membership streams, primary-session binding, broker subscription, config listener, inject state, accordion state, plus the three tab bodies), _ConfigSection/_ConfigPermKind enums (641/644), _MetaSection/_MetaRow models (646-657), _AgentRosterRow(+State) (680-928), _permissionModeBadge + _PermissionModeBadge T-181 (935-1009), _IconButton (1012-1039), _InjectTextField (1043-1070), _TaskRow T-171 (1077-1141), _TabStrip (1145-1192).
|
||||
|
||||
Target layout under lib/builtin/claude/src/meta_sidebar/: models.dart (enums + _MetaSection/_MetaRow + layout consts), activity_tab.dart (ActivityTabView ~70 LOC, from _activityBody/_runtimeSection 272-302), team_tab.dart (TeamTabView ~120 LOC, from _teamBody/_taskSection 304-380, props-driven with callbacks), config_tab.dart (ConfigTabView ~200 LOC, from _configBody family 382-597; accordion _expanded state stays in parent, passed as prop+callback), roster_row.dart (_AgentRosterRow ~250 LOC incl. bypass-confirm state), permission_badge.dart (~80), task_row.dart (~70), tab_strip.dart (~55), icon_button.dart + inject_field.dart (~30 each — keep here initially; promote to lib/widgets/ only when a second consumer appears). Root file shrinks to ~150 LOC of lifecycle + event bindings + tab switch.
|
||||
|
||||
Execution order (each phase independently green): 1) primitives (icon button, inject field, permission badge, models); 2) stateless tab views (activity, config, tab strip) with prop threading; 3) team tab + roster row (most orchestrator coupling: verify show/hide, mute, inject submit-and-clear, shift-click bypass confirm, fork, close, task reassign cycle, auto-front on TeamMemberJoined at line 158); 4) cleanup of moved methods from the root state.
|
||||
|
||||
Tests: test/builtin/claude/claude_meta_sidebar_test.dart imports only the root file and public symbols — no changes needed; no goldens reference the sidebar. Caveat from the rat sweep (T-385): TeamMemberJoined currently has no emitter — coordinate before investing in the team tab plumbing.', NULL, '2026-06-11 22:09:55', '2026-06-11 22:09:55', '2026-06-11 22:09:55', NULL, '02f7234d8c395607fe5aa477e6bf291f', 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 ('06FB0TNQM4RJ9K19P5AX14RHRR', 'description', 'parser.dart is a single 1139-line file containing the full ESC/CSI/OSC/DCS handler tree for the terminal emulator. Functional but unwieldy; the consultant flagged it as ''consider splitting'' in the T-107 review.
|
||||
|
||||
Suggested split (sequenced with the T-91 coverage sweep on lib/src/terminal/, so the split doesn''t fight in-flight test work):
|
||||
|
||||
- parser.dart — entry point + state machine driver
|
||||
- esc_handlers.dart — single-char ESC dispatch table + handlers
|
||||
- csi_handlers.dart — CSI parameter parsing + handlers
|
||||
- osc_handlers.dart — OSC string handlers (title, colour set, etc.)
|
||||
- dcs_handlers.dart — DCS/SOS/PM/APC string handlers
|
||||
|
||||
Each handler module exports a registrar that the driver wires at construction.
|
||||
|
||||
Done when:
|
||||
- parser.dart < 400 LOC
|
||||
- All existing parser tests pass without changes
|
||||
- No new public surface; everything stays library-private
|
||||
|
||||
Source: T-107 / consultants.md "Code quality — Findings — [Major]".', 'parser.dart is a single 1139-line file containing the full ESC/CSI/OSC/DCS handler tree for the terminal emulator. Functional but unwieldy; the consultant flagged it as ''consider splitting'' in the T-107 review.
|
||||
|
||||
Suggested split (sequenced with the T-91 coverage sweep on lib/src/terminal/, so the split doesn''t fight in-flight test work):
|
||||
|
||||
- parser.dart — entry point + state machine driver
|
||||
- esc_handlers.dart — single-char ESC dispatch table + handlers
|
||||
- csi_handlers.dart — CSI parameter parsing + handlers
|
||||
- osc_handlers.dart — OSC string handlers (title, colour set, etc.)
|
||||
- dcs_handlers.dart — DCS/SOS/PM/APC string handlers
|
||||
|
||||
Each handler module exports a registrar that the driver wires at construction.
|
||||
|
||||
Done when:
|
||||
- parser.dart < 400 LOC
|
||||
- All existing parser tests pass without changes
|
||||
- No new public surface; everything stays library-private
|
||||
|
||||
Source: T-107 / consultants.md "Code quality — Findings — [Major]".
|
||||
|
||||
Split breakdown from the 2026-06-11 Fable review (epic T-359), verified against the file 2026-06-12:
|
||||
|
||||
Structure today: main parser + routing (11-116), CSI core _escHandleCSI (196-209) + _consumeCsi (217-272), _csiHandlers table of 27 final bytes (274-304), cursor movement handlers (306-807), erase/scroll/line/char ops (809-945), SGR monolith (411-622, 212 LOC), mode set/reset (395-409, 946-1030), DA/DSR (334-343, 624-635), window manipulation (658-712), OSC (1034-1110), _Csi state object (1113-1131 — note the commented-out `intermediates` field at 1117/1125).
|
||||
|
||||
Target layout under escape/: parser.dart keeps EscapeParser (queue, tokenization, top-level dispatch, ~400 LOC); csi_parser.dart (CsiSequence value object — prefix, params, RESTORED intermediates, finalByte — plus the consume logic from _consumeCsi); csi_handlers.dart (dispatch table, now keyed on final byte + intermediates); cursor_handlers.dart; sgr_handler.dart (the 411-622 monolith); mode_handler.dart; osc_parser.dart + osc_handlers.dart. EscapeHandler interface unchanged. Preserve the zero-allocation/reset-able design goal noted at lines 14-16.
|
||||
|
||||
Bug this split must fix (verified): _consumeCsi DISCARDS intermediate bytes — lines 258-261 have `// intermediates.add(char);` commented out and `continue`, so CSI Ps SP q (DECSCUSR, cursor style) and CSI ! p / SP-intermediate forms dispatch on the bare final byte and fall to unknownCSI. Fix: restore the intermediates field on CsiSequence, capture them during consume, dispatch on (intermediates, finalByte), and add EscapeHandler.setCursorStyle for DECSCUSR. (DECSTR is CSI ! p — soft terminal reset — same intermediate mechanism.)
|
||||
|
||||
Related bug with its own ticket (T-369): unguarded params[i+1] lookahead in SGR 38/48 at lines ~502/512/547/557 (RangeError on truncated sequences) + colon-form sub-parameters unhandled. The split makes the fix natural: sgr_handler.dart owns guarded lookahead helpers; if T-369 lands first, carry its tests over; if this lands first, fix it inside sgr_handler.dart and close T-369 with it.
|
||||
|
||||
Tests: test/terminal/escape/parser_test.dart (786 LOC) splits along the same seams — keep parser_test.dart for top-level dispatch/SBC/rollback, add csi_parser_test.dart (intermediates capture, DECSCUSR), sgr_handler_test.dart (bounds + colon form + 256/RGB), mode_handler_test.dart, osc_parser_test.dart, window/DA splits as convenient. The _RecordingHandler fixture is reusable across all of them.', NULL, '2026-06-11 22:10:19', '2026-06-11 22:10:19', '2026-06-11 22:10:19', NULL, '2942deccaff5b534ac3e33ac23b4d9fa', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:14:48', '2026-06-11 22:14:48', '2026-06-11 22:14:48', NULL, '6f607da089f1326c07f4c17a6153c014', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:18:25', '2026-06-11 22:18:25', '2026-06-11 22:18:25', NULL, '307a819cf82cf2ed49b3fd9e192992b6', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:18:56', '2026-06-11 22:18:56', '2026-06-11 22:18:56', NULL, '6b229d2b93c00f6197b026610c921273', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:24:07', '2026-06-11 22:24:07', '2026-06-11 22:24:07', NULL, '6e0986eb4b0c15223d7b373bbbc421cb', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:24:34', '2026-06-11 22:24:34', '2026-06-11 22:24:34', NULL, '151c44c4c697f83900b1cc003c6a94f6', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:26:15', '2026-06-11 22:26:15', '2026-06-11 22:26:15', NULL, '05386705854fd48ee75d7a3dfbfd5bc9', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:26:38', '2026-06-11 22:26:38', '2026-06-11 22:26:38', NULL, '6d14a673d6733b5024726bfc227f6711', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:30:24', '2026-06-11 22:30:24', '2026-06-11 22:30:24', NULL, 'aa1fe975e147be83905f24c63662254d', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:30:48', '2026-06-11 22:30:48', '2026-06-11 22:30:48', NULL, '2c5c8e5cdf68c96e25705f014dcd6acc', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:32:08', '2026-06-11 22:32:08', '2026-06-11 22:32:08', NULL, 'f3dbb31fb6f6f416a2d4d8ef37723316', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:32:29', '2026-06-11 22:32:29', '2026-06-11 22:32:29', NULL, '7b833a606da59ab523e0bb43f1753f6a', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:34:31', '2026-06-11 22:34:31', '2026-06-11 22:34:31', NULL, '254ae09f42b4da439a04d59d675d8f42', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:34:51', '2026-06-11 22:34:51', '2026-06-11 22:34:51', NULL, '14a7d3d6d6c71cae423ce12c71b6c540', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:39:04', '2026-06-11 22:39:04', '2026-06-11 22:39:04', NULL, 'ad1c483fe26d5e417b2e1cd8986114f3', 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 ('06FBHBWE2W1226T58CX37E50HC', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:39:32', '2026-06-11 22:39:32', '2026-06-11 22:39:32', NULL, '9983d6ca4c8b8b49724fb5fe42b541cd', 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 ('06FBHBWE2W1226T58CX37E50HC', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:44:05', '2026-06-11 22:44:05', '2026-06-11 22:44:05', NULL, '7846c244fccd1cd444f6715ed7472549', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:44:30', '2026-06-11 22:44:30', '2026-06-11 22:44:30', NULL, 'f20e68df446500477d6a3c7761e354c3', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:46:46', '2026-06-11 22:46:46', '2026-06-11 22:46:46', NULL, '434ffa25b18e16c5b158c61df6f40bf8', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:47:12', '2026-06-11 22:47:12', '2026-06-11 22:47:12', NULL, 'e816d6787437a31d3ec34332d069776e', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:53:45', '2026-06-11 22:53:45', '2026-06-11 22:53:45', NULL, 'efa0615f1968f8cfebd7b0e26acbdf31', 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 ('06FB0TNQM6K1QRC911JMQ9C960', 'description', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause.
|
||||
|
||||
(a) No timeout / no fallback on the init-event wait.
|
||||
On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume <id>'' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · <id>'' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide.
|
||||
|
||||
(b) Resume is decided by file existence, not resumable content.
|
||||
claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a).
|
||||
|
||||
Acceptance:
|
||||
1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery.
|
||||
2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript.
|
||||
3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count.
|
||||
4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume.
|
||||
|
||||
Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257.
|
||||
|
||||
UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause.
|
||||
|
||||
(a) No timeout / no fallback on the init-event wait.
|
||||
On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume <id>'' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · <id>'' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide.
|
||||
|
||||
(b) Resume is decided by file existence, not resumable content.
|
||||
claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a).
|
||||
|
||||
Acceptance:
|
||||
1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery.
|
||||
2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript.
|
||||
3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count.
|
||||
4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume.
|
||||
|
||||
Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257.
|
||||
|
||||
UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.
|
||||
|
||||
T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', NULL, '2026-06-11 22:53:52', '2026-06-11 22:53:52', '2026-06-11 22:53:52', NULL, '5a99913696126412ccc7b18f75d3ec15', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:54:18', '2026-06-11 22:54:18', '2026-06-11 22:54:18', NULL, '2cadeabff0a0a89bbcc06db35dfa2f15', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:57:42', '2026-06-11 22:57:42', '2026-06-11 22:57:42', NULL, 'cb060646e7e967d607dc013a75ec0a28', 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 ('06FBHD098CV2N73823KX4Z99P4', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:01:24', '2026-06-11 23:01:24', '2026-06-11 23:01:24', NULL, '33ef6141e9af3e6957def829e635b138', 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 ('06FB0TNQM5W6VN98RQM6S22X28', 'status', 'backlog', 'done', NULL, '2026-06-11 23:04:46', '2026-06-11 23:04:46', '2026-06-11 23:04:46', NULL, '5b0e12802a8f9c0b90f8b08e97f85e29', 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 ('06FBHD098CV2N73823KX4Z99P4', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:04:46', '2026-06-11 23:04:46', '2026-06-11 23:04:46', NULL, '5d8d8c7a17da4894510db957e7af3c3f', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:05:16', '2026-06-11 23:05:16', '2026-06-11 23:05:16', NULL, '252a8c28446f3c86876ec826fce03987', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:06:26', '2026-06-11 23:06:26', '2026-06-11 23:06:26', NULL, '4e1cc82f703d717f7592890e230caf00', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:07:01', '2026-06-11 23:07:01', '2026-06-11 23:07:01', NULL, '795c139edb5acd0d2a187dac6a3066c7', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:08:13', '2026-06-11 23:08:13', '2026-06-11 23:08:13', NULL, 'f83a2fc13f3839070957bea6ac7fdc5f', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:08:40', '2026-06-11 23:08:40', '2026-06-11 23:08:40', NULL, 'd61ecda5f46b3ad0e15ea566bc83a52c', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:10:56', '2026-06-11 23:10:56', '2026-06-11 23:10:56', NULL, '4b505b885dc39785651e259370fbc97b', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:11:24', '2026-06-11 23:11:24', '2026-06-11 23:11:24', NULL, '4763adf4924a8f8452c393db9ad03868', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:13:18', '2026-06-11 23:13:18', '2026-06-11 23:13:18', NULL, '5382c848b654d1a93daed15a28d652cf', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:13:49', '2026-06-11 23:13:49', '2026-06-11 23:13:49', NULL, '0edb800f29a8851306f7d21fb546d342', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:16:28', '2026-06-11 23:16:28', '2026-06-11 23:16:28', NULL, '2fe291251d1a4789c8d2b75a2c32396f', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:16:53', '2026-06-11 23:16:53', '2026-06-11 23:16:53', NULL, 'e6c0cb3a2fce6e257e054f5b3e209814', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:18:22', '2026-06-11 23:18:22', '2026-06-11 23:18:22', NULL, '22809d0fac7e1aa116e682c18dae7455', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:18:47', '2026-06-11 23:18:47', '2026-06-11 23:18:47', NULL, '2ebff93f1c7328538d0075a2a5c34cca', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:20:52', '2026-06-11 23:20:52', '2026-06-11 23:20:52', NULL, '6a025da791052ffa97803f6ce8afa2f1', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'description', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
|
||||
|
||||
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
|
||||
|
||||
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.', 'lib/kernel/src/notify.dart has zero widget consumers — anything pushed through the Notifications service (e.g. cli_install''s dogfood warnings) accumulates in a list no surface renders. ToastService exists right next to it and does render.
|
||||
|
||||
Fix options (pick one, note it on this ticket): (a) route notify-level messages through ToastService with severity styling; (b) add a notifications tray/indicator surface; (c) delete the service and migrate callers to toasts. Option (a) or (c) is likely right for current scale — avoid building a tray nobody asked for.
|
||||
|
||||
Acceptance: a notification posted by cli_install is visibly surfaced in the UI (test via whichever surface is chosen); no silent sink remains.
|
||||
|
||||
Resolved with option (a): Notifications now takes the kernel MessageBus and publishes every notification to the toast channel (severity-mapped, ''title — message''); the in-memory active list stays for API compatibility. No tray built.', NULL, '2026-06-11 23:20:59', '2026-06-11 23:20:59', '2026-06-11 23:20:59', NULL, 'a34021fbc6ce201bc9e8f5954129b7cd', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:21:26', '2026-06-11 23:21:26', '2026-06-11 23:21:26', NULL, '6aa2966edf63b21ad22355332371c219', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:24:31', '2026-06-11 23:24:31', '2026-06-11 23:24:31', NULL, '8d2499a198a90a09f0fd145c4c6ee9de', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'description', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
|
||||
|
||||
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
|
||||
|
||||
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.', 'lib/builtin/welcome/src/welcome_view.dart:173-174 — the Clone-from-git and Start-a-Claude-session tiles are inert on tap, and the keyboard shortcuts printed on the tiles are not registered anywhere. First-run users hit dead UI on the first screen.
|
||||
|
||||
Fix: either wire the tiles (clone flow; open a Claude pane) and register the shortcuts through the keymap subsystem, or remove the tiles until the flows exist — no advertised dead ends. Note: the welcome screen also duplicates FileActions'' open-folder flow verbatim; the dedup is covered by the copy-paste sweep ticket under this epic, but if you touch this file, prefer calling into FileActions.
|
||||
|
||||
Acceptance: every tile on the welcome screen performs its action (widget test taps each); every shortcut shown is registered in the keymap.
|
||||
|
||||
Resolved by removal, not stub flows: the two inert tiles are gone (each returns with its real flow — clone is honorable-mention territory in Q-49), the Open-folder shortcut glyph now matches the actual ctrl+o binding, and the tips card was corrected to six bindings that actually exist (quick open, palette, sidebar/context collapse, find-in-files, focus mode — the old card advertised four bindings that were never registered).', NULL, '2026-06-11 23:24:39', '2026-06-11 23:24:39', '2026-06-11 23:24:39', NULL, '53b1878fe9fd9c383d11bade928e811d', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:25:05', '2026-06-11 23:25:05', '2026-06-11 23:25:05', NULL, '9da97fcbea83b39c381d63cb06ea7b50', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:28:30', '2026-06-11 23:28:30', '2026-06-11 23:28:30', NULL, 'bd49ff4ff2fa7892ab5b667e46236300', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:28:58', '2026-06-11 23:28:58', '2026-06-11 23:28:58', NULL, 'baf6395b93ca8eea9fd9044debffd54d', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:31:55', '2026-06-11 23:31:55', '2026-06-11 23:31:55', NULL, '06da283bf11516ecae7fd4bd326e470a', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:32:21', '2026-06-11 23:32:21', '2026-06-11 23:32:21', NULL, '4da867943aa4bb0dea8bdc2699b64423', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:35:05', '2026-06-11 23:35:05', '2026-06-11 23:35:05', NULL, 'f1f9f574e58d9142946e8f3267a63e55', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:35:29', '2026-06-11 23:35:29', '2026-06-11 23:35:29', NULL, '6999f0d1e163b19d8a93c527359cfb28', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'description', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
|
||||
|
||||
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
|
||||
|
||||
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
|
||||
|
||||
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
|
||||
|
||||
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
|
||||
|
||||
2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', NULL, '2026-06-11 23:38:58', '2026-06-11 23:38:58', '2026-06-11 23:38:58', NULL, '5d3c1b5fcdfd2f75a20ddf8cca9cb060', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'status', 'in_progress', 'review', NULL, '2026-06-11 23:39:03', '2026-06-11 23:39:03', '2026-06-11 23:39:03', NULL, '2be4d4e7daec8409e44c8c51e10cb458', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 00:01:13', '2026-06-12 00:01:13', '2026-06-12 00:01:13', NULL, '1924fedc940094f43fc5433cd1af7df5', 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 ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'description', NULL, 'Split out of the T-385 rat sweep: nothing in production constructs TeamMemberJoined/TeamMemberLeft (verified — only the type definition and tests), yet the team roster surfaces (lib/builtin/claude/src/team_panel_host.dart and the meta sidebar Team tab) populate their member lists EXCLUSIVELY from kernel.events.on<TeamMemberJoined>() — ghost-fed UI. The real membership source exists: TeamBroker (orchestrator.broker, T-170/T-171) tracks members via addMember/removeMember on team spawn/close.
|
||||
|
||||
Fix: drive both roster surfaces from TeamBroker membership (expose a listenable roster or change stream on the broker), delete TeamMemberJoined/TeamMemberLeft from kernel events/types.dart, and remove the dead listeners. Coordinate with T-395 (meta sidebar split) — whichever lands second adapts.
|
||||
|
||||
Acceptance: spawning a team session through the orchestrator makes the member appear in both surfaces (widget test); the ghost event types are gone from types.dart; no kernel.events team-member subscriptions remain.', NULL, '2026-06-12 00:12:04', '2026-06-12 00:12:04', '2026-06-12 00:12:04', NULL, 'f04b7af4488069d004d99acbf9717cc6', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'status', 'in_progress', 'done', NULL, '2026-06-12 00:23:13', '2026-06-12 00:23:13', '2026-06-12 00:23:13', NULL, '8c817e20084503af9ab849d4974eef97', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'description', 'Verified-dead code worth one sweep (coverage denominator benefits too):
|
||||
|
||||
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
|
||||
- ToolCheck — zero callers.
|
||||
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
|
||||
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
|
||||
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
|
||||
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
|
||||
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
|
||||
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
|
||||
|
||||
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
|
||||
|
||||
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'Verified-dead code worth one sweep (coverage denominator benefits too):
|
||||
|
||||
- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests.
|
||||
- ToolCheck — zero callers.
|
||||
- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56.
|
||||
- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub).
|
||||
- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan.
|
||||
- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI).
|
||||
- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present.
|
||||
- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25.
|
||||
|
||||
Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up.
|
||||
|
||||
Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.
|
||||
|
||||
Done 2026-06-12 across six commits. Notes: ColumnHat''s file carried the LIVE hatHeight constant (app hat bar + menu bar) — moved to widgets/src/chrome_metrics.dart before deleting the dead widget. TranscriptPublisher class removed; the ClaudeConversation addressing constants stay (still consumed). The TeamMemberJoined ghost-event rewiring is real work, split out as T-396. mocktail dropped with D-25 amended (hand-rolled fakes throughout). ptyc binary untracked+deleted (no licenses.yaml entry existed). Coverage rose 95.03% → 95.13% with the dead denominator gone; full push-check green.', NULL, '2026-06-12 00:23:24', '2026-06-12 00:23:24', '2026-06-12 00:23:24', NULL, '0791573d68f344d3edab4cd8b89d2d69', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 00:23:56', '2026-06-12 00:23:56', '2026-06-12 00:23:56', NULL, 'e2cf08e3a17b8f8ad8288061d263744c', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'status', 'in_progress', 'done', NULL, '2026-06-12 00:29:57', '2026-06-12 00:29:57', '2026-06-12 00:29:57', NULL, 'f7a67bd27641e35f6cf1955fe78d41a6', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'description', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
|
||||
|
||||
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
|
||||
|
||||
Target layout:
|
||||
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
|
||||
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
|
||||
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
|
||||
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
|
||||
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
|
||||
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
|
||||
|
||||
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
|
||||
|
||||
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
|
||||
|
||||
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
|
||||
|
||||
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
|
||||
|
||||
Target layout:
|
||||
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
|
||||
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
|
||||
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
|
||||
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
|
||||
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
|
||||
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
|
||||
|
||||
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
|
||||
|
||||
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
|
||||
|
||||
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.
|
||||
|
||||
Done 2026-06-12. One deviation from the plan: the shell pieces went to lib/src/shell/ rather than lib/widgets/ + lib/builtin/hat/ — SlotHost/RootLayout know kernel + contribution types (not widget primitives), and the hat bar isn''t extension-shaped (no contributions), so neither home fit. app.dart kept the planned re-exports; zero test edits needed.', NULL, '2026-06-12 00:30:11', '2026-06-12 00:30:11', '2026-06-12 00:30:11', NULL, '871c3a1d4c3545dfa41bdafa3a39aaa8', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 00:30:17', '2026-06-12 00:30:17', '2026-06-12 00:30:17', NULL, '95cd4a80d4e61af091427c6d20bf701a', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'status', 'in_progress', 'done', NULL, '2026-06-12 00:39:04', '2026-06-12 00:39:04', '2026-06-12 00:39:04', NULL, '9354054c9dee37482c76674a331ebc26', 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 ('06FB0TNQM4RJ9K19P5AX14RHRR', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 00:39:41', '2026-06-12 00:39:41', '2026-06-12 00:39:41', NULL, 'a5439ee868e2491127308f8028d29417', 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 ('06FB0TNQM4RJ9K19P5AX14RHRR', 'status', 'in_progress', 'done', NULL, '2026-06-12 00:50:21', '2026-06-12 00:50:21', '2026-06-12 00:50:21', NULL, 'e8f7d648aeab251aa91d4b110d1d5cbc', 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 ('06FBJM6XXQZ3EMGRC13XRYVBEM', 'description', NULL, 'Follow-up to T-123. The escape parser now captures CSI intermediate bytes (0x20-0x2f) on _Csi.intermediates and routes any intermediate-bearing sequence to unknownCSI instead of mis-dispatching on the bare final byte. Nothing implements those forms yet.
|
||||
|
||||
Scope: implement DECSCUSR — `CSI Ps SP q` — cursor shape + blink:
|
||||
- 0/1 blinking block (default), 2 steady block, 3 blinking underline, 4 steady underline, 5 blinking bar, 6 steady bar.
|
||||
|
||||
Plan:
|
||||
1. lib/src/terminal/src/core/escape/handler.dart — add `void setCursorShape(<enum> shape, {required bool blink})` (or an int-style variant matching the existing surface; note resetCursorStyle() there is SGR pen state, NOT cursor shape — pick a name that cannot be confused with it).
|
||||
2. lib/src/terminal/src/core/escape/csi_handlers.dart — dispatch: in parser.dart _escHandleCSI, intermediate-bearing sequences currently all fall to unknownCSI; add a lookup keyed on (intermediates, finalByte) — a simple `if (_csi.intermediates is [0x20] && finalByte == ''q'')` check is fine until a second form exists.
|
||||
3. lib/src/terminal/src/core/terminal.dart — implement the handler method: store a cursorStyle field, notify observers.
|
||||
4. Renderer (lib/src/terminal/src/ui/) — draw underline/bar cursors; today only block is painted. This is the bulk of the work; check TerminalPainter for the cursor paint path.
|
||||
5. Tests: parser dispatch (test/terminal/escape/parser_test.dart has a _RecordingHandler fixture + an existing ''CSI intermediate bytes (T-123)'' group with a DECSCUSR placeholder test that expects unknownCSI — update it), terminal state, painter golden if shape rendering lands.
|
||||
|
||||
DECSTR (`CSI ! p`, soft reset) is a separate, smaller follow-up — same intermediates mechanism, maps to a subset of the existing reset paths; file separately if wanted.
|
||||
|
||||
Done when: claude/vim cursor-shape changes (insert vs normal mode) render as bar vs block in the terminal pane.', NULL, '2026-06-12 00:52:40', '2026-06-12 00:52:40', '2026-06-12 00:52:40', NULL, 'fd38b932757c81de62d693d72b883448', 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 ('06FB3DKQQJ583944DG8561VQ3G', 'status', 'backlog', 'done', NULL, '2026-06-12 01:04:52', '2026-06-12 01:04:52', '2026-06-12 01:04:52', NULL, '66a692e143b6a4be1b4845825e9e16ad', 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 ('06FB3DJZDDZ00BSA04B660RS7M', 'description', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
|
||||
|
||||
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
|
||||
|
||||
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
|
||||
|
||||
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.', NULL, '2026-06-12 01:05:38', '2026-06-12 01:05:38', '2026-06-12 01:05:38', NULL, '06d89d9d6456a6263a30f246ac165f0e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 01:10:56', '2026-06-12 01:10:56', '2026-06-12 01:10:56', NULL, '68dd989ecd85d7a231bf03e42b864417', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'description', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).
|
||||
|
||||
2026-06-12 progress: the model-independent slice is in — WorkspaceRef value type (lib/kernel/src/workspace_ref.dart: local/remote ctors, ssh://[user@]host[:port]/abs/path parse with rejection of host-less/path-less forms, uri/display, value equality; exported from kernel.dart) and RecentProject host/port/user (back-compatible JSON — absent keys deserialize local; isRemote; ref getter; relativePath renders host:path; copyWith preserves host identity). Tests: test/kernel/src/workspace_ref_test.dart + new RecentProject cases in project_test.dart.
|
||||
|
||||
REMAINING (gated on T-330''s footprint pick / T-336 execution layer): migrate ProjectManager._current/current off bare Directory onto WorkspaceRef (touch all .current?.path callers), open() branching on isRemote, resolveProject remote toplevel via the execution layer, swapBackend remote branch (DaemonTransport seam from T-331 is ready), connection lifecycle + preflight + events --since re-sync. The ssh://localhost loopback verify also needs a reachable sshd (none on the dev box).', NULL, '2026-06-12 01:11:07', '2026-06-12 01:11:07', '2026-06-12 01:11:07', NULL, '9818a9e40bff585344ea3308256ccd67', 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 ('06FB3DJZDDZ00BSA04B660RS7M', 'status', 'backlog', 'done', NULL, '2026-06-12 03:14:44', '2026-06-12 03:14:44', '2026-06-12 03:14:44', NULL, '862d2f0dd02ae19c1608c05478ed6fd9', 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 ('06FB3DJZDDZ00BSA04B660RS7M', 'description', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
|
||||
|
||||
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
|
||||
|
||||
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
|
||||
|
||||
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
|
||||
|
||||
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
|
||||
|
||||
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
|
||||
|
||||
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.
|
||||
|
||||
Resolved 2026-06-12: user picked the no-install ssh-exec model. Artifacts landed: D-96 (footprint + D-56 reconciliation), D-97 (ssh:// URI + system-ssh BatchMode auth, Windows deferred), D-98 (remote-tool contract + batched preflight; pql/claude degrade, shell+git required), D-99 (identity keyed on (host, repo), amends D-41/D-77); Q-23 marked Resolved. The ControlMaster latency probe was waived by the decision — latency is an accepted cost of the chosen model, to be measured during T-336 implementation against a real host. T-336 expanded into concrete tickets.', NULL, '2026-06-12 03:14:52', '2026-06-12 03:14:52', '2026-06-12 03:14:52', NULL, 'aa9bb91c7815bbfd2cfd575287693ac2', 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 ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'description', NULL, 'The foundation module of the no-install ssh-exec execution layer (D-96/D-97). New lib/src/remote/ssh_connection.dart (Flutter-free — will be exercised by dart-test core suites like lib/src/ipc):
|
||||
|
||||
- SshConnection(WorkspaceRef ref): owns one ControlMaster connection per (host, port, user). Open: `ssh -o BatchMode=yes -o ControlMaster=auto -o ControlPath=<user-scope socket dir>/%C -o ControlPersist=60 -N` (or -M + background); the ControlPath dir lives in user scope next to the D-70 socket dir, never in the repo (D-93). Surface open errors verbatim (BatchMode auth failures must reach the UI with the D-97 guidance message).
|
||||
- run(List<String> argv, {String? cwd, String? stdin}) → (exitCode, stdout, stderr): one exec channel over the master (`ssh <dest> -- cd <cwd> && exec ...` with proper shell quoting — write a quoteForShell helper, test it hard: spaces, quotes, $, globs).
|
||||
- close(): `ssh -O exit` + cleanup. isAlive via `ssh -O check`.
|
||||
- The ssh binary path is injectable (constructor param defaulting to ''ssh'') — tests use a stub executable (a shell script recording argv and replaying canned stdout/exit codes), so the full lifecycle is testable without sshd. Pattern: test/remote/ssh_connection_test.dart writes the stub into a temp dir via tester-side File I/O (dart test, no Flutter).
|
||||
- Latency: measure per-run round-trip in debug logs (D-96 accepted the cost; T-330 deferred the measurement to here).
|
||||
|
||||
Done when: connection open/run/close lifecycle green under dart test with the stub ssh; quoting helper covered for the hostile cases; BatchMode failure surfaces a typed exception with stderr attached.', NULL, '2026-06-12 03:15:15', '2026-06-12 03:15:15', '2026-06-12 03:15:15', NULL, '6dd627aa5bdf8c8c4e9b8179c55fa601', 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 ('06FBKMXSVCE98K1H76N00TYCQR', 'description', NULL, 'The sweep half of D-96: subsystems that touch the workspace (git client, pql client, files listing/IO, search engines, editor registry) gain an ExecutionContext seam instead of bare Process.run/File/Directory, so the same subsystem code serves local and ssh-exec workspaces.
|
||||
|
||||
Shape: lib/src/remote/execution_context.dart (Flutter-free) —
|
||||
- abstract ExecutionContext { Future<ProcResult> run(String exe, List<String> args, {String? cwd, String? stdinText}); plus the file primitives actually used: readFile/writeFile/stat/list/exists/delete (audit the real call surface first — grep Process.run + dart:io File/Directory under lib/src/{git,pql,files,search,editor,daemon}). }
|
||||
- LocalExecutionContext: today''s behavior verbatim (Process.run + dart:io).
|
||||
- SshExecutionContext(SshConnection) [T-398]: run → exec channel; file primitives via standard remote commands (cat/stat -c/find/test/rm; write via `cat > file` with stdin) — POSIX only per D-98.
|
||||
|
||||
Migration order (one subsystem per commit, zero behavior change proven by existing suites): git/operations.dart → pql/client.dart → files/listing.dart → search → editor/registry.dart. Constructor-inject the context defaulting to LocalExecutionContext so call sites don''t churn.
|
||||
|
||||
Watcher: the polling watcher (debounced mtime/git-status sweep emitting the same FileChange events; inotifywait opportunistic) is its own follow-up ticket once this seam exists — don''t fold it in here.
|
||||
|
||||
Done when: all five subsystems take an ExecutionContext, local default keeps every existing test green untouched, SshExecutionContext passes a stub-ssh suite for run + each file primitive.', NULL, '2026-06-12 03:15:36', '2026-06-12 03:15:36', '2026-06-12 03:15:36', NULL, 'ad670b5cb9daeb051843fb6da71f602f', 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 ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'description', NULL, 'Interactive channels of the ssh-exec model (D-96). A remote terminal/Claude pane spawns `ssh -tt [-p port] [user@]host -- cd <cwd> && exec <cmd>` LOCALLY via the existing native PTY (posix_openpt + posix_spawn, lib/src/pty/native_pty.dart) — the local PTY wraps the ssh process, the remote side gets its own pty from -tt. So NO new PTY mechanism: implement a RemotePtySpawner that builds the ssh argv from a WorkspaceRef + command and hands it to NativePty.
|
||||
|
||||
- Resize: local PTY resize propagates through ssh automatically (SIGWINCH on the local pty → ssh forwards). Verify with a resize test against the stub.
|
||||
- Exit/loss: ssh exiting (network drop) is a pane exit — the claude pane''s session-end status line (T-372 work) already renders that; terminal pane likewise.
|
||||
- Env: CLIDE_SOCK etc. (agent_bootstrap.dart) is Phase-3 scope (T-333) — out of scope here.
|
||||
- Tests: stub ssh (same harness as T-398) + the existing pty dart-test suite pattern (test/pty is serial under dart test).
|
||||
|
||||
Done when: a RemotePtySpawner produces correct argv (quoting via T-398''s helper), spawns through NativePty, resize + exit propagate, covered under dart test with the stub.', NULL, '2026-06-12 03:15:55', '2026-06-12 03:15:55', '2026-06-12 03:15:55', NULL, '29f76f72c9879c8728dfc00c1e604a8a', 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 ('06FBKN2MP35NPPK1BRDYY2M428', 'description', NULL, 'D-96''s watching degrade. Remote workspaces get FileChange events from a poller, not inotify, with the SAME event shapes the local watcher (lib/src/files/watcher.dart) emits — consumers must not know the difference.
|
||||
|
||||
- Primary mode: debounced sweep over the ExecutionContext (T-399): `git status --porcelain -z` (tracked changes, cheap on the remote) + a `find -newer <stamp>` pass for untracked/ignored-relevant paths, on a ~2s cadence, diffed against the previous snapshot to synthesize add/modify/delete events.
|
||||
- Opportunistic upgrade: if the D-98 preflight found inotifywait, hold one long-lived exec channel running `inotifywait -m -r` and translate its lines — instant events, still zero-install (inotifywait is the remote''s own tool).
|
||||
- Pause the sweep while no remote workspace is open; back off (cadence x4) when the pane is unfocused/minimized to respect the round-trip budget.
|
||||
- Tests: drive with a fake ExecutionContext replaying canned snapshots; assert synthesized event sequences (created/modified/deleted, rename = delete+create), debounce, and the inotifywait line-translation table.
|
||||
|
||||
Done when: poller emits watcher-compatible events from snapshot diffs under test, inotifywait mode translates correctly, and cadence/backoff is config-free but bounded.', NULL, '2026-06-12 03:16:12', '2026-06-12 03:16:12', '2026-06-12 03:16:12', NULL, '7e7a88fb0b5d0193bd3ac83f23b62652', 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 ('06FBKN4QVFVE51MY2N0CWCVXHM', 'description', NULL, 'Implements D-98. On remote connect (after the SshConnection opens, before ProjectOpened fires), run ONE batched probe over the exec channel:
|
||||
|
||||
for t in sh git pql claude inotifywait; do printf ''%s='' "$t"; command -v "$t" >/dev/null 2>&1 && "$t" --version 2>/dev/null | head -1 || echo MISSING; done
|
||||
|
||||
(or equivalent single round-trip). Parse into a RemoteToolset { git: version?, pql: version?, claude: version?, inotifywait: bool }.
|
||||
|
||||
- shell+git MISSING → the open fails with the probe output in the error (actionable, names the host).
|
||||
- pql MISSING → planning/query surfaces dark behind the D-95-style banner ("pql not found on <host> — install it there to enable tickets/decisions"); version skew vs the bundled local pql is surfaced (toast), not reconciled.
|
||||
- claude MISSING → Claude pane disabled with notice; everything else live.
|
||||
- inotifywait presence feeds the T-401 watcher mode pick.
|
||||
- Tests: parse table from canned probe outputs (all-present, pql-missing, git-missing, weird version strings); fail-the-open path; banner gating is a later UI ticket under Phase 5 (T-335) — this ticket is the probe + model + open-gate only.
|
||||
|
||||
Done when: probe runs as one exec round-trip, RemoteToolset drives open-failure for missing required tools, parse covered under dart test.', NULL, '2026-06-12 03:16:30', '2026-06-12 03:16:30', '2026-06-12 03:16:30', NULL, '86ad94f4dc6f7380b39c3ec96ce67d0f', 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 ('06FB3DQEMTDHF8SV27AKAB8JHW', 'description', 'PLACEHOLDER — exact tickets crystallize after the Phase-0 spike (T-330) picks the footprint model. Both designs below so the choice is a swap, not a redesign. IF auto-pushed agent: execution layer is mostly transport + provisioning — subsystems (GitClient, PqlClient, FilesService, SearchService, NativePty) run UNCHANGED inside the remote agent; buildDispatcher (main.dart ~:176) split into UI-coupled vs headless-safe registrations; new bin/clide_agent.dart hosts the headless set + an IpcServer; RemoteTransport = SSH-tunneled agent socket (stdio bridge); watching is native inotify in the agent (watcher.dart unchanged); plus a provisioning module lib/src/remote/ (push, version-name, launch, GC). IF no-install ssh-exec: introduce a RemoteExecutionContext that each subsystem uses instead of bare Process.run/File/Directory — ssh -tt for PTYs, multiplexed ControlMaster command channels for git/pql/file ops, and a polling watcher (debounced git-status/mtime, or inotifywait if present) emitting the same FileChange events so the UI is unaware; heavier subsystem surface, zero remote footprint. Either way: pql runs where its .pql/ index lives (remote), git runs where .git/ lives (remote), clide still only wraps pql (D-3 preserved). Blocks Phases 3 and 4. Depends on Phase 0 (T-330) and Phase 1 (T-331).', 'PLACEHOLDER — exact tickets crystallize after the Phase-0 spike (T-330) picks the footprint model. Both designs below so the choice is a swap, not a redesign. IF auto-pushed agent: execution layer is mostly transport + provisioning — subsystems (GitClient, PqlClient, FilesService, SearchService, NativePty) run UNCHANGED inside the remote agent; buildDispatcher (main.dart ~:176) split into UI-coupled vs headless-safe registrations; new bin/clide_agent.dart hosts the headless set + an IpcServer; RemoteTransport = SSH-tunneled agent socket (stdio bridge); watching is native inotify in the agent (watcher.dart unchanged); plus a provisioning module lib/src/remote/ (push, version-name, launch, GC). IF no-install ssh-exec: introduce a RemoteExecutionContext that each subsystem uses instead of bare Process.run/File/Directory — ssh -tt for PTYs, multiplexed ControlMaster command channels for git/pql/file ops, and a polling watcher (debounced git-status/mtime, or inotifywait if present) emitting the same FileChange events so the UI is unaware; heavier subsystem surface, zero remote footprint. Either way: pql runs where its .pql/ index lives (remote), git runs where .git/ lives (remote), clide still only wraps pql (D-3 preserved). Blocks Phases 3 and 4. Depends on Phase 0 (T-330) and Phase 1 (T-331).
|
||||
|
||||
2026-06-12: footprint decided — no-install ssh-exec (D-96, user pick). This story is now the EXECUTION-LAYER UMBRELLA for that model; the agent-model branch in the description above is dead. Expanded into: T-398 (SSH connection manager — ControlMaster + exec channel, the foundation), T-399 (ExecutionContext seam sweep across git/pql/files/search/editor), T-400 (remote PTY via ssh -tt through the existing NativePty), T-401 (polling watcher), T-402 (D-98 preflight probe). Blocker graph: 399/400/402 by 398; 401 by 399. Close this story when those five are done; T-333/T-334 unblock then.', NULL, '2026-06-12 03:16:59', '2026-06-12 03:16:59', '2026-06-12 03:16:59', NULL, 'ee6546888baa7789804fc49c278a6e60', 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', NULL, '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.', NULL, '2026-06-12 03:21:06', '2026-06-12 03:21:06', '2026-06-12 03:21:06', NULL, 'bf4b66250410bce443e0114635bb2401', 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', NULL, '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.', NULL, '2026-06-12 03:21:26', '2026-06-12 03:21:26', '2026-06-12 03:21:26', NULL, '263c482620fb5598ea49e292dc7d573a', 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', NULL, '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.', NULL, '2026-06-12 03:21:43', '2026-06-12 03:21:43', '2026-06-12 03:21:43', NULL, '73451f38a7c81a487fa3d539e9d7daa5', 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', NULL, '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.', NULL, '2026-06-12 03:22:05', '2026-06-12 03:22:05', '2026-06-12 03:22:05', NULL, '78c20e5b2c1549926cecc559a95426ff', 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', NULL, '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.', 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;
|
||||
|
||||
@@ -178,3 +178,57 @@ 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'T-353', '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, '53374633101d04f94981baaf4f2e0315', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'T-354', '2026-06-11 13:36:51', '2026-06-11 13:36:51', NULL, '32bb5d359401599022a8771031d8a08a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'T-355', '2026-06-11 13:36:55', '2026-06-11 13:36:55', NULL, '2eb6809e958613a924b35e080ab17609', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSM0PRGYR61R0NWYAT9VDC', 'T-356', '2026-06-11 13:37:00', '2026-06-11 13:37:00', NULL, 'bdb597080218a3e8783f6c5cf74c529a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'T-357', '2026-06-11 13:37:19', '2026-06-11 13:37:19', NULL, '96cf88e036dc3a45487cdbffff26cdde', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'T-358', '2026-06-11 13:37:25', '2026-06-11 13:37:25', NULL, '2a656aaec54bf50c34c7e28347b1fb29', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBGHNEQTAEPGNJKN42C1E8', 'T-359', '2026-06-11 21:54:36', '2026-06-11 21:54:36', NULL, '1930fad7b18c79cf97d913d8372b77bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'T-360', '2026-06-11 21:54:49', '2026-06-11 21:54:49', NULL, '1ffef3c00cdca28566ab67a11ec17e53', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'T-361', '2026-06-11 21:55:01', '2026-06-11 21:55:01', NULL, '5f8bdede98496496bf031a40a09dee16', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'T-362', '2026-06-11 21:55:13', '2026-06-11 21:55:13', NULL, '610e74eb7b4ff9db3949ed01a0268380', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'T-363', '2026-06-11 21:55:26', '2026-06-11 21:55:26', NULL, '5daf7bcd9ccdc37f8aed531ef12d395f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'T-364', '2026-06-11 21:55:38', '2026-06-11 21:55:38', NULL, '025de46ba9e8d005fd8b1f74687cd8b6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'T-365', '2026-06-11 21:55:49', '2026-06-11 21:55:49', NULL, 'b366b41e4737a2761a01284fd7dd44e0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'T-366', '2026-06-11 21:56:01', '2026-06-11 21:56:01', NULL, '8a75dc7dea83a0e643d1912bda46dd57', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'T-367', '2026-06-11 21:56:13', '2026-06-11 21:56:13', NULL, '578b51eafd19044dc0e2720f6e55d633', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'T-368', '2026-06-11 21:56:33', '2026-06-11 21:56:33', NULL, '0df409c83fe28af2d49a156118ed6ece', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'T-369', '2026-06-11 21:56:47', '2026-06-11 21:56:47', NULL, '7f47ddc3e84f9fea915200992a5a0baf', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'T-370', '2026-06-11 21:57:04', '2026-06-11 21:57:04', NULL, '1908b7c0903c389ad5b743fb89ca32e0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC46SEHH8NQY481VMGK66R', 'T-371', '2026-06-11 21:57:17', '2026-06-11 21:57:17', NULL, '2095f10159561a5e81b2a9b990c79fa2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'T-372', '2026-06-11 21:57:31', '2026-06-11 21:57:31', NULL, '9d22f370e726af56d47d6c8acd93bc87', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'T-373', '2026-06-11 21:57:44', '2026-06-11 21:57:44', NULL, '231c399183a83e569c0645e1d149625f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'T-374', '2026-06-11 21:57:56', '2026-06-11 21:57:56', NULL, '9832ac0b3e0bebd85f3271a5ea96d4ed', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'T-375', '2026-06-11 21:58:08', '2026-06-11 21:58:08', NULL, 'de3569d098b5c4cf6a3d49ee3d33d1c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'T-376', '2026-06-11 21:58:22', '2026-06-11 21:58:22', NULL, 'fe23c94d8971d21963a2b2e2739e05a3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCEC25337J2AXXQNST56Y4', 'T-377', '2026-06-11 21:58:40', '2026-06-11 21:58:40', NULL, '1585df7f3826ddbcef8a030ffd1e0890', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCG84F4SPFW111CC4K26A8', 'T-378', '2026-06-11 21:58:55', '2026-06-11 21:58:55', NULL, 'c6b0d9d124401f1d2bdc403567cbfdf8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'T-379', '2026-06-11 21:59:13', '2026-06-11 21:59:13', NULL, '0d9aed402c9840ef6fb75edfcfcbc3f4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCM1RBAF72SCZBKRTXJSYC', 'T-380', '2026-06-11 21:59:26', '2026-06-11 21:59:26', NULL, '1a0d767f30c9bc0e9c96f39d468ca67b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'T-381', '2026-06-11 21:59:42', '2026-06-11 21:59:42', NULL, 'cfac236e079164f9588d936f5101c71c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'T-382', '2026-06-11 21:59:55', '2026-06-11 21:59:55', NULL, 'b54be44ec4cfbbc649324471a5e2141f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCST6CQ449VJGAP6C5KZ5W', 'T-383', '2026-06-11 22:00:14', '2026-06-11 22:00:14', NULL, '0e18de4e94e4fcf36fc40de763522cbd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'T-384', '2026-06-11 22:00:29', '2026-06-11 22:00:29', NULL, '57d7b6a6c05791acadd1cebe611c8991', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'T-385', '2026-06-11 22:00:44', '2026-06-11 22:00:44', NULL, 'ebda2dd9c7dfe92bab2260dc34212ac4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD098CV2N73823KX4Z99P4', 'T-386', '2026-06-11 22:01:07', '2026-06-11 22:01:07', NULL, 'de90501f2d4be80231651d25d04f4649', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD2FWTDFYA00W4QXTE41M0', 'T-387', '2026-06-11 22:01:25', '2026-06-11 22:01:25', NULL, '8a247364a872c223b37682c6496d3920', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'T-388', '2026-06-11 22:01:43', '2026-06-11 22:01:43', NULL, 'cc238ee850d4d0ed90d05b9a42c8d506', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD6FQMXMQQQ877KMNCK6QC', 'T-389', '2026-06-11 22:01:57', '2026-06-11 22:01:57', NULL, 'bb0657e304f4ad1445d756ef7170c62d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD8F2NBEFZNPKSJ9W253J0', 'T-390', '2026-06-11 22:02:14', '2026-06-11 22:02:14', NULL, '35453f0d8c7e0fb89c243481f870de03', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'T-391', '2026-06-11 22:02:31', '2026-06-11 22:02:31', NULL, '0647245b4da95532bb3abd6f20cd87de', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDC7B2MFQZVR1K9E30FXDR', 'T-392', '2026-06-11 22:02:44', '2026-06-11 22:02:44', NULL, 'a1612ff75df3d134b8e05c716d24ebfe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDE5A3965GNRFS5WPZW878', 'T-393', '2026-06-11 22:03:00', '2026-06-11 22:03:00', NULL, 'f8f29fb8b31cc8afdd8af989c3f711f1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'T-394', '2026-06-11 22:03:21', '2026-06-11 22:03:21', NULL, 'f9cee5dc96cedfabc3eaaf7352e732c8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'T-395', '2026-06-11 22:03:26', '2026-06-11 22:03:26', NULL, '382a5742e32da0f38c1e143715a0b656', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'T-396', '2026-06-12 00:11:50', '2026-06-12 00:11:50', NULL, '20577481e56f82bb0df9e56a266303c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJM6XXQZ3EMGRC13XRYVBEM', 'T-397', '2026-06-12 00:52:25', '2026-06-12 00:52:25', NULL, 'bf89e25b384be60faa65e9eb1ec2fab9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'T-398', '2026-06-12 03:15:00', '2026-06-12 03:15:00', NULL, 'd0ed46ee279c148f1d76683e86e0d4aa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', 'T-399', '2026-06-12 03:15:21', '2026-06-12 03:15:21', NULL, 'eacf3522aeb2bccbaf006c9703b9794d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'T-400', '2026-06-12 03:15:41', '2026-06-12 03:15:41', NULL, 'a7971f428145d04ae82d4cd89f3eeb9d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN2MP35NPPK1BRDYY2M428', 'T-401', '2026-06-12 03:16:00', '2026-06-12 03:16:00', NULL, '29980034db5fe381473b156ece7d8a1a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN4QVFVE51MY2N0CWCVXHM', 'T-402', '2026-06-12 03:16:18', '2026-06-12 03:16:18', NULL, 'c65717bf20ea6886ef7a86f5cb4b2929', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'T-403', '2026-06-12 03:20:52', '2026-06-12 03:20:52', NULL, '372a686b214820b5d093b11b9f3d57a5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'T-404', '2026-06-12 03:21:11', '2026-06-12 03:21:11', NULL, 'c479a9dea6687b553bc638a9849cc5de', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'T-405', '2026-06-12 03:21:31', '2026-06-12 03:21:31', NULL, 'e4e1695f838b8fbf02aae49a6f2df4fe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'T-406', '2026-06-12 03:21:49', '2026-06-12 03:21:49', NULL, '689352238d2050a3769b2a9613f0a793', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'T-407', '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '129d2b3c31022d53025a3e28169a060e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+163
@@ -16,6 +16,169 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.4.0] — 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Live tail inside expanded Bash activity cards.** A Bash card that follows a
|
||||
file (`tail -f …`) now shows a live, scrolling read-only tail of that file
|
||||
below the result — connected only while the card is expanded. Commands with no
|
||||
followable file show a muted "nothing to follow" note. (T-325)
|
||||
|
||||
- **Double-tap-modifier shortcuts (e.g. double-Shift "Search Everywhere").**
|
||||
The keymap can now bind a bare modifier and a double-tap sequence
|
||||
(`shift shift`). All four presets (default, vim, vscode, jetbrains) map
|
||||
double-Shift to the quick-open finder — JetBrains' "Search Everywhere"
|
||||
gesture, aliased to clide's existing fuzzy file finder. (T-341)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Each spawned subagent gets its own collapsing activity card.** A fan-out of
|
||||
N agents no longer merges into one "Activity / N steps" cluster — each spawn is
|
||||
its own card with its prompt and nested run. Non-agent tool calls still group
|
||||
as before. (T-342)
|
||||
|
||||
### Removed
|
||||
|
||||
- **Dead-code sweep.** The legacy free-function git API (with its latent
|
||||
pipe deadlock), ToolCheck, the fd-passing-era libc bindings, the GraphView
|
||||
placeholder, the superseded ColumnHat widget, the tmux-era
|
||||
TranscriptPublisher, the committed `ptyc` binary, and the unused
|
||||
`mocktail` dev-dependency (D-25 amended) are gone. (T-385)
|
||||
|
||||
- **Dead welcome-screen tiles.** "Clone from git…" and "Start a Claude
|
||||
session" did nothing on tap and advertised shortcuts that were never
|
||||
registered; the tips card now lists only shortcuts that exist in the
|
||||
default keymap. Each tile returns when its flow ships. (T-383)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Terminal CSI sequences with intermediate bytes no longer mis-dispatch.**
|
||||
The parser dropped intermediates, so e.g. VT420 scroll-left (`CSI 5 SP @`)
|
||||
executed as "insert 5 blank characters"; such sequences are now reported
|
||||
as unknown instead. (T-123)
|
||||
|
||||
- **PTY master fd no longer leaks when a child exits on its own.** Every
|
||||
terminal or Claude pane whose process ended naturally left its pty device
|
||||
open for the life of the app; natural exit now releases the fd. (T-360)
|
||||
|
||||
- **Closing a terminal pane now closes its shell.** Pane disposal looked up
|
||||
the kernel illegally and swallowed the failure, so `pane.close` was never
|
||||
sent — the backend PTY and daemon pane leaked on every closed terminal
|
||||
pane, and Claude panes leaked a settings listener the same way. (T-366)
|
||||
|
||||
- **Scrolling up during a streaming reply no longer fights the auto-scroll.**
|
||||
The conversation followed the tail on every streamed token regardless of
|
||||
scroll position, dragging a reader back to the bottom; it now follows only
|
||||
while already pinned there. (T-368)
|
||||
|
||||
- **The terminal no longer crashes on truncated SGR color sequences.**
|
||||
`ESC[38m` and friends threw a RangeError inside the emulator; incomplete
|
||||
38/48 sequences are now ignored, and colon-form truecolor/256-color
|
||||
sub-parameters (`38:2:r:g:b`, ITU T.416) parse like the semicolon form
|
||||
instead of being mangled. (T-369)
|
||||
|
||||
- **File listing flags symlinks again and the workspace walk no longer
|
||||
follows them.** Symlink detection was dead code, so `files.walk` (and the
|
||||
search engine on top of it) silently descended symlinked directories —
|
||||
including ones pointing outside the workspace. (T-365)
|
||||
|
||||
- **Search-and-replace now honors its include/exclude globs.** The filters
|
||||
were accepted but never applied, so replace could rewrite files outside
|
||||
the scope the user typed; replace now uses the same glob filtering as
|
||||
search. (T-364)
|
||||
|
||||
- **Switching projects releases the previous workspace's services.** The old
|
||||
file watcher, pane PTYs, in-flight searches, and editor buffers were left
|
||||
alive on every project switch, with stale watcher events leaking into the
|
||||
new workspace. (T-367)
|
||||
|
||||
- **Expanded activity cards are readable by screen readers again.** The
|
||||
collapser's summarized button semantics excluded the whole card, so
|
||||
expanding a run announced nothing inside it; the exclusion is now scoped
|
||||
to the header and the inner cards stay in the a11y tree. (T-370)
|
||||
|
||||
- **A crashed Claude process no longer looks like it's still thinking.**
|
||||
stderr is now drained continuously (an undrained pipe could block the
|
||||
child mid-turn) and the exit code is watched: when the process dies the
|
||||
pane stops spinning, clears any unanswerable permission prompt, reports
|
||||
the exit in the status line, and logs the stderr tail. (T-361)
|
||||
|
||||
- **The Claude status bar populates reliably after a session starts.** The
|
||||
session's init event often fired before the pane subscribed and the plain
|
||||
broadcast stream dropped it, leaving the model/mode/context line blank;
|
||||
session state streams now replay their latest value to late subscribers.
|
||||
(T-274, T-386)
|
||||
|
||||
- **New terminal panes open in the project root.** The shell spawned in the
|
||||
app process's working directory — `$HOME` for desktop launches, and the
|
||||
wrong repo after a project switch. (T-381)
|
||||
|
||||
- **Two simultaneous spawns of the same Claude session no longer leak a
|
||||
process.** Concurrent spawn calls for one pane id both passed the registry
|
||||
check and the loser's live process was orphaned; spawns for an id are now
|
||||
coalesced onto one in-flight future. (T-374)
|
||||
|
||||
- **`/clear` in a fork pane clears instead of re-forking.** The fork source
|
||||
took precedence on every respawn, so clearing a fork tab silently branched
|
||||
the original conversation again; the source now seeds only the first
|
||||
bind. (T-375)
|
||||
|
||||
- **Pipelined IPC requests are now truly serial and framing-safe.** The
|
||||
server's read handler could interleave concurrent requests (against
|
||||
D-72's contract), drop or double frames split across reads, and corrupt
|
||||
multi-byte characters split across chunks. (T-372)
|
||||
|
||||
- **Settings survive nested structures, crashes, and corruption.** Maps
|
||||
inside lists (the keymap overlay shape) were corrupted on save; writes
|
||||
are now atomic (temp file + rename), and a file that fails to parse is
|
||||
preserved as `.broken` with a logged warning instead of being silently
|
||||
reset. (T-376)
|
||||
|
||||
- **Markdown hard breaks break lines and images leave a visible trace.**
|
||||
Both rendered as empty text — words on either side of a hard break glued
|
||||
together and images vanished; breaks now emit a newline and images render
|
||||
an italic `[image: alt]` placeholder. (T-379)
|
||||
|
||||
- **Extension notifications actually appear on screen.** Messages pushed
|
||||
through the kernel Notifications service (e.g. the CLI-install dogfood
|
||||
warnings) accumulated in a list no surface rendered; they now raise
|
||||
toasts with matching severity. (T-382)
|
||||
|
||||
- **Failed `clide claude.*` commands now exit non-zero.** Sixteen handlers
|
||||
reported success with an error message buried in the payload, so scripts
|
||||
could not detect failures like an unknown permission mode; they now
|
||||
return proper error envelopes per the D-6 contract. (T-391)
|
||||
|
||||
- **Terminal output no longer garbles multi-byte characters split across
|
||||
reads.** PTY output and live-tail bytes were decoded per chunk, turning a
|
||||
rune split across reads into replacement-character noise; the terminal
|
||||
now ingests bytes through a persistent decoder. (T-373)
|
||||
|
||||
- **Extension lifecycle is transactional.** A throw mid-activation now
|
||||
unwinds every contribution it had mounted (a retry no longer
|
||||
double-applies), deactivating an extension is refused while active
|
||||
extensions depend on it, and duplicate contribution/command ids are
|
||||
rejected instead of silently clobbering. (T-377)
|
||||
|
||||
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
|
||||
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
|
||||
session out of plan mode, but clide's tracked permission mode didn't follow,
|
||||
so the mode indicator and composer stayed stuck on "plan". The approval now
|
||||
syncs the tracked mode to `default`. (T-337)
|
||||
|
||||
### Security
|
||||
|
||||
- **The MCP HTTP server now requires a per-start auth token.** The localhost
|
||||
SSE port served the entire clide command surface unauthenticated,
|
||||
bypassing the unix socket's 0600 gate; requests must now present the
|
||||
token published in the 0600 `/ide` lock file. (T-362)
|
||||
|
||||
- **`editor.open` / `editor.save` are now workspace-confined.** Both verbs
|
||||
accepted absolute paths and `..` traversal verbatim — an unconfined read
|
||||
and write primitive over IPC. They now pass the same path-safety guard as
|
||||
`files.read`, including a symlink re-check at save time. (T-363)
|
||||
|
||||
## [2.3.3] — 2026-06-11
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -58,8 +58,10 @@ bindings:
|
||||
# only claims ctrl+p for selectPrevious `when: palette.open`, so this
|
||||
# is conflict-free. Arrow/enter/escape inside the overlay are handled
|
||||
# locally by the widget.
|
||||
# `shift shift` (double-tap) is the JetBrains "Search Everywhere" gesture;
|
||||
# clide aliases it to the quick-open finder across all presets (T-341).
|
||||
- intent: quickOpen.open
|
||||
keys: [ctrl+p, meta+p]
|
||||
keys: [ctrl+p, meta+p, shift shift]
|
||||
when: "!palette.open"
|
||||
# Nav stays on arrows/ctrl+n (not ctrl+p — that's the open chord and
|
||||
# would collide while the overlay is up).
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
# IntelliJ's editor-scoped contexts have no producer yet, so global chords
|
||||
# stay ungated (they're global in IntelliJ too).
|
||||
#
|
||||
# "Search Everywhere" (double-Shift) maps to clide's quick-open finder via
|
||||
# the `shift shift` double-tap gesture (T-341) — see the quick-open binding.
|
||||
#
|
||||
# Not bound — no clide command analogue (kept out of scope per the ticket):
|
||||
# Run (Shift+F10), Debug (Shift+F9), Rename/Refactor (Shift+F6), Settings
|
||||
# (Ctrl+Alt+S). And "Search Everywhere" (double-Shift) is not expressible by
|
||||
# the current chord matcher (bare/double modifiers unsupported) — tracked by
|
||||
# T-341; Go to File / Find Action below are the practical stand-ins.
|
||||
# (Ctrl+Alt+S).
|
||||
|
||||
name: jetbrains
|
||||
|
||||
@@ -52,8 +53,10 @@ bindings:
|
||||
# -- Quick open: Go to File / Go to Class / Recent Files --------------
|
||||
# win/linux: Ctrl+Shift+N, Ctrl+N, Ctrl+E. mac: Cmd+Shift+O, Cmd+O,
|
||||
# Cmd+E. clide has one fuzzy file finder, so all land on quick-open.
|
||||
# `shift shift` (double-tap) is IntelliJ's "Search Everywhere"; clide
|
||||
# aliases it to the quick-open finder (T-341).
|
||||
- intent: quickOpen.open
|
||||
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e]
|
||||
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e, shift shift]
|
||||
when: "!palette.open"
|
||||
- intent: quickOpen.selectNext
|
||||
keys: down
|
||||
|
||||
@@ -34,8 +34,9 @@ bindings:
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: palette.open
|
||||
# `shift shift` (double-tap) aliases to quick-open across presets (T-341).
|
||||
- intent: quickOpen.open
|
||||
keys: [ctrl+p, meta+p]
|
||||
keys: [ctrl+p, meta+p, shift shift]
|
||||
when: "!palette.open"
|
||||
- intent: quickOpen.selectNext
|
||||
keys: [down, ctrl+n]
|
||||
|
||||
@@ -51,8 +51,9 @@ bindings:
|
||||
when: palette.open
|
||||
|
||||
# -- Quick open / Go to File (Ctrl+P) ---------------------------------
|
||||
# `shift shift` (double-tap) aliases to quick-open across presets (T-341).
|
||||
- intent: quickOpen.open
|
||||
keys: [ctrl+p, meta+p]
|
||||
keys: [ctrl+p, meta+p, shift shift]
|
||||
when: "!palette.open"
|
||||
- intent: quickOpen.selectNext
|
||||
keys: [down, ctrl+n]
|
||||
|
||||
+1
-10
@@ -39,7 +39,7 @@ self:
|
||||
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||
# pubspec instead.
|
||||
version: "2.3.3"
|
||||
version: "2.4.0"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
@@ -205,15 +205,6 @@ dependencies:
|
||||
# Build-time-only dependencies — test runners, mocks, lints. Tracked
|
||||
# here for audit completeness; NOT rendered in the About screen.
|
||||
dev_dependencies:
|
||||
- name: mocktail
|
||||
kind: dart-package
|
||||
version: "1.0.5"
|
||||
homepage: https://pub.dev/packages/mocktail
|
||||
license: MIT
|
||||
purpose: >-
|
||||
Mocks at IO / IPC boundaries. ChangeNotifier facades use
|
||||
hand-rolled fakes instead of mocks (D-025).
|
||||
|
||||
- name: alchemist
|
||||
kind: dart-package
|
||||
version: "0.12.1"
|
||||
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
# fable-ous.md
|
||||
|
||||
*A fable about clide, as told by Fable.*
|
||||
|
||||
*Produced by 13 parallel subsystem reviewers reading ~58k LOC of Dart, ~100 raw
|
||||
findings put through 70 adversarial verification passes (exactly one finding was
|
||||
refuted — it had missed D-14), 5 feature-ideation lenses, and a full read of the
|
||||
pql planning vault: 94 confirmed decisions, 24 open questions, 11 rejected
|
||||
alternatives, 358 tickets. Everything below cites real file:line evidence that a
|
||||
skeptical second agent re-read and failed to knock down.*
|
||||
|
||||
---
|
||||
|
||||
## TL;DR scoreboard
|
||||
|
||||
| Dimension | Verdict |
|
||||
|---|---|
|
||||
| Build health | **Green.** `make analyze` 0 issues, 1383 tests pass, format clean, coverage 95.30% over the 95 floor |
|
||||
| Documentation discipline | **Best-in-class.** Near-every file cites its D-NNN/T-NNN; zero TODO/FIXME debt in 58k LOC |
|
||||
| Honesty | **High.** CHANGELOG claims verified against code; stubs self-describe as stubs |
|
||||
| Real bugs found | ~14 distinct high-severity, ~35 medium — mostly in process lifecycle, a11y, and terminal conformance |
|
||||
| Systemic risks | Broadcast-streams-without-replay, silent `catch (_)`, kernel lookup in `dispose()`, copy-paste drift |
|
||||
| Release process | **Stalled.** No git tag since v2.1.0 despite five CHANGELOG releases; `ci/release.sh` is a stub |
|
||||
| Killer-feature headroom | Enormous — the moats (owned agent loop, owned renderer, pql vault) are real and barely exploited |
|
||||
|
||||
The fable in one sentence: **a castle with exceptional masonry, a few unlocked
|
||||
side doors, and a dragon hoard of features in the basement nobody has spent yet.**
|
||||
|
||||
---
|
||||
|
||||
## Part I — The state of the realm (what's genuinely excellent)
|
||||
|
||||
Credit where due, because this codebase does several things better than most
|
||||
production repos:
|
||||
|
||||
- **Governance traceability is real, not ceremonial.** Nearly every non-trivial
|
||||
declaration carries its ticket/decision id. Multiple reviewers independently
|
||||
called it "the best I've seen at this scale." Code-to-decision drift is
|
||||
*auditable from the code itself* — and indeed most drift findings below were
|
||||
found exactly that way.
|
||||
- **Schema-validated IPC dispatch (D-74)** is beautifully executed: schemas
|
||||
co-registered with handlers, the MCP tool surface and `clide capabilities`
|
||||
both generated from the same registry (`lib/src/daemon/dispatcher.dart:90-137`)
|
||||
so three surfaces cannot drift apart.
|
||||
- **The keymap subsystem (D-82)** — layered precedence, headless
|
||||
`SequenceMatcher`, clock-injected `ModifierTapTracker` — is exemplary,
|
||||
near-fully test-mirrored design.
|
||||
- **`ClideTappable`, the anchored-overlay/menu family (D-88), reduced-motion
|
||||
honoring in every animated primitive** — the owned widget layer is coherent
|
||||
and disciplined.
|
||||
- **Battle scars are encoded where they happened.** `pumpAsync` documents why
|
||||
`pumpAndSettle` wedges; `KernelFixture` encodes the T-280 teardown-hang fix;
|
||||
flake postmortems live as comments in the test that almost shipped them.
|
||||
- **Security instincts**: 0600 socket + live-listener probe before unlink, git
|
||||
argv hardening with `--` terminators, `path_safety.dart` documenting its
|
||||
threat models inline, workspace-relative binary resolution forbidden (T-98).
|
||||
- **Zero TODO/FIXME/HACK** across the tree. The clean-board policy is lived.
|
||||
|
||||
Patterns worth *extending* (the reviewers kept wishing other code did this):
|
||||
the coalesced-notify `Timer(Duration.zero)` trick in `ConversationController`,
|
||||
the pure-Dart-core/thin-Flutter-shell split, and `RecordingEventSink`-style
|
||||
event-driven test waits.
|
||||
|
||||
---
|
||||
|
||||
## Part II — The bestiary (bugs I would fix, in order)
|
||||
|
||||
### 🐉 Dragons (high severity, verified, fix this week)
|
||||
|
||||
1. **Every naturally-exited PTY leaks its master fd — forever.**
|
||||
`lib/src/pty/native_pty.dart:444-450` — on child EOF, `_reap()` sets
|
||||
`_dead = true` but never closes `_fd`; a later `close()` short-circuits at
|
||||
`if (_dead) return;` (line 460) so `_nativeClose(_fd)` (line 477) never runs.
|
||||
Two reviewers found this independently. Every terminal/Claude pane whose
|
||||
child exits on its own leaks an fd and a pty device for the life of the app.
|
||||
|
||||
2. **The Claude child process is observed only via stdout.** Three reviewers
|
||||
converged here. `lib/builtin/claude/src/stream_json_session.dart:43-78` —
|
||||
stderr is *never drained* (≥64KB of `--verbose` spew = pipe fills = child
|
||||
blocks mid-turn = the flagship pane wedges with zero diagnostics), and
|
||||
nothing watches `exitCode` or `onDone` (line 303), so a crashed/dead
|
||||
session just looks… thoughtful. Drain stderr into a ring buffer, surface a
|
||||
terminal `SessionEnded` state. This also intersects T-283 (no resume
|
||||
timeout/fallback).
|
||||
|
||||
3. **The MCP HTTP server exposes the entire dispatcher with zero auth.**
|
||||
`lib/src/ipc/mcp_server.dart:138-195`, started unconditionally at boot
|
||||
(`lib/main.dart:174-180`). D-71's threat model ("another user on the same
|
||||
host should not drive my IDE") is enforced with 0600 on the unix socket —
|
||||
and then bypassed wholesale by an unauthenticated localhost HTTP port that,
|
||||
since D-86, serves *every* clide verb as a tool. Generate a token in the
|
||||
lock file (Claude Code's own `/ide` lock format has a slot for it), require
|
||||
the header.
|
||||
|
||||
4. **`editor.open`/`editor.save` skip path confinement entirely.**
|
||||
`lib/src/editor/registry.dart:215-219` returns absolute paths verbatim, no
|
||||
`..` normalization, no `path_safety` call — an unconfined read *and write*
|
||||
primitive over IPC while `files.read` is carefully guarded. Same family:
|
||||
**`search.replace` silently ignores its include/exclude globs**
|
||||
(`lib/src/search/replace_engine.dart:124-143`) and will happily rewrite
|
||||
files outside the filter the user typed; and **`listDir`'s symlink
|
||||
detection is dead code** (`lib/src/files/listing.dart:46-54` — `stat()`
|
||||
follows links, so `isSymlink` is always false) which means `walkFiles`
|
||||
descends symlinked dirs the docs claim it skips.
|
||||
|
||||
5. **Closing a terminal pane never closes the shell.**
|
||||
`lib/builtin/terminal/src/terminal_pane.dart:131-137` calls
|
||||
`ClideKernel.of(context)` from `dispose()` — illegal ancestor lookup,
|
||||
swallowed by `catch (_)` — so `pane.close` is never sent and the backend
|
||||
PTY + daemon pane leak. The same idiom leaks the settings listener in every
|
||||
disposed `ClaudePane` (`claude_pane.dart:460-466`). Combined with dragon #1
|
||||
this is a two-stage leak pipeline. Cache the kernel ref in
|
||||
`didChangeDependencies`, delete the catch-all.
|
||||
|
||||
6. **Project switch leaks the entire previous workspace.**
|
||||
`lib/main.dart:335-344` — a new dispatcher gets fresh `PaneRegistry`,
|
||||
`FilesService`, `EditorRegistry`, etc., but nothing calls the old set's
|
||||
`shutdown()` methods (which exist and have zero callers). Old watchers keep
|
||||
emitting into the new workspace's bus.
|
||||
|
||||
7. **T-274's root cause, found and verified:** the status bar is empty because
|
||||
`statusStream` is a plain broadcast controller — the `system/init` event
|
||||
fires while `spawn()` is still awaiting a 256KB transcript-tail read, before
|
||||
the pane ever subscribes (`claude_pane.dart:322`,
|
||||
`session_orchestrator.dart:222-225`). Seed from `session.status` on bind, or
|
||||
make it replay-latest. (The broadcast-without-replay shape is a recurring
|
||||
bug factory — see Part III.)
|
||||
|
||||
8. **Auto-scroll yanks a scrolled-up reader to the bottom on every streamed
|
||||
token.** `conversation_view.dart:268-277` — the `_atBottom` pin exists but
|
||||
is only consulted on viewport *resize*, not on new items. Anyone reading
|
||||
earlier output during a long streaming reply is dragged to the bottom
|
||||
continuously. One-line gate + the missing twin test.
|
||||
|
||||
9. **The terminal can crash on garbled output.** SGR 38/48 extended-color
|
||||
parsing does unguarded `params[i + 1]` lookahead
|
||||
(`lib/src/terminal/src/core/escape/parser.dart:501-516`) — `printf '\e[38m'`
|
||||
throws RangeError inside `Terminal.write`. An emulator must never throw on
|
||||
hostile bytes. While in there: colon-form SGR sub-parameters are mangled
|
||||
into bogus params.
|
||||
|
||||
10. **A11y has drifted despite being a Tier-0 contract.** Two independent
|
||||
verified findings: `ClideCollapserCard`'s `excludeSemantics: true` wipes
|
||||
*every expanded child* from the a11y tree
|
||||
(`lib/widgets/src/clide_collapser_card.dart:92-101`) — a screen-reader user
|
||||
can expand a run and hear nothing; and the three a11y gate tests
|
||||
hand-enumerate their subjects and have measurably fallen behind `lib/`
|
||||
(contrast checks fewer themes than `main.dart:443-454` loads; i18n checks 4
|
||||
of 8 namespaces). The gates stay green while covering less. Make `lib/`
|
||||
export the canonical lists and iterate them in the gates.
|
||||
|
||||
### 🦂 Scorpions (medium — real, will sting eventually)
|
||||
|
||||
- **D-72's "serial dispatch" isn't.** `lib/src/ipc/server.dart:151-180` uses an
|
||||
`async` onData without pausing the subscription — pipelined requests
|
||||
interleave, and the shared `StringBuffer` framing can drop/double lines.
|
||||
`client.cast<List<int>>().transform(utf8.decoder).transform(LineSplitter())`
|
||||
+ `await for` fixes framing, UTF-8 split chunks, and serialization at once.
|
||||
- **Split-chunk UTF-8 corruption is endemic at byte→String seams**: the
|
||||
terminal's only ingestion API is `write(String)` (`terminal.dart:218`) so
|
||||
both consumers decode per-chunk; `FileTailFollower` starts mid-character by
|
||||
construction. Add `writeBytes()` with a persistent chunked decoder.
|
||||
- **`Orchestrator.spawn()` races itself** — check-then-act across two awaits;
|
||||
concurrent spawns for one id leak a live `claude` process
|
||||
(`session_orchestrator.dart:191-249`). Hold a `Map<String, Future<ManagedSession>>`.
|
||||
- **Fork panes misbehave on `/clear`, `/resume`, `/fork`** —
|
||||
`widget.forkSourceId` wins forever, so `/clear` *re-forks the original
|
||||
conversation* instead of clearing (`claude_pane.dart:266-281`).
|
||||
- **Settings persistence corrupts maps-inside-lists on write**
|
||||
(`settings.dart:199-219` emits `toString()`), breaking the documented keymap
|
||||
overlay across restarts; writes are non-atomic and a parse failure silently
|
||||
resets all settings.
|
||||
- **Extension activation isn't transactional** — a throw mid-contribution
|
||||
leaves contributions mounted while the extension records as failed; retry
|
||||
double-applies (`extensions_manager.dart:133-192`). Plus: disabling an
|
||||
extension ignores dependents, and registries clobber silently on id
|
||||
collision — fine among curated builtins, hazardous the day Tier-6 Lua lands.
|
||||
- **Terminal conformance debt** (the fork fixes what it trips over but has no
|
||||
vttest-style suite): HTS is a no-op (`isSetAt` instead of `setAt`,
|
||||
`terminal.dart:423`), DECCKM is tracked but never consumed, legacy mouse rows
|
||||
are off-by-one *and the test enshrines the bug*, CPR replies 0-based where
|
||||
every real terminal is 1-based, scrollback is maintained but structurally
|
||||
unreachable (`ViewportOffset.zero()` pinned every build,
|
||||
`terminal_view.dart:224`).
|
||||
- **Markdown renderer**: hard breaks and images render as empty text (words
|
||||
glue together; `clide_markdown.dart:408-410`), and the whole document
|
||||
re-parses with sync `existsSync()` calls *inside build* on every streaming
|
||||
delta — multiplied by the conversation view re-deriving everything O(n) per
|
||||
notification and token streaming re-encoding the full reply per delta
|
||||
(O(n²) churn, `stream_json_session.dart:410-431`).
|
||||
- **Terminal panes spawn in `Directory.current`**, not the open project root
|
||||
(`terminal_pane.dart:69`) — desktop launches get `$HOME` shells.
|
||||
- **The Notifications service renders nowhere** — `notify.dart` has zero widget
|
||||
consumers; cli_install's dogfood warnings vanish into an unrendered list
|
||||
while `ToastService` sits right there.
|
||||
- **Welcome screen no-ops**: "Clone from git…" and "Start a Claude session"
|
||||
advertise shortcuts that don't exist and do nothing on tap
|
||||
(`welcome_view.dart:173-174`).
|
||||
- **`make test-e2e`/`ui-dev`/`ui-smoke` are dead** — `tools/ui/*.sh` still `cd`
|
||||
into the removed `app/` directory; the staged Gitea CI workflow would fail in
|
||||
three independent ways on activation, while D-32 calls it "ready."
|
||||
|
||||
### 🐀 Rats (low, but they breed)
|
||||
|
||||
Dead code worth a one-day extermination sweep: the entire legacy free-function
|
||||
git API (~250 LOC duplicating `GitClient`, kept alive only by tests, *with its
|
||||
own latent pipe-deadlock bug*), `ToolCheck`, ~60% of `ffi/libc.dart` (fd-passing
|
||||
era), `GraphView` (unreachable placeholder), `ColumnHat` (duplicated
|
||||
line-for-line in app.dart, kept alive by a zero-coverage test), the tmux-era
|
||||
team pipeline (`TranscriptPublisher`, `TeamMemberJoined` — *nothing emits these
|
||||
events*, yet the team roster UI still listens to them exclusively, meaning team
|
||||
tiles are populated by ghosts), the dead `ptyc` binary still committed in
|
||||
`native/linux-x64/` against D-62/D-63, and `mocktail` — pinned, documented in
|
||||
D-25 as the IO-mocking strategy, and imported by exactly zero files.
|
||||
|
||||
---
|
||||
|
||||
## Part III — Patterns I would change (the systemic stuff)
|
||||
|
||||
1. **Broadcast streams that carry state need replay-latest.** This one shape
|
||||
caused T-274, the meta sidebar's manual compensation, and the
|
||||
prompt-stream's `initialData` workaround. Write a tiny `ValueStream` wrapper
|
||||
once; retrofit `statusStream`, `busyStream`, `pendingPromptStream`.
|
||||
|
||||
2. **Ban `catch (_) {}` on I/O and lifecycle paths.** The silent-swallow idiom
|
||||
turned an illegal-lookup-in-dispose into two resource leaks and turned
|
||||
process-spawn failures into blank panes. Cleanup paths may swallow; spawn,
|
||||
read, and dispose paths must log through the kernel Logger they already have.
|
||||
|
||||
3. **Sync I/O in async handlers on the single isolate.** `files.read` does a
|
||||
sync 10MB read; the replace engine reads and rewrites the workspace
|
||||
synchronously *while grep right next to it fans out to isolates per D-79*.
|
||||
Decide the rule (offload above N KB), write it into a D-record, apply it.
|
||||
|
||||
4. **Path confinement belongs at the dispatch layer, not per-verb.** files.read
|
||||
remembered, search.replace half-remembered, editor.* forgot. A confinement
|
||||
check keyed off the co-registered schema (the registry already knows which
|
||||
params are paths) ends the per-verb lottery.
|
||||
|
||||
5. **Copy-paste is the repo's main duplication tax.** The welcome screen clones
|
||||
FileActions' entire open-folder flow verbatim; palette and quick-open are
|
||||
~230-line near-twins; three private "tail a growing file" implementations in
|
||||
the claude builtin alone; five hand-rolled `_userErr` helpers; five
|
||||
copy-pasted git test sandboxes (none isolating host git config); two
|
||||
parallel ANSI flag enums that already drifted (strikethrough is stored but
|
||||
never painted). Each is small; together they're how a solo-dev repo rots.
|
||||
|
||||
6. **Hand-enumerated lists drift; export the truth.** Bundled themes (already
|
||||
drifted between `main.dart` and the testmode harness — catppuccin is
|
||||
silently unvalidated), a11y gate subjects, i18n namespaces. One exported
|
||||
const each, consumed by both sides.
|
||||
|
||||
7. **The claude builtin returns `ok` with an `error` payload in 16 handlers**,
|
||||
drifting from the D-6 exit-code contract every other subsystem honors. A
|
||||
scripted `clide claude.agent.set-permission-mode bogus` exits 0 today.
|
||||
|
||||
8. **God-files**: `app.dart` (1187 LOC, five concerns — split plan is in the
|
||||
findings), `claude_meta_sidebar.dart` (1192), `parser.dart` (1139, T-123
|
||||
already exists — and the split should also fix `_consumeCsi` discarding
|
||||
intermediate bytes, which permanently blocks DECSCUSR/DECSTR).
|
||||
|
||||
9. **Docs drift at the front door**: CLAUDE.md and README still say "tmux owns
|
||||
Claude session persistence (D-41)" — superseded by D-75/D-77 per
|
||||
`docs/architecture.md`; README says "Pre-v2.0 (2.0.0-dev)" at v2.3.3 and
|
||||
headlines "canvas and graph surfaces" that are a 17-line stub and a flat
|
||||
ListView respectively. clide's honesty is its brand; the README is the one
|
||||
place currently off-brand.
|
||||
|
||||
10. **Close the release loop.** Five CHANGELOG releases since the last git tag;
|
||||
`ci/release.sh` exits 64 and references the dissolved sidecar; the pre-push
|
||||
fast path's safety argument cites "release CI on tagged versions" that
|
||||
doesn't exist; and the fast path skips ALL tests for pushes touching
|
||||
`test/`, `ci/`, or the hook itself. Back-tag 2.2.0–2.3.3, add tagging to
|
||||
the git-commit skill ritual, widen the fast-path regex. (This is also the
|
||||
blocking prerequisite your own T-47 refinement identified for self-update.)
|
||||
|
||||
---
|
||||
|
||||
## Part IV — Killer features (the dragon hoard)
|
||||
|
||||
Five ideation lenses, 27 proposals, deduplicated and ranked. The convergence
|
||||
test mattered: **two lenses independently invented the flight recorder, and two
|
||||
independently invented the visual canvas round-trip** — when separate agents
|
||||
with different briefs land on the same feature, that's the market talking.
|
||||
|
||||
Clide's structural moats, verified against code: it *spawns and owns* the agent
|
||||
process (D-77/D-78) where competitors are sandboxed extension guests; it owns
|
||||
every pixel (terminal, markdown, canvas); everything is local-and-committed
|
||||
(transcripts, costs, decisions, tickets) where competitors' business models
|
||||
require cloud custody; and the pql vault is structured planning data no
|
||||
mainstream IDE has an analogue for.
|
||||
|
||||
### Tier 1 — do these (high leverage, mostly M-effort, plumbing exists)
|
||||
|
||||
1. **Agent Blame + Session Flight Recorder** `[L]` — gutter action on any line:
|
||||
*which session, which turn, which prompt, which permission grant, what it
|
||||
cost* — opening the native conversation at the exact `tool_use`. Timeline
|
||||
scrubber to replay a session. The transcript pipeline
|
||||
(`transcript_reader.dart`, `session_index.dart`) already parses everything
|
||||
needed. Cursor/Copilot cannot ship this: their logs live server-side by
|
||||
business design. *Two lenses converged here.*
|
||||
|
||||
2. **Context X-ray** `[M]` — per-card token attribution ("this 40KB Bash tail
|
||||
is 12% of your window") + a real compaction indicator. `stream_json_session.dart`
|
||||
already parses usage and contextWindow per event; the renderer owns the
|
||||
cards. Fixes T-244 (invisible compaction) as a side effect. Context is the
|
||||
scarcest resource in agent pairing and every tool renders it as one opaque
|
||||
percentage.
|
||||
|
||||
3. **Trust Ledger + decision-aware permission prompts** `[M]` — every
|
||||
permission rule with provenance (which prompt, which session, which ticket),
|
||||
ticket-scoped expiry; and when a `can_use_tool` request arrives, chip the
|
||||
relevant D-record onto the card (edit touching pubspec.yaml → D-31
|
||||
prefer-zero-deps, one keystroke to deny *with the decision cited*).
|
||||
Governance stops being documentation and becomes live agent policy. No
|
||||
competitor has a queryable in-repo decision system to even attempt this.
|
||||
|
||||
4. **Agent Activity HUD** `[M]` — four backlog tickets and one open question
|
||||
are secretly one feature: build T-59's `OperationsRegistry` once and feed it
|
||||
git/pql progress (T-59), sidebar badges (T-58), compaction state (T-244),
|
||||
the status strip (T-274), with Q-34's budget slot reserved. Fix the T-274
|
||||
plumbing bug first or the HUD inherits blank-slot syndrome.
|
||||
|
||||
5. **Active Ticket Context** `[S]` ← *cheapest win in the whole list* — picking
|
||||
up a ticket binds it to the session: a "working on T-244" chip, auto
|
||||
`in_progress` flip, `(T-NNN)` pre-suggested in commit messages, changelog
|
||||
reminder on done. Makes kanban ambient instead of homework, and makes every
|
||||
trail/ledger feature below reliable.
|
||||
|
||||
### Tier 2 — the differentiators (L/XL, each could headline a release)
|
||||
|
||||
6. **Twin-timeline rewind** `[L]` — snapshot the worktree as hidden git refs
|
||||
(`git write-tree` → `refs/clide/checkpoints`) at every turn boundary, keyed
|
||||
to turn uuid; every user-message card gains "restore files to before this."
|
||||
Claude's `/rewind` only restores what Claude itself edited; clide owns both
|
||||
timelines.
|
||||
|
||||
7. **Visual Dialog** `[L]` — one bidirectional scene schema: Claude draws
|
||||
(D-91 canvas cards), the user annotates in the interaction zone (T-260), and
|
||||
the annotations return as *structured geometry + flattened PNG*, not prose.
|
||||
Merge the T-317/T-318 and T-260 specs into one protocol before they become
|
||||
two dialects. *Two lenses converged here.*
|
||||
|
||||
8. **Immortal terminals** `[M]` — T-258 (terminal as editor-mode peer) fused
|
||||
with T-325 live-tails: any long process — user shell *or* agent-spawned
|
||||
build — promotes to a full tmux-backed surface that survives restart.
|
||||
"The build that never dies" is structural for clide, a plugin fantasy for
|
||||
Electron. Resolve Q-27 (swap vs split) as part of it, as T-258 already notes.
|
||||
|
||||
9. **Local cost ledger** `[M]` — per-turn cost/tokens persisted against the
|
||||
active pql ticket; the board shows what each feature actually cost.
|
||||
Flat-subscription opacity is the competitors' business model; turn-level
|
||||
local cost data is clide's birthright. (Tokens primary, dollars advisory —
|
||||
subscription auth reports notional costs.)
|
||||
|
||||
10. **Label-routed work queues / ticket dispatch** `[M→XL]` — T-277 labels +
|
||||
the shipped pick-up path turn the board into an agent control surface;
|
||||
the XL extension dispatches a ticket to a teammate session in an isolated
|
||||
git worktree (SpawnSpec.cwd already exists). Local, no-telemetry
|
||||
background agents with the work item, isolation, review surface, and audit
|
||||
trail all in-repo.
|
||||
|
||||
### Tier 3 — moonshots (XL, pick one per quarter, they compound)
|
||||
|
||||
11. **Semantic terminal** — OSC 133 markers (clide spawns the shell, injection
|
||||
is trivial) lift scrollback into foldable command regions with recognizers
|
||||
for test runners and stack traces; real widgets between rows is something
|
||||
xterm.js structurally cannot do. *Note: fix the scrollback-unreachable bug
|
||||
first — a semantic scrollback you can't scroll is a koan.*
|
||||
12. **Living codebase map** — tree-sitter imports + pql links + git churn on
|
||||
the owned canvas, with live agent heat from `tool_use` events: watch Claude
|
||||
*move through your codebase* in real time.
|
||||
13. **Live mixed documents** — fenced blocks in the owned markdown renderer
|
||||
become live embeds (canvas scenes, pql query results, decision cards,
|
||||
confirm-gated command buttons). Notebook-grade, zero webview. The
|
||||
`ClideMarkdownHooks` seam already exists.
|
||||
14. **Remote Claude over SSH** — T-329 is fully ticketed and undersold: agent
|
||||
on the buildbox, permission prompts rendering natively local. VS Code
|
||||
Remote moves the editor; nobody remotes the *agent control channel*.
|
||||
15. **Sealed-workspace mode** — an egress-audit proxy around the whole agent
|
||||
stack, operationalizing D-60/D-64 into a provable property. The one
|
||||
feature in this list competitors *cannot* copy without breaking their own
|
||||
products.
|
||||
|
||||
### Honorable mentions
|
||||
|
||||
Plan-to-Board bridge (ExitPlanMode approval files the plan as a ticket tree),
|
||||
Release Cockpit (renders `[Unreleased]` with word-count badges + one-action
|
||||
release cut — would also unstall Part III #10), Daily Helm ("since you were
|
||||
last here" pulse on the welcome screen, computed from data the repo already
|
||||
commits), Total-recall conversation search (D-79 grep over the transcript
|
||||
corpus + fork-from-here), Shared Gaze (attach editor selection/scroll context
|
||||
to each outgoing turn), Speakable Layouts (`clide layout apply review.yaml` —
|
||||
the agent stages your workspace), Agent fleet tray (enumerate per-workspace
|
||||
sockets, show every repo's agent state — *requires fixing T-247's stale-socket
|
||||
litter first*), Governance Graph (the D/Q/R/T web as a navigable map — gives
|
||||
the placeholder graph view a flagship dataset).
|
||||
|
||||
---
|
||||
|
||||
## Part V — If I were you, Monday morning
|
||||
|
||||
1. **One leak-fix commit**: PTY fd on natural exit + kernel-lookup-in-dispose
|
||||
(terminal & claude panes) + project-switch service disposal. Three findings,
|
||||
one theme, one afternoon.
|
||||
2. **One Claude-resilience commit**: drain stderr, watch exitCode, seed status
|
||||
on bind, gate auto-scroll on `_atBottom`. The flagship pane stops having
|
||||
silent failure modes.
|
||||
3. **One security commit**: MCP auth token + editor.* path confinement +
|
||||
search.replace glob filter + symlink-walk fix.
|
||||
4. **One rat-extermination day**: dead git API, ToolCheck, libc bindings,
|
||||
ColumnHat, GraphView, tmux-era team pipeline, ptyc binary, mocktail. The
|
||||
diff is gloriously red and the coverage denominator thanks you.
|
||||
5. **Tag your releases.** Five releases of honest changelog work are currently
|
||||
unaddressable commits.
|
||||
6. Then go build the **Active Ticket chip** (S!) and the **Context X-ray**, and
|
||||
let clide start showing people things no other IDE can.
|
||||
|
||||
---
|
||||
|
||||
*Findings methodology: every medium/high claim above survived an independent
|
||||
adversarial re-read of the cited lines (one claim did not — the proposed
|
||||
"can't-disable core extensions" guard, which D-14 deliberately rejects, so it
|
||||
stays out of this report). The full per-finding evidence, severities, and
|
||||
suggested fixes live in the review transcripts; ~34 additional low-severity
|
||||
findings were verified by citation only.*
|
||||
|
||||
*— Fable, 2026-06-11*
|
||||
+26
-2
@@ -66,7 +66,7 @@ You might also want, project-permitting:
|
||||
- [D-22: WCAG-AA contrast gate on bundled themes](decisions/accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes) — _accessibility_
|
||||
- [D-23: Test pyramid — seven layers](decisions/testing.md#d-23-test-pyramid--seven-layers) — _testing_
|
||||
- [D-24: Golden tests — primitives only, Alchemist + Ahem](decisions/testing.md#d-24-golden-tests--primitives-only-alchemist--ahem) — _testing_
|
||||
- [D-25: Mocks — mocktail at IO, hand-rolled fakes for ChangeNotifiers](decisions/testing.md#d-25-mocks--mocktail-at-io-hand-rolled-fakes-for-changenotifiers) — _testing_
|
||||
- [D-25: Mocks — hand-rolled fakes throughout; mocktail dropped](decisions/testing.md#d-25-mocks--hand-rolled-fakes-throughout-mocktail-dropped) — _testing_
|
||||
- [D-26: Web driver — raw Playwright + Flutter semantics](decisions/testing.md#d-26-web-driver--raw-playwright--flutter-semantics) — _testing_
|
||||
- [D-27: Startup regression gate](decisions/testing.md#d-27-startup-regression-gate) — _testing_
|
||||
- [D-28: Test organisation — mirror `lib/` in `test/`](decisions/testing.md#d-28-test-organisation--mirror-lib-in-test) — _testing_
|
||||
@@ -133,6 +133,14 @@ You might also want, project-permitting:
|
||||
- [D-89: inline pasted-image thumbnails that expand to the lightbox](decisions/design.md#d-89-inline-pasted-image-thumbnails-that-expand-to-the-lightbox) — _design_
|
||||
- [D-90: clide:// deep links — paranoid allowlist + user confirmation](decisions/architecture.md#d-90-clide-deep-links--paranoid-allowlist--user-confirmation) — _architecture_
|
||||
- [D-91: Unified conversation drawing card backed by a canvas renderer](decisions/architecture.md#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer) — _architecture_
|
||||
- [D-92: Ship pql bundled with clide](decisions/tooling.md#d-92-ship-pql-bundled-with-clide) — _tooling_
|
||||
- [D-93: clide writes no directories of its own into the workspace](decisions/architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) — _architecture_
|
||||
- [D-94: Workspace mode is a first-class, extensible declared capability](decisions/architecture.md#d-94-workspace-mode-is-a-first-class-extensible-declared-capability) — _architecture_
|
||||
- [D-95: Workspace validity and onboarding flow](decisions/architecture.md#d-95-workspace-validity-and-onboarding-flow) — _architecture_
|
||||
- [D-96: Remote-execution footprint — no-install ssh-exec](decisions/architecture.md#d-96-remote-execution-footprint--no-install-ssh-exec) — _architecture_
|
||||
- [D-97: ssh:// workspace URI + system-ssh auth](decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth) — _architecture_
|
||||
- [D-98: Remote-tool contract + connect preflight](decisions/architecture.md#d-98-remote-tool-contract--connect-preflight) — _architecture_
|
||||
- [D-99: Remote session identity keyed on (host, workspace)](decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace) — _architecture_
|
||||
|
||||
## Open questions
|
||||
|
||||
@@ -152,7 +160,6 @@ You might also want, project-permitting:
|
||||
- [Q-17: Icon set growth](questions/process.md#q-17-icon-set-growth) — _process_
|
||||
- [Q-18: Theme hot-reload in release builds](questions/process.md#q-18-theme-hot-reload-in-release-builds) — _process_
|
||||
- [Q-20: Kernel DB service — namespaced SQL access?](questions/process.md#q-20-kernel-db-service--namespaced-sql-access) — _process_
|
||||
- [Q-23: SSH-remote development — run clide against a remote workspace](questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace) — _architecture_
|
||||
- [Q-25: Body text face — mono everywhere vs Josefin Sans UI + mono code](questions/architecture.md#q-25-body-text-face--mono-everywhere-vs-josefin-sans-ui--mono-code) — _architecture_
|
||||
- [Q-26: Small screen layout (< 1000px)](questions/architecture.md#q-26-small-screen-layout--1000px) — _architecture_
|
||||
- [Q-27: Two-editor split](questions/architecture.md#q-27-two-editor-split) — _architecture_
|
||||
@@ -160,6 +167,22 @@ You might also want, project-permitting:
|
||||
- [Q-30: Focus behavior when editor is dirty and viewer is peeked](questions/architecture.md#q-30-focus-behavior-when-editor-is-dirty-and-viewer-is-peeked) — _architecture_
|
||||
- [Q-31: XWayland fallback for frameless — proper Wayland protocol needed](questions/architecture.md#q-31-xwayland-fallback-for-frameless--proper-wayland-protocol-needed) — _architecture_
|
||||
- [Q-34: How + when to surface the account/team token budget given upstream doesn't expose it](questions/architecture.md#q-34-how--when-to-surface-the-accountteam-token-budget-given-upstream-doesnt-expose-it) — _architecture_
|
||||
- [Q-35: Agent Blame + Session Flight Recorder — implement?](questions/design.md#q-35-agent-blame--session-flight-recorder--implement) — _design_
|
||||
- [Q-36: Context X-ray — implement?](questions/design.md#q-36-context-x-ray--implement) — _design_
|
||||
- [Q-37: Trust Ledger + decision-aware permission prompts — implement?](questions/design.md#q-37-trust-ledger--decision-aware-permission-prompts--implement) — _design_
|
||||
- [Q-38: Agent Activity HUD — implement?](questions/design.md#q-38-agent-activity-hud--implement) — _design_
|
||||
- [Q-39: Active Ticket Context — implement?](questions/design.md#q-39-active-ticket-context--implement) — _design_
|
||||
- [Q-40: Twin-timeline rewind — implement?](questions/design.md#q-40-twin-timeline-rewind--implement) — _design_
|
||||
- [Q-41: Visual Dialog — one bidirectional scene schema — implement?](questions/design.md#q-41-visual-dialog--one-bidirectional-scene-schema--implement) — _design_
|
||||
- [Q-42: Immortal terminals — implement?](questions/design.md#q-42-immortal-terminals--implement) — _design_
|
||||
- [Q-43: Local cost ledger — implement?](questions/design.md#q-43-local-cost-ledger--implement) — _design_
|
||||
- [Q-44: Label-routed work queues / ticket dispatch — implement?](questions/design.md#q-44-label-routed-work-queues--ticket-dispatch--implement) — _design_
|
||||
- [Q-45: Semantic terminal — implement?](questions/design.md#q-45-semantic-terminal--implement) — _design_
|
||||
- [Q-46: Living codebase map — implement?](questions/design.md#q-46-living-codebase-map--implement) — _design_
|
||||
- [Q-47: Live mixed documents — implement?](questions/design.md#q-47-live-mixed-documents--implement) — _design_
|
||||
- [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_
|
||||
- [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
|
||||
- [Q-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_
|
||||
|
||||
## Resolved questions
|
||||
|
||||
@@ -169,6 +192,7 @@ You might also want, project-permitting:
|
||||
- [Q-19: (withdrawn)](questions/process.md#q-19-withdrawn) — _process_
|
||||
- [Q-21: Pql absorbs planning vs keeps separate](questions/architecture.md#q-21-pql-absorbs-planning-vs-keeps-separate) — _architecture_
|
||||
- [Q-22: Ticket persistence strategy](questions/architecture.md#q-22-ticket-persistence-strategy) — _architecture_
|
||||
- [Q-23: SSH-remote development — run clide against a remote workspace](questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace) — _architecture_
|
||||
- [Q-28: Terminal strip scope — shell only or logs/errors/tests](questions/architecture.md#q-28-terminal-strip-scope--shell-only-or-logserrorstests) — _architecture_
|
||||
- [Q-32: MCP tool surface — minimum slash-ide or extended clide tools?](questions/architecture.md#q-32-mcp-tool-surface--minimum-slash-ide-or-extended-clide-tools) — _architecture_
|
||||
- [Q-33: MCP transport — SSE, WebSocket, stdio, or all?](questions/architecture.md#q-33-mcp-transport--sse-websocket-stdio-or-all) — _architecture_
|
||||
|
||||
@@ -23,6 +23,7 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
### D-4: Ignore file strategy
|
||||
- **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage)
|
||||
- **Amendment (2026-06-11):** Per [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), clide no longer writes a `.clide/` directory into the repo; only `.pql/` is added to `.gitignore` at install time. The `.clide/` mention below is retained for history.
|
||||
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
|
||||
- **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept.
|
||||
- **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface.
|
||||
@@ -72,7 +73,7 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
### D-10: State management — `ChangeNotifier` + `ListenableBuilder`
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Per-feature state uses `ChangeNotifier` exposed through a feature facade (singleton-per-kernel); widgets subscribe via `ListenableBuilder`. No Riverpod, Provider, BLoC, or Redux.
|
||||
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-25](testing.md#d-25-mocks--mocktail-at-io-hand-rolled-fakes-for-changenotifiers)). Violates [D-31 prefer-zero-deps](tooling.md#d-31-prefer-zero-deps-exact-pin) otherwise. See [R-8](../rejected/architecture.md#r-8-riverpod--provider--bloc-for-state).
|
||||
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-25](testing.md#d-25-mocks--hand-rolled-fakes-throughout-mocktail-dropped)). Violates [D-31 prefer-zero-deps](tooling.md#d-31-prefer-zero-deps-exact-pin) otherwise. See [R-8](../rejected/architecture.md#r-8-riverpod--provider--bloc-for-state).
|
||||
- **Cost:** No codegen ergonomics; manual `notifyListeners()` discipline. The `ListenableBuilder.listenable` contract rejects rebuilds outside the subscribed notifier — intentional.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
@@ -190,6 +191,7 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
### D-53: State persistence across sessions
|
||||
- **Date:** 2026-04-22
|
||||
- **Amendment (2026-06-11):** Per [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), this state moves from in-repo `.clide/settings.yaml` to user-scope storage keyed by workspace-path hash. The `.clide/settings.yaml` references below are retained for history.
|
||||
- **Decision:** The following layout state is persisted across app restarts: collapse state of left and right panels, active left section (tickets/decisions/files/git/pr), active right context type, pql pane expanded/collapsed, editor split ratio when open, fuzzy find recent picks. Stored via `SettingsStore` in project-scoped settings (`.clide/settings.yaml`).
|
||||
- **Rationale:** Users expect their workspace layout to survive restarts. Without persistence, every launch starts at the default layout preset, which is disorienting when the user has customised their column widths and panel states.
|
||||
- **Cost:** Adds write-on-change to several layout operations. Must handle migration if the setting keys evolve. `.clide/settings.yaml` is already gitignored, so personal layout state stays personal.
|
||||
@@ -469,4 +471,67 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- **Relationship:** Narrows [Q-4](../questions/architecture.md#q-4-canvas-schema-compatibility-with-obsidian) — clide's canvas is its own HTML-canvas-inspired JSON; Obsidian `.canvas` is an *import* format via conversion, not the native schema. Consumes the stdin/`--file` JSON input plumbing (T-315). Subsumes the standalone icon card (T-313) and image-annotation work (T-316) as templates of this card. **Merges the former Tier-5 "canvas and graph view" epic (T-7) into one canvas epic (T-317):** the Tier-5 canvas *pane* (T-322, interactive/editable — distinct from the display-only conversation card) and graph *view* (T-323) consume the same shared renderer; T-7 is cancelled as superseded. The conversation drawing card stays display-only per [D-78]; the canvas pane is a full interactive pane. (D-17 "panels are extension-shaped" is unaffected and still governs the panes.)
|
||||
- **Raised by:** 2026-06-10 — user, while refining the icon-preview card (T-313): "make it all into one drawing card that receives a json input and selects based on the context inside the json what to draw … pull the entire thing closer to a dynamic canvas than a bunch of one-off renderers." Clarified the model is HTML `<canvas>` (not Obsidian's), templates-over-primitives, per-object label/description, and reuse as the `.canvas` renderer; before/after comparisons, SVGs, icons, and graphs all become things you send into the card.
|
||||
|
||||
### D-93: clide writes no directories of its own into the workspace
|
||||
- **Date:** 2026-06-11
|
||||
- **Decision:** clide-the-IDE contributes **zero** directories to a workspace. The only tool-owned directories physically written into a repo are `.git/` (git's, brought by the user) and `.pql/` (pql's repo data — index + planning changelog). All IDE-local per-workspace state — panel collapse, active sections, split ratios, project theme, recent picks ([D-53](#d-53-state-persistence-across-sessions)), and any future per-repo extension DB — moves to **user scope**, stored outside the repo and keyed by a hash of the workspace path, the same convention the IPC socket already uses ([D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)). If clide ever needs shared, *committed* per-repo config, it lives as clide-owned keys in `.pql/config.yaml` (the existing `ignore_files:` precedent, [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)/[D-4](#d-4-ignore-file-strategy)) — never a new directory.
|
||||
- **Context:** clide previously wrote project-scoped settings to an in-repo `.clide/` directory ([D-53](#d-53-state-persistence-across-sessions)). Even gitignored, that put an IDE scratch dir physically inside the user's repo. "Written in the repo" — not "checked in" — is the thing being minimized.
|
||||
- **Rationale:** One tool dir in the repo (`.pql/`), and it earns its place because it holds data *about* the repo. Personal IDE state is not repo data, so it belongs in user scope — exactly where [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic) and [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) already keep per-workspace runtime state. Nothing shared is lost: `.clide/settings.yaml` was already gitignored, so it was never committed anyway.
|
||||
- **Cost:** A one-time migration of any existing in-repo `.clide/settings.yaml` to user scope, then dropping the dir. Per-workspace state inherits [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)'s trade-off: moving or renaming a repo re-keys it and resets personal layout.
|
||||
- **Amends [D-4](#d-4-ignore-file-strategy):** D-4's clause "`.clide/`) is added to `.gitignore` at install time" is moot — clide no longer writes `.clide/` into the repo. Only `.pql/` is added to `.gitignore` at install time.
|
||||
- **Amends [D-53](#d-53-state-persistence-across-sessions):** persisted layout state moves from in-repo `.clide/settings.yaml` to user-scope storage keyed by workspace-path hash.
|
||||
- **Cross-reference:** [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-4](#d-4-ignore-file-strategy), [D-53](#d-53-state-persistence-across-sessions), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed).
|
||||
- **Raised by:** 2026-06-11 — user: "I am not a fan of IDEs tossing in multiple dirs … only the pql dir which contains repo data gets [written] in repo."
|
||||
|
||||
### D-94: Workspace mode is a first-class, extensible declared capability
|
||||
- **Date:** 2026-06-11
|
||||
- **Decision:** A clide workspace runs in exactly one **mode** at a time, drawn from an open, extensible vocabulary — initially `edit` (full local read/write; the default) and `read` (read-only; no writable `.pql/`), with `remote`, `ssh`, and `webui` reserved as future values. Every extension declares the modes it supports in its manifest (`modes: [edit, read]`); an extension with no declaration is assumed `edit`-only. The extension host activates an extension only when the active workspace mode is in its declared set — unsupported extensions stay dormant. New modes are added as new vocabulary values **without schema changes**; the open SSH-remote question ([Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace)) is expected to resolve *into* a mode value, not a parallel mechanism.
|
||||
- **Context:** Read-mode degrade ([D-95](#d-95-workspace-validity-and-onboarding-flow)) needs to know which extensions remain functional without pql and without write access. A boolean `read_mode_safe` would answer only today's question and would not compose with the `remote`/`ssh`/`webui` modes already on the horizon.
|
||||
- **Rationale:** Modelling capability as a declared mode set is uniform and future-proof — one mechanism the host gates on, one place extensions opt in, and third-party extensions participate by declaring. Reserving the future values now means remote/ssh/webui work plugs into an existing seam instead of inventing its own.
|
||||
- **Cost:** Every builtin extension must declare its modes (a one-time classification pass); the host gains mode-gating logic; the vocabulary is open-ended and must stay coherent as values accrue. Defaulting an undeclared extension to `edit`-only is conservative but may surprise authors.
|
||||
- **Cross-reference:** [D-17](extensions.md#d-17-panels-are-extension-shaped-from-day-one), [D-95](#d-95-workspace-validity-and-onboarding-flow), [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace).
|
||||
- **Raised by:** 2026-06-11 — user: "read_mode_safe: true is not leaving space for further modes (ssh mode, remote mode, webui mode, read mode, edit mode). Prepare it for that."
|
||||
|
||||
### D-95: Workspace validity and onboarding flow
|
||||
- **Date:** 2026-06-11
|
||||
- **Decision:** A clide workspace is valid only when it is a git repo with an initialized `.pql/`. Two consequences. **(1) Git is a precondition the user owns.** clide never auto-runs `git init`; opening a non-git folder *offers* initialization (**default no**, with a guard that warns when a parent `.git` would make this a nested repo) or lets the user pick another folder. **(2) pql is clide-provisioned.** Because pql now ships bundled ([D-92](tooling.md#d-92-ship-pql-bundled-with-clide)), an uninitialized repo triggers a **required, idempotent** prep flow that reconciles state (virgin / pql-user / partially-init / fully-init / previously-declined) and **discloses exactly what it writes** — `.gitignore` entries for `.pql/`, pql's config, and (only on opt-in) git hooks. The mandatory floor is pql **config + index** (the files/query/ignore engine); the **planning layer** (decisions/tickets + the changelog hooks of [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code), which alter the user's git workflow) is a **contextual opt-in** offered when the user first opens the Decisions or Tickets surface — never forced at onboarding. A writable repo with no `.pql/` is *invalid-until-initialized*; a repo clide **cannot** write (read-only mount, no permission) degrades to **read mode** ([D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability)) — file tree, editor, and the pure-Dart content search ([D-79](#d-79-workspace-content-search-is-a-pure-dart-in-process-engine-outside-pql)) stay live; pql-backed surfaces go dark behind a clear banner. A decline is remembered in user scope, keyed by repo path; clide does not re-nag, and an explicit "initialize workspace" command is always available.
|
||||
- **Context:** [D-4](#d-4-ignore-file-strategy) already specified that `.pql/` is "added to `.gitignore` at install time" — presuming an install-time event that never had a trigger. Bundling pql ([D-92](tooling.md#d-92-ship-pql-bundled-with-clide)) is what makes "pql required" honest: clide can always provide the means to create `.pql/`. This record is that missing trigger.
|
||||
- **Rationale:** pql is clide's core query/ignore engine, not just the ticket board, so a repo without it is degraded for *core editing*, not only planning — gating on `.pql/` is truthful. Git, by contrast, is a foundational, identity-level user decision (and `git init` in the wrong place is a footgun), so clide offers but never imposes it. Splitting the mandatory config+index from the opt-in planning hooks keeps the invasive git-workflow change consensual and contextual. Read-mode degrade keeps clide usable as an editor on repos it cannot write — consistent with [D-80](#d-80-filesread-allows-trusted-claude-config-roots-beyond-the-workspace)'s read appetite — instead of refusing them outright.
|
||||
- **Cost:** An onboarding/state-reconciliation flow with a disclosing modal. The installer must handle the known hooks gotcha (`pql init` writes to `.git/hooks` and ignores an existing `core.hooksPath`) — it must not silently clobber a repo that sets `core.hooksPath`. The read-mode path gates extensions by their declared modes ([D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability)) and must provide graceful fallbacks where pql surfaces go dark.
|
||||
- **Cross-reference:** [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-4](#d-4-ignore-file-strategy), [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code), [D-79](#d-79-workspace-content-search-is-a-pure-dart-in-process-engine-outside-pql), [D-80](#d-80-filesread-allows-trusted-claude-config-roots-beyond-the-workspace), [D-92](tooling.md#d-92-ship-pql-bundled-with-clide), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability).
|
||||
- **Raised by:** 2026-06-11 — user, this planning session: a repo without `.pql/` is "invalid for clide"; non-git folder → "offer default no"; planning hooks contextual; unwritable repos degrade.
|
||||
|
||||
### D-96: Remote-execution footprint — no-install ssh-exec
|
||||
- **Date:** 2026-06-12
|
||||
- **Decision:** SSH-remote workspaces (T-329, shape A of [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace)) use **stock OpenSSH only — nothing clide-specific is ever installed on the remote.** Interactive surfaces (terminal panes, the Claude process) run over `ssh -tt` PTY channels; command-style subsystems (git, pql, file ops, search) run as exec channels multiplexed over a persistent **ControlMaster** connection; file watching degrades to **polling** (debounced mtime/git-status sweep, `inotifywait` used opportunistically when present) that emits the same FileChange events, so the UI layer is unaware of the difference. Subsystems reach the remote through a `RemoteExecutionContext` seam instead of bare `Process.run`/`File`/`Directory`. The rejected alternative — an auto-pushed self-managed remote agent (VS Code Remote model) — would have bought native inotify and a stateful remote backend at the price of deploying and version-managing clide components on the remote.
|
||||
- **Rationale:** The user's standing constraint is decisive: no clide components to install, update, GC, or version-reconcile on remote machines. Zero-footprint also dissolves the agent model's open sub-questions (placement, multi-client sharing, version skew, cleanup) — they simply don't arise. The costs (per-command round-trip, polling watcher) are bounded and amortizable (ControlMaster reuses one authenticated connection); the agent model's costs are operational and permanent.
|
||||
- **D-56 reconciliation:** [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server)'s "single process" rule is *strengthened*, not bent: with no-install there is no second clide process anywhere — the local Flutter app remains the only clide process, and the remote side is plain sshd + the tools already on the box. [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) is likewise untouched — ssh is an external tool clide shells out to, not a second core language or runtime.
|
||||
- **Cost:** Every remote command pays an SSH round-trip (ControlMaster removes handshake cost, not latency). Watching is polling-grade — change events arrive on the sweep cadence, not instantly. The execution-context seam must be threaded through each subsystem that touches the filesystem or spawns processes; that sweep is the bulk of T-336. No stateful remote backend means event streams are synthesized locally from command results.
|
||||
- **Cross-reference:** Resolves [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace). [D-97](#d-97-ssh-workspace-uri--system-ssh-auth) (naming + auth), [D-98](#d-98-remote-tool-contract--connect-preflight) (what must exist remotely), [D-99](#d-99-remote-session-identity-keyed-on-host-workspace) (identity), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability) (remote workspaces surface as a workspace mode — the reserved `ssh` value — so extensions gate on it declaratively). Implemented by the T-329 epic; execution layer is T-336.
|
||||
- **Raised by:** 2026-06-12 — user, resolving the T-330 footprint spike: "go with the no-install ssh-exec model."
|
||||
|
||||
### D-97: ssh:// workspace URI + system-ssh auth
|
||||
- **Date:** 2026-06-12
|
||||
- **Decision:** A remote workspace is named by the URI `ssh://[user@]host[:port]/abs/remote/path`. `host` may be a `~/.ssh/config` alias; user/port are optional and, when absent, resolve through ssh's own config machinery. **Auth delegates entirely to system ssh** — agent, keys, `~/.ssh/config`, ProxyJump, all of it; clide never stores credentials or implements an auth flow of its own. v1 connections run ssh in **BatchMode** (non-interactive): when auth would prompt, the connect fails with an actionable message ("set up key auth / ssh-agent for <host>") instead of clide hosting a password dialog. Windows (no standard ssh config surface) is an acknowledged v1 gap. The `WorkspaceRef` value type (T-332) is the canonical carrier — parse/round-trip of this URI, `host:path` display form, bare-path = local.
|
||||
- **Rationale:** Matches the epic's locked auth posture and pql's "wrap, don't duplicate" instinct applied to OpenSSH: the user's existing ssh config is the source of truth, and anything clide reimplements (agents, prompts, jump hosts) would be a worse, second implementation of it. BatchMode keeps the failure mode crisp instead of wedging a TTY prompt inside a GUI flow.
|
||||
- **Cost:** First-run UX depends on the user's ssh hygiene — no in-app password fallback. Host-alias resolution means the same workspace can be reachable under two names (`buildbox` vs `buildbox.lan`) and be keyed as two identities ([D-99](#d-99-remote-session-identity-keyed-on-host-workspace) keys on the *given* host string; aliasing dedupe is deliberately not attempted).
|
||||
- **Cross-reference:** [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec), [D-99](#d-99-remote-session-identity-keyed-on-host-workspace). Implemented by T-332 (WorkspaceRef landed 2026-06-12; open-flow pending T-336).
|
||||
- **Raised by:** 2026-06-12 — T-330 spike artifacts, URI shape locked at epic planning (2026-06-10).
|
||||
|
||||
### D-98: Remote-tool contract + connect preflight
|
||||
- **Date:** 2026-06-12
|
||||
- **Decision:** What must exist on the remote, and what merely degrades. **Required:** a POSIX shell and `git` — without them the workspace cannot open (workspace validity, [D-95](#d-95-workspace-validity-and-onboarding-flow), requires a git repo). **Optional, degrading:** `pql` — absent, the planning/query surfaces (tickets, decisions, vault queries) go dark behind a banner, mirroring [D-95](#d-95-workspace-validity-and-onboarding-flow)'s read-mode degrade; clide cannot provision pql remotely under [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec)'s no-install rule, so the banner tells the user what to install where. **Optional, degrading:** `claude` — absent, the Claude pane is disabled with a notice; terminal/editor/git stay fully live. On connect, a single batched preflight command probes all of these (one round-trip: `command -v` + version for each) and the result drives the degrade set; a missing *required* tool fails the open with the probe output.
|
||||
- **Rationale:** The contract keeps "remote" honest without smuggling an installer in: clide states what it found, works with what's there, and never mutates the remote toolset. One batched probe respects the per-command latency cost [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec) accepts. Folding pql-absence into the existing degrade vocabulary (D-94 modes / D-95 banner) reuses a shipped pattern instead of inventing a remote-special one.
|
||||
- **Cost:** A degraded-but-open remote workspace is a new partial state to keep coherent (which surfaces dark, which live). Version *skew* (remote pql older than the bundled local one) is real and detected by the preflight but only surfaced, not reconciled, in v1.
|
||||
- **Cross-reference:** [D-92](tooling.md#d-92-ship-pql-bundled-with-clide) (bundling is local-only under no-install), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability), [D-95](#d-95-workspace-validity-and-onboarding-flow), [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec).
|
||||
- **Raised by:** 2026-06-12 — T-330 spike artifacts ("decide the remote-tool contract: what must exist remotely, whether pql is hard-required or degrades, and how a preflight surfaces what is missing").
|
||||
|
||||
### D-99: Remote session identity keyed on (host, workspace)
|
||||
- **Date:** 2026-06-12
|
||||
- **Decision:** Workspace-keyed identity generalizes from *path* to *(host, path)* — local workspaces are `(null, path)`, so nothing changes for them. Consequences: Claude session identity ([D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)'s one-primary-per-repo, [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer)'s stream-json sessions) re-keys on (host, repo) — the same repo path on two hosts (or local + remote) is two distinct sessions, never one; Claude's `--resume` transcripts live on the host where claude runs (the remote's `~/.claude/…`), which falls out naturally because claude is spawned remotely. Per-workspace user-scope state ([D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace)/[D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)'s path-hash keying) hashes the WorkspaceRef canonical URI instead of the bare path — same generalization, same machinery. The host string is taken as given (alias ≠ FQDN; no dedupe, per [D-97](#d-97-ssh-workspace-uri--system-ssh-auth)).
|
||||
- **Rationale:** Path-only keying would silently fuse two different machines' checkouts of the same repo path into one session/layout/socket identity — wrong in every case. Hashing the canonical URI is the smallest amendment that fixes this everywhere at once, because every consumer already keys off one derived string.
|
||||
- **Amends [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) / [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer):** "per repo" reads as "per (host, repo)" throughout; local keeps its existing identity (null host hashes identically to the pre-amendment bare path — no migration).
|
||||
- **Cost:** Renaming a host alias re-keys its sessions and layout state (accepted; same trade-off D-70 already made for moved repos).
|
||||
- **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer), [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec), [D-97](#d-97-ssh-workspace-uri--system-ssh-auth). Implemented across T-332 (identity carrier) and T-333 (session re-key).
|
||||
- **Raised by:** 2026-06-12 — T-330 spike artifacts ("session identity keyed on (host, repo) amending D-41/D-77").
|
||||
|
||||
---
|
||||
|
||||
@@ -18,10 +18,10 @@ Test pyramid, drivers, client-side constraint.
|
||||
- **Cost:** Goldens have zero real text; layouts rely on widget tests. Acceptable.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-25: Mocks — mocktail at IO, hand-rolled fakes for ChangeNotifiers
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** `mocktail 1.0.4` mocks IO boundaries (sockets, processes, `dart:io` File/Directory). `ChangeNotifier` facades get hand-rolled fakes — tiny classes that extend `ChangeNotifier` with test-controlled setters. No `mocktail` for notifiers.
|
||||
- **Rationale:** Mocking a `ChangeNotifier` with a generated mock hides subscription bugs — `notifyListeners` becomes a mock call instead of actually firing. Hand-rolled fakes exercise the real subscription machinery.
|
||||
### D-25: Mocks — hand-rolled fakes throughout; mocktail dropped
|
||||
- **Date:** 2026-04-21 (amended 2026-06-12)
|
||||
- **Decision:** Test doubles are hand-rolled fakes — tiny classes that extend the real base (`ChangeNotifier` facades, `StreamJsonProcess`, `DaemonClient`) with test-controlled setters. **Amendment (2026-06-12, T-385):** `mocktail` was originally pinned for IO boundaries, but after the T-91 coverage drive it had zero imports — every IO seam ended up with an injected hand-rolled fake (`FakeDaemonClient`, fake process factories, recording event sinks) instead. The unused dep is dropped; the no-mocks-for-notifiers rule stands and in practice covers IO seams too.
|
||||
- **Rationale:** Mocking a `ChangeNotifier` with a generated mock hides subscription bugs — `notifyListeners` becomes a mock call instead of actually firing. Hand-rolled fakes exercise the real subscription machinery. The same held at IO seams: constructor-injected fakes kept tests on real control flow.
|
||||
- **Cost:** Roughly 20 lines per fake. Rounds out to less code than configuring a mocktail whenCall chain.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
|
||||
@@ -91,4 +91,13 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-61](#d-61-dependency-vetting-checklist), `POLICY.md`.
|
||||
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
|
||||
|
||||
### D-92: Ship pql bundled with clide
|
||||
- **Date:** 2026-06-11
|
||||
- **Decision:** clide ships `pql` as a vendored, version-pinned native binary — the same model used for git via dugite ([D-59](#d-59-bundled-git-via-dugite-native)). The pinned binary lives under `native/<platform>/` with a `BUILD.md` provenance record ([D-63](#d-63-vendored-binary-rebuild-process)) and an `assets/licenses.yaml` entry ([D-42](#d-42-dependencies-documented-in-licensesyaml), [D-65](#d-65-license-compatibility-matrix)). Resolution order is: `CLIDE_PQL_BIN` env override (dev escape hatch — e.g. pointing at a pql built side-by-side) → bundled binary resolved against the **install directory** (next to the executable, never workspace-relative) → system `pql` on PATH. The bundled copy never self-updates — the pin is the contract, so `pql self-update` is inert for it. A version floor is enforced *softly*: if the resolved pql (override or PATH) is older than the pinned floor, the Problems panel surfaces it rather than clide silently mis-driving an incompatible binary. CI runs the in-tree binary instead of provisioning pql on the runner.
|
||||
- **Context:** pql is clide's files/query/ignore engine **and** its planning engine ([D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)), yet it was an unmanaged external dependency: users installed and updated it themselves, with no version pin. Beyond the "update the binary, then install it" friction, an old PATH pql replaying the changelog ([D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code)) is a latent *corruption* risk, not merely a missing-feature one. This is a distribution gap, not an architecture one.
|
||||
- **Rationale:** Bundling makes a fresh clone/install work with zero separate pql setup, pins the version clide was tested against (closing the changelog-schema-skew risk), and reuses the proven dugite pattern and its supply-chain gates ([D-60](#d-60-no-network-on-default-launch-path)/[D-61](#d-61-dependency-vetting-checklist)/[D-63](#d-63-vendored-binary-rebuild-process)). It is **additive, not a fork**: pql stays a standalone tool, clide still wraps and never reimplements ([D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)), and pql's universality for terminal/VS Code users is untouched. pql being a pure-Go, no-CGo static binary makes per-platform bundling cheap.
|
||||
- **Cost:** A pinned binary per shipped platform (linux-x64 now; macOS arm64/x64 when those builds land), each carried through the [D-63](#d-63-vendored-binary-rebuild-process) rebuild ritual on every pql release — the same bump cadence as dugite and tree-sitter. Resolving the bundled binary against the install dir and **never** a workspace-relative path is mandatory: a repo could otherwise plant `native/pql` and gain code execution (the T-98 dugite lesson).
|
||||
- **Cross-reference:** [D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-59](#d-59-bundled-git-via-dugite-native), [D-60](#d-60-no-network-on-default-launch-path), [D-61](#d-61-dependency-vetting-checklist), [D-63](#d-63-vendored-binary-rebuild-process), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-65](#d-65-license-compatibility-matrix), [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code).
|
||||
- **Raised by:** 2026-06-11 — user: "pql updates live outside this repo and people have to first update the pql binaries and install them."
|
||||
|
||||
---
|
||||
|
||||
@@ -60,9 +60,15 @@ ticket persistence.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-23: SSH-remote development — run clide against a remote workspace
|
||||
- **Status:** Open
|
||||
- **Status:** Resolved → [D-96](../decisions/architecture.md#d-96-remote-execution-footprint--no-install-ssh-exec), [D-97](../decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth), [D-98](../decisions/architecture.md#d-98-remote-tool-contract--connect-preflight), [D-99](../decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace)
|
||||
- **Resolved (2026-06-12):** Shape (A) — execution remote, UI local — with the **no-install ssh-exec** footprint (user pick): stock OpenSSH only, `ssh -tt` PTYs + ControlMaster exec channels, polling watcher, zero clide components on the remote (D-96). Naming/auth via `ssh://` URI + system ssh in BatchMode (D-97); remote-tool contract with batched connect preflight (D-98); session + state identity re-keyed on (host, repo), amending D-41/D-77 (D-99). Implementation: T-329 epic, execution layer T-336.
|
||||
- **Question:** Clide today assumes the workspace, the daemon, and the Flutter UI all run on the same machine. A growing class of users edits on remote systems (build servers, GPU boxes, cloud dev environments). What's the architecture for "open repo on host-B from UI on host-A"? Two shapes: (A) daemon-on-remote — clide's Dart daemon runs on the remote; the app talks to it over an SSH-tunnelled unix socket or a dedicated TCP socket (mTLS?), pty/process/filesystem work stays server-side; local app is pure UI. (B) filesystem-mounted — remote mounted via sshfs/9p/rclone, daemon runs locally against the mount; simpler but every fs op + git call crosses the network, and PTYs get complicated (local shell on remote filesystem? ssh-exec per command?). (A) matches VS Code Remote / JetBrains Gateway; (B) matches nothing load-bearing. Sub-questions either way: auth (ssh-agent? per-project keys? OIDC?), tmux / Claude session persistence semantics (does primary-per-repo re-key on host + repo?), multi-host identity in `.pql/pql.db`, latency tolerance for the event stream, re-sync on disconnect.
|
||||
- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-5` and `D-6`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind.
|
||||
- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-5` and `D-6`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind. The 2026-06-11 Fable review (fable-ous.md Part IV, Tier 3 #14) reframed the differentiator and ranked T-329 as undersold: the agent runs on the buildbox while permission prompts render natively local — VS Code Remote moves the *editor*; nobody remotes the *agent control channel*. That framing favours shape (A).
|
||||
- **Triage (2026-06-12):** Shape (A) is effectively settled (T-329 epic locked: execution remote, UI + clipboard local, system ssh auth for v1). The model-independent backbone is proceeding: Phase 1 (T-331, `DaemonTransport` seam) landed. What remains open is the **footprint model** — the user decision T-330 gates on:
|
||||
1. **No-install ssh-exec** — zero remote footprint; stock `ssh -tt` PTYs + ControlMaster command channels; watching degrades to polling; heavier subsystem surface locally.
|
||||
2. **Auto-pushed self-managed agent** (VS Code Remote model) — clide deploys/version-checks a headless agent binary on connect; native inotify + stateful backend; but installs clide components on the remote, which the user has said they don't want to manage.
|
||||
|
||||
If (2), the agent sub-questions need answers before T-336 expands: placement (per-host `~/.clide/agent` likely), GC on disconnect/version-bump/repo-removal, multi-client sharing (IpcServer is already multi-connection, D-72), version skew (version-named binaries coexist). Either way the **remote-tool contract** needs a D-record: what must exist remotely (git/shell at minimum), whether pql is hard-required or degrades, and how a connect-preflight surfaces gaps. Evidence gap: the ControlMaster per-command latency probe (T-330) needs a reachable sshd — none in the dev environment; run it against a real remote before deciding if latency is the deciding factor.
|
||||
- **Source:** 2026-04-22 planning (user-raised).
|
||||
|
||||
### Q-22: Ticket persistence strategy
|
||||
@@ -153,4 +159,10 @@ ticket persistence.
|
||||
- **Context:** The only unshipped piece of the otherwise-complete native-Claude epic (T-132). Blocked on data availability, not on clide work — hence a question (when/how to revisit) rather than active scope. Option (c) interacts with D-75's "version-pinned coupling to CC internals" posture. Resolved by T-158 when a viable path lands.
|
||||
- **Source:** 2026-06-09 — split out of T-132 / T-158 (was "blocked on upstream"); project memory `claude-usage-budget-not-exposed`, GitHub anthropics/claude-code#44328.
|
||||
|
||||
### Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?
|
||||
- **Status:** Open
|
||||
- **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish.
|
||||
- **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked.
|
||||
- **Source:** T-384 / 2026-06-11 Fable review (epic T-359).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Open Questions — Design
|
||||
|
||||
Feature proposals from the 2026-06-11 Fable review (fable-ous.md Part IV,
|
||||
epic [T-359]). Each asks the same question — are we going to implement this
|
||||
feature? — so the answer can resolve into a D-record (and an initiative
|
||||
ticket) or an R-record. Effort tags `[S/M/L/XL]` come from the review.
|
||||
|
||||
---
|
||||
|
||||
### Q-35: Agent Blame + Session Flight Recorder — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to implement agent blame — a gutter action on any line answering *which session, which turn, which prompt, which permission grant, what it cost*, opening the native conversation at the exact `tool_use` — plus a timeline scrubber to replay a session? `[L]`
|
||||
- **Context:** Two ideation lenses independently invented this. The transcript pipeline (`transcript_reader.dart`, `session_index.dart`) already parses everything needed. Cursor/Copilot cannot ship it: their logs live server-side by business design — local-and-committed transcripts are a structural moat.
|
||||
- **Source:** fable-ous.md Part IV, Tier 1 #1 (2026-06-11 Fable review).
|
||||
|
||||
### Q-36: Context X-ray — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to implement per-card token attribution ("this 40KB Bash tail is 12% of your window") plus a real compaction indicator? `[M]`
|
||||
- **Context:** `stream_json_session.dart` already parses usage and contextWindow per event; the renderer owns the cards. Would fix T-244 (invisible compaction) as a side effect. Context is the scarcest resource in agent pairing and every tool renders it as one opaque percentage.
|
||||
- **Source:** fable-ous.md Part IV, Tier 1 #2 (2026-06-11 Fable review).
|
||||
|
||||
### Q-37: Trust Ledger + decision-aware permission prompts — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to implement a permission-rule ledger with provenance (which prompt, which session, which ticket; ticket-scoped expiry), and decision-aware prompts that chip the relevant D-record onto a `can_use_tool` card (edit touching pubspec.yaml → [D-31](../decisions/tooling.md#d-31-prefer-zero-deps-exact-pin), one keystroke to deny *with the decision cited*)? `[M]`
|
||||
- **Context:** Governance stops being documentation and becomes live agent policy. No competitor has a queryable in-repo decision system to attempt this. Builds on the D-78 interaction-zone prompt surface.
|
||||
- **Source:** fable-ous.md Part IV, Tier 1 #3 (2026-06-11 Fable review).
|
||||
|
||||
### Q-38: Agent Activity HUD — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to build the `OperationsRegistry` (T-59) once and feed it git/pql progress (T-59), sidebar badges (T-58), compaction state (T-244), and the status strip (T-274), with [Q-34](architecture.md#q-34-how--when-to-surface-the-accountteam-token-budget-given-upstream-doesnt-expose-it)'s budget slot reserved? `[M]`
|
||||
- **Context:** Four backlog tickets and one open question are secretly one feature. Prerequisite: fix the T-274 plumbing bug first or the HUD inherits blank-slot syndrome (root cause is on T-274; the ValueStream retrofit is T-386).
|
||||
- **Source:** fable-ous.md Part IV, Tier 1 #4 (2026-06-11 Fable review).
|
||||
|
||||
### Q-39: Active Ticket Context — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to bind picking up a ticket to the session — a "working on T-NNN" chip, auto `in_progress` flip, `(T-NNN)` pre-suggested in commit messages, changelog reminder on done? `[S]`
|
||||
- **Context:** Cheapest win in the review's whole feature list. Makes kanban ambient instead of homework, and makes trail/ledger features (Q-35, Q-43) reliable by giving every turn a ticket anchor.
|
||||
- **Source:** fable-ous.md Part IV, Tier 1 #5 (2026-06-11 Fable review).
|
||||
|
||||
### Q-40: Twin-timeline rewind — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to snapshot the worktree as hidden git refs (`git write-tree` → `refs/clide/checkpoints`) at every turn boundary, keyed to turn uuid, so every user-message card gains "restore files to before this"? `[L]`
|
||||
- **Context:** Claude's `/rewind` only restores what Claude itself edited; clide owns both timelines. Needs a retention/GC policy for the checkpoint refs.
|
||||
- **Source:** fable-ous.md Part IV, Tier 2 #6 (2026-06-11 Fable review).
|
||||
|
||||
### Q-41: Visual Dialog — one bidirectional scene schema — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to define one bidirectional scene schema where Claude draws ([D-91](../decisions/architecture.md#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer) canvas cards), the user annotates in the interaction zone (T-260), and annotations return as *structured geometry + flattened PNG*, not prose?
|
||||
- **Context:** Two lenses converged here. The T-317/T-318 and T-260 specs should merge into one protocol *before* they become two dialects — this question is urgent in ordering even if the build is later. `[L]`
|
||||
- **Source:** fable-ous.md Part IV, Tier 2 #7 (2026-06-11 Fable review).
|
||||
|
||||
### Q-42: Immortal terminals — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to fuse T-258 (terminal as editor-mode peer) with T-325 live-tails so any long process — user shell *or* agent-spawned build — promotes to a full tmux-backed surface that survives restart? `[M]`
|
||||
- **Context:** "The build that never dies" is structural for clide, a plugin fantasy for Electron competitors. Resolving [Q-27](architecture.md#q-27-two-editor-split) (swap vs split) is part of it, as T-258 already notes.
|
||||
- **Source:** fable-ous.md Part IV, Tier 2 #8 (2026-06-11 Fable review).
|
||||
|
||||
### Q-43: Local cost ledger — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to persist per-turn cost/tokens against the active pql ticket so the board shows what each feature actually cost? (Tokens primary, dollars advisory — subscription auth reports notional costs.) `[M]`
|
||||
- **Context:** Flat-subscription opacity is the competitors' business model; turn-level local cost data is clide's birthright. Depends on Q-39 (active ticket binding) for reliable attribution.
|
||||
- **Source:** fable-ous.md Part IV, Tier 2 #9 (2026-06-11 Fable review).
|
||||
|
||||
### Q-44: Label-routed work queues / ticket dispatch — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to turn the board into an agent control surface — T-277 labels + the shipped pick-up path routing work queues, with an XL extension dispatching a ticket to a teammate session in an isolated git worktree (`SpawnSpec.cwd` already exists)? `[M→XL]`
|
||||
- **Context:** Local, no-telemetry background agents with the work item, isolation, review surface, and audit trail all in-repo.
|
||||
- **Source:** fable-ous.md Part IV, Tier 2 #10 (2026-06-11 Fable review).
|
||||
|
||||
### Q-45: Semantic terminal — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to inject OSC 133 markers (clide spawns the shell, injection is trivial) to lift scrollback into foldable command regions, with recognizers for test runners and stack traces, and real widgets between rows? `[XL]`
|
||||
- **Context:** xterm.js structurally cannot do widgets between rows. Hard prerequisite: the scrollback-unreachable bug (in T-378) — a semantic scrollback you can't scroll is a koan.
|
||||
- **Source:** fable-ous.md Part IV, Tier 3 #11 (2026-06-11 Fable review).
|
||||
|
||||
### Q-46: Living codebase map — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to render tree-sitter imports + pql links + git churn on the owned canvas, with live agent heat from `tool_use` events — watching Claude move through the codebase in real time? `[XL]`
|
||||
- **Context:** Needs the canvas surface (T-317 family) and the tree-sitter FFI work to be solid first.
|
||||
- **Source:** fable-ous.md Part IV, Tier 3 #12 (2026-06-11 Fable review).
|
||||
|
||||
### Q-47: Live mixed documents — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to make fenced blocks in the owned markdown renderer live embeds — canvas scenes, pql query results, decision cards, confirm-gated command buttons? Notebook-grade, zero webview. `[XL]`
|
||||
- **Context:** The `ClideMarkdownHooks` seam already exists.
|
||||
- **Source:** fable-ous.md Part IV, Tier 3 #13 (2026-06-11 Fable review).
|
||||
|
||||
### Q-48: Sealed-workspace mode — implement?
|
||||
- **Status:** Open
|
||||
- **Question:** Are we going to build an egress-audit proxy around the whole agent stack, operationalizing [D-60](../decisions/tooling.md#d-60-no-network-on-default-launch-path)/[D-64](../decisions/architecture.md#d-64-no-telemetry--architectural-commitment) into a provable property? `[XL]`
|
||||
- **Context:** The one feature in the review's list competitors cannot copy without breaking their own products.
|
||||
- **Source:** fable-ous.md Part IV, Tier 3 #15 (2026-06-11 Fable review).
|
||||
|
||||
### Q-49: Review honorable mentions — which, if any, get promoted?
|
||||
- **Status:** Open
|
||||
- **Question:** Which of the review's honorable mentions, if any, do we promote to tickets: Plan-to-Board bridge (ExitPlanMode approval files the plan as a ticket tree), Release Cockpit (renders `[Unreleased]` + one-action release cut — would also unstall the release-loop story T-393), Daily Helm (since-you-were-last-here pulse on the welcome screen), Total-recall conversation search (D-79 grep over the transcript corpus + fork-from-here), Shared Gaze (attach editor selection/scroll context to outgoing turns), Speakable Layouts (`clide layout apply review.yaml`), Agent fleet tray (per-workspace sockets — requires T-247's stale-socket fix first), Governance Graph (the D/Q/R/T web as a navigable map)?
|
||||
- **Context:** Kept as one record to avoid fifteen low-signal questions; promote individually as appetite appears.
|
||||
- **Source:** fable-ous.md Part IV, honorable mentions (2026-06-11 Fable review).
|
||||
|
||||
---
|
||||
+11
-1129
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,14 @@ final class EditRun extends RenderGroup {
|
||||
/// Tools whose result is a diff the user wants to keep first-class at L1/L2.
|
||||
bool isDiffTool(String name) => const {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Update'}.contains(name);
|
||||
|
||||
/// Tool names that spawn a sub-agent (sidechain): Claude Code emits `Task`,
|
||||
/// the Agent SDK surface uses `Agent`. An agent spawn is ALWAYS its own
|
||||
/// first-class collapsing card — it breaks the Activity cluster so a fan-out
|
||||
/// of N agents reads as N cards, never one merged "Activity / N steps" card
|
||||
/// (T-342). Each card carries its own folded prompt (T-263) + nested run
|
||||
/// (T-264); the fold mechanics are unchanged, only the grouping boundary.
|
||||
bool isAgentTool(String name) => name == 'Task' || name == 'Agent';
|
||||
|
||||
/// The file an edit tool-use targets, or null if [it] isn't a same-file edit
|
||||
/// (used to group consecutive edits, T-296).
|
||||
String? editFilePath(ConversationItem it) {
|
||||
@@ -159,6 +167,10 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
|
||||
case AssistantThinkingMessage():
|
||||
return level != FoldLevel.tools;
|
||||
case AssistantToolUse(:final name):
|
||||
// An Agent/Task spawn is always its own first-class card (T-342) — it
|
||||
// breaks the cluster at every level, including L3, so parallel agents
|
||||
// never merge into one Activity card.
|
||||
if (isAgentTool(name)) return false;
|
||||
// The Edit/Write call stays first-class with its diff at L1/L2.
|
||||
if (level == FoldLevel.everything) return true;
|
||||
return !isDiffTool(name);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/// Detect the file a Bash command follows, for the live-tail sub-card (T-325).
|
||||
///
|
||||
/// Claude Code runs every Bash tool itself and clide only sees the final
|
||||
/// `tool_result` block — we never tap the running command's stdout. So instead
|
||||
/// of mirroring the process, we detect a *file-backed source* the command
|
||||
/// reads/follows and open our own read-only follower on the same file.
|
||||
///
|
||||
/// Deliberately conservative (the ticket's "small, explicit allowlist"): only
|
||||
/// the read/follow verbs below, only a single file argument, and only paths
|
||||
/// that resolve INSIDE the workspace. Anything else — a pipe into `tail`, a
|
||||
/// redirect, two files, a path outside the repo — returns null so the caller
|
||||
/// shows a "nothing to follow" affordance instead of following the wrong thing.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/files/path_safety.dart';
|
||||
|
||||
/// Verbs whose single file argument clide can independently follow read-only.
|
||||
const Set<String> _followVerbs = {'tail', 'cat', 'less'};
|
||||
|
||||
/// The file [command] reads/follows that clide can mirror read-only, as an
|
||||
/// absolute path inside [workspaceRoot] — or null when there is no single,
|
||||
/// safe, file-backed source. See the library doc for the policy.
|
||||
String? detectBashTailSource(String command, {required Directory workspaceRoot}) {
|
||||
String? found;
|
||||
for (final segment in _commandSegments(command)) {
|
||||
final tokens = _tokenize(segment);
|
||||
if (tokens.isEmpty || !_followVerbs.contains(tokens.first)) continue;
|
||||
final files = _fileArgs(tokens.first, tokens.sublist(1));
|
||||
if (files.length != 1) continue; // 0 → reads stdin (a pipe); >1 → ambiguous
|
||||
|
||||
final String resolved;
|
||||
try {
|
||||
resolved = resolveUnderRoot(workspaceRoot, files.single);
|
||||
} on PathOutsideRoot {
|
||||
continue; // outside the workspace → don't follow (v1 policy)
|
||||
}
|
||||
if (found != null && found != resolved) return null; // two distinct sources
|
||||
found = resolved;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// Whether [command] expresses an intent to FOLLOW a file — used to decide
|
||||
/// when to surface the live-tail segment at all, so ordinary commands (`ls`,
|
||||
/// `git status`, a plain `cat`) get no segment, but a `tail …` with no
|
||||
/// followable file still shows the "nothing to follow" note. v1 triggers on
|
||||
/// `tail` or a follow flag (`-f`/`-F`/`--follow`); `cat`/`less` are detectable
|
||||
/// sources but don't trigger the UI on their own (T-325).
|
||||
bool bashHasTailIntent(String command) {
|
||||
for (final segment in _commandSegments(command)) {
|
||||
final tokens = _tokenize(segment);
|
||||
if (tokens.isEmpty) continue;
|
||||
if (tokens.first == 'tail') return true;
|
||||
if (tokens.any((t) => t == '-f' || t == '-F' || t == '--follow')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Split a command line into command/pipeline segments on `|`, `;`, `&`. The
|
||||
/// doubled forms (`&&`, `||`) fall out as empty middles and are dropped.
|
||||
Iterable<String> _commandSegments(String command) => command.split(RegExp(r'[|;&]')).where((s) => s.trim().isNotEmpty);
|
||||
|
||||
/// Positional (non-flag) file arguments for [verb]. Skips flags, consumes the
|
||||
/// value of `tail -n N` / `-c N`, honours `--` (end of options), and stops at a
|
||||
/// redirect (`>` / `<`) — everything after a redirect targets a fd, not the
|
||||
/// command's input.
|
||||
List<String> _fileArgs(String verb, List<String> args) {
|
||||
final files = <String>[];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
final a = args[i];
|
||||
if (a == '--') {
|
||||
files.addAll(args.sublist(i + 1).where((t) => !t.contains('>') && !t.contains('<')));
|
||||
break;
|
||||
}
|
||||
if (a.contains('>') || a.contains('<')) break; // a redirect ends positional args
|
||||
if (a.startsWith('-')) {
|
||||
if (verb == 'tail' && (a == '-n' || a == '-c') && i + 1 < args.length) i++; // -n N / -c N
|
||||
continue;
|
||||
}
|
||||
files.add(a);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/// Minimal shell tokeniser: splits on whitespace, honours single/double quotes
|
||||
/// (no escape or expansion handling — enough to recover file arguments).
|
||||
List<String> _tokenize(String s) {
|
||||
final out = <String>[];
|
||||
final buf = StringBuffer();
|
||||
String? quote;
|
||||
var has = false;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
final ch = s[i];
|
||||
if (quote != null) {
|
||||
if (ch == quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
has = true;
|
||||
} else if (ch == '"' || ch == "'") {
|
||||
quote = ch;
|
||||
has = true;
|
||||
} else if (ch == ' ' || ch == '\t') {
|
||||
if (has) {
|
||||
out.add(buf.toString());
|
||||
buf.clear();
|
||||
has = false;
|
||||
}
|
||||
} else {
|
||||
buf.write(ch);
|
||||
has = true;
|
||||
}
|
||||
}
|
||||
if (has) out.add(buf.toString());
|
||||
return out;
|
||||
}
|
||||
@@ -253,6 +253,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
_version = _parseVersion(await _guard(_versionRunner));
|
||||
await _readProbeCache();
|
||||
await _loadDiskConfig();
|
||||
if (_disposed) return; // activation fired-and-forgot; teardown won
|
||||
_startWatchers();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -296,8 +297,14 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set when [dispose] runs. The fire-and-forget [load] from extension
|
||||
/// activation checks this so a teardown racing an in-flight load can't
|
||||
/// notify (or start watchers on) a disposed notifier.
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_stopWatching();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,7 @@ class ClaudePane extends StatefulWidget {
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
StreamSubscription<SessionStatus>? _statusSub;
|
||||
StreamSubscription<SessionEnd>? _endSub;
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
ConversationController? _conversation;
|
||||
StreamJsonSession? _session;
|
||||
@@ -80,6 +81,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
String? _error;
|
||||
String _statusLine = 'starting…';
|
||||
|
||||
/// One-shot fork source: seeds the first bind, then cleared so /clear,
|
||||
/// /resume, and respawns operate on this pane's own session (T-375).
|
||||
late String? _forkSource = widget.forkSourceId;
|
||||
|
||||
bool _spawned = false;
|
||||
|
||||
/// Per-session composer draft (text + caret), held here so an unsent
|
||||
@@ -141,6 +146,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Cache the kernel for dispose() — ancestor lookups there are illegal,
|
||||
// and the old lookup-and-swallow leaked the settings listener on every
|
||||
// disposed pane (T-366).
|
||||
_kernel = ClideKernel.of(context);
|
||||
// Spawn once, after the kernel is available.
|
||||
if (!_spawned) {
|
||||
_spawned = true;
|
||||
@@ -168,11 +177,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
void dispose() {
|
||||
activeClaudeConfig?.removeListener(_onConfigChanged);
|
||||
_kernel()?.settings.removeListener(_onSettingsChanged);
|
||||
_kernel?.settings.removeListener(_onSettingsChanged);
|
||||
_projectSub?.cancel();
|
||||
_projectSub = null;
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
// The orchestrator owns the session, so disposing this pane does NOT kill
|
||||
// it — that's what lets a hidden/kept-alive pane keep its session (T-169).
|
||||
// A secondary tab being *closed* is a real teardown, so close its session;
|
||||
@@ -224,6 +235,8 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
Future<void> _rebindToActiveProject() async {
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
|
||||
_conversation = null;
|
||||
_session = null;
|
||||
@@ -263,7 +276,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
|
||||
final ManagedSession managed;
|
||||
final forkSource = widget.forkSourceId;
|
||||
final forkSource = _forkSource;
|
||||
if (forkSource != null) {
|
||||
// Fork pane: branch source session into a new clide-managed session.
|
||||
// The clide-internal id is a fresh UUID; the real claude session id is
|
||||
@@ -277,6 +290,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
||||
return;
|
||||
}
|
||||
// One-shot: the fork source seeds only the FIRST bind. Leaving it set
|
||||
// made /clear re-fork the original conversation instead of clearing —
|
||||
// every later respawn must operate on this pane's own session (T-375).
|
||||
_forkSource = null;
|
||||
if (!mounted) return;
|
||||
setState(() => _statusLine = 'fork of $forkSource');
|
||||
} else {
|
||||
@@ -314,7 +331,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// a fresh spawn vs connecting to existing on-disk history (the seed read
|
||||
// from the transcript/sidecar). Surfaces the resume path in `make run`.
|
||||
final seeded = _conversation?.items.length ?? 0;
|
||||
_kernel()?.log.info(
|
||||
_kernel?.log.info(
|
||||
'claude',
|
||||
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot — '
|
||||
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
|
||||
@@ -323,6 +340,24 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = s);
|
||||
});
|
||||
// Surface a dead process instead of letting it look thoughtful (T-361):
|
||||
// late binders read the replayed end; live sessions stream it.
|
||||
final alreadyEnded = managed.session.end;
|
||||
if (alreadyEnded != null) {
|
||||
_onSessionEnd(alreadyEnded);
|
||||
} else {
|
||||
_endSub = managed.session.endedStream.listen(_onSessionEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/// The claude process exited under this pane's live session. Stop looking
|
||||
/// busy, say so in the status line, and log the drained stderr tail —
|
||||
/// the diagnostics that used to vanish (T-361).
|
||||
void _onSessionEnd(SessionEnd end) {
|
||||
if (!mounted) return;
|
||||
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
|
||||
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
|
||||
setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart');
|
||||
}
|
||||
|
||||
// Send composed text to Claude over the stream-json channel. Commands clide
|
||||
@@ -422,7 +457,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
/// re-bind the pane to it.
|
||||
Future<void> _resumeFlow() async {
|
||||
final root = _repoRoot;
|
||||
final dialog = _kernel()?.dialog;
|
||||
final dialog = _kernel?.dialog;
|
||||
if (root == null || dialog == null) return;
|
||||
final dir = Directory(claudeProjectDir(root));
|
||||
final sessions = await listSessions(dir);
|
||||
@@ -440,6 +475,8 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
Future<void> _respawnWithSession(String sessionId, {bool clearTranscript = false}) async {
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old session
|
||||
// Erase only after the process is dead, so claude isn't mid-write.
|
||||
final root = _repoRoot;
|
||||
@@ -455,15 +492,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
// -- helpers --------------------------------------------------------------
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
DaemonClient? _ipc() => _kernel?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
try {
|
||||
return ClideKernel.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/// Cached in didChangeDependencies (T-366); see note there.
|
||||
KernelServices? _kernel;
|
||||
|
||||
// -- build ----------------------------------------------------------------
|
||||
|
||||
@@ -496,7 +528,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
onTap: _focusComposerOnTap,
|
||||
child: ConversationView(
|
||||
controller: _conversation!,
|
||||
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
|
||||
foldLevel: foldLevelFromName(_kernel?.settings.get<String>(kActivityFoldLevelKey)),
|
||||
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
||||
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
||||
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||
|
||||
@@ -14,8 +14,10 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
|
||||
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
@@ -23,6 +25,7 @@ import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/src/terminal/terminal.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -205,6 +208,15 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
if (it is AssistantToolUse) toolUseIds.add(it.toolUseId);
|
||||
}
|
||||
|
||||
// With more than one agent in the turn (a parallel fan-out), the
|
||||
// "nearest preceding agent" fallback is unsafe: an item with no
|
||||
// parent_tool_use_id and no rooted parentUuid chain would mis-file into
|
||||
// whichever agent was emitted last — landing in a SIBLING agent's card.
|
||||
// Drop the fallback in that case so an unattributable item orphans
|
||||
// (rendered inline) rather than cross-attributed (T-342). A single agent
|
||||
// has only one possible owner, so the fallback stays safe there.
|
||||
final multipleAgents = agentByToolUseId.length > 1;
|
||||
|
||||
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
|
||||
// Direct route: stream-json hands us the spawning Agent's tool-use id on
|
||||
// the item itself (T-338) — no chain to walk.
|
||||
@@ -223,7 +235,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
if (sidechainByUuid[parent] != true) break; // left the run's chain
|
||||
cur = parent;
|
||||
}
|
||||
return nearest;
|
||||
return multipleAgents ? null : nearest;
|
||||
}
|
||||
|
||||
final owned = <String>{};
|
||||
@@ -256,9 +268,13 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
void _onChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
// Follow the tail — jump to the bottom after the new item lays out.
|
||||
// Follow the tail — but only when already pinned to it. New items arrive
|
||||
// on every streamed token; jumping unconditionally yanks a reader who
|
||||
// scrolled up back to the bottom for the whole reply (T-368, twin of the
|
||||
// T-297 resize gate).
|
||||
if (!_atBottom) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scroll.hasClients) {
|
||||
if (_scroll.hasClients && _atBottom) {
|
||||
_scroll.jumpTo(_scroll.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
@@ -371,9 +387,9 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
/// used for the "claude" message card's stripe + label.
|
||||
const claudeAccent = Color(0xFFD97757);
|
||||
|
||||
/// The tool names that launch a sub-agent (sidechain). Claude Code emits
|
||||
/// `Task`; the Agent SDK surface uses `Agent` — accept both (T-263).
|
||||
bool _isAgentTool(String name) => name == 'Task' || name == 'Agent';
|
||||
/// The tool names that launch a sub-agent (sidechain) — shared with the
|
||||
/// grouping pass so "is this an agent spawn?" has one definition (T-342).
|
||||
bool _isAgentTool(String name) => isAgentTool(name);
|
||||
|
||||
/// Open a governance/ticket record clicked in the conversation (T-279) in its
|
||||
/// context-pane reader, reusing the existing `selection` MessageBus addressing
|
||||
@@ -414,6 +430,66 @@ void _openFile(BuildContext context, String path, int? line) {
|
||||
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, 'line': ?line}));
|
||||
}
|
||||
|
||||
/// A live, read-only tail of the file a Bash command follows (T-325).
|
||||
///
|
||||
/// Mounts when the Bash card is EXPANDED — the collapser builds its children
|
||||
/// lazily (clide_collapser_card.dart), so initialising here and tearing down in
|
||||
/// [dispose] gives the "connect on expand, disconnect on collapse" lifecycle
|
||||
/// for free. Resolves the followed file from the command against the open
|
||||
/// workspace; when there's no independent file-backed source (a pipe into
|
||||
/// `tail`, a path outside the repo) it shows a muted note instead of an empty
|
||||
/// terminal.
|
||||
class _BashLiveTail extends StatefulWidget {
|
||||
const _BashLiveTail({required this.command});
|
||||
|
||||
final String command;
|
||||
|
||||
@override
|
||||
State<_BashLiveTail> createState() => _BashLiveTailState();
|
||||
}
|
||||
|
||||
class _BashLiveTailState extends State<_BashLiveTail> {
|
||||
Terminal? _terminal;
|
||||
FileTailFollower? _follower;
|
||||
bool _resolved = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_resolved) return; // resolve once — InheritedWidget access needs context
|
||||
_resolved = true;
|
||||
final root = ClideKernel.of(context).project.current;
|
||||
final source = root == null ? null : detectBashTailSource(widget.command, workspaceRoot: root);
|
||||
if (source == null) return; // no file-backed source → muted note in build
|
||||
final term = Terminal(maxLines: 1000);
|
||||
_terminal = term;
|
||||
// writeBytes: the follower's chunk boundaries are arbitrary (it can even
|
||||
// start mid-rune by construction) — keep decode state across reads (T-373).
|
||||
_follower = FileTailFollower(source, onData: term.writeBytes);
|
||||
unawaited(_follower!.start());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_follower?.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final term = _terminal;
|
||||
if (term == null) {
|
||||
return ClideText('no independent source to follow', muted: true, fontSize: clideFontMeta);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 160,
|
||||
child: ClipRect(
|
||||
child: ClidePtyView(terminal: term, label: 'live tail', fontSize: clideFontMeta),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One conversation item, rendered by kind.
|
||||
class _ConversationTurn extends StatelessWidget {
|
||||
const _ConversationTurn({
|
||||
@@ -685,6 +761,14 @@ class _ConversationTurn extends StatelessWidget {
|
||||
label: 'result',
|
||||
child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)),
|
||||
),
|
||||
// T-325: a Bash card that follows a file (`tail -f …`) gets a live,
|
||||
// scrolling tail of that file below the result — connected lazily, only
|
||||
// while the card is expanded (the collapser builds segments on expand).
|
||||
if (t.name == 'Bash' && t.input['command'] is String && bashHasTailIntent(t.input['command'] as String))
|
||||
CardSegment(
|
||||
label: 'live tail',
|
||||
child: _BashLiveTail(command: t.input['command'] as String),
|
||||
),
|
||||
];
|
||||
|
||||
// A resolved permission-prompted call is tinted green if approved / red if
|
||||
|
||||
@@ -21,6 +21,18 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// D-6 contract (T-391): a failed command returns an ERROR envelope (non-zero
|
||||
/// CLI exit), never `ok` with an `error` field a script can't detect.
|
||||
IpcResponse _userErr(String msg, {String? hint}) => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: msg, hint: hint),
|
||||
);
|
||||
|
||||
IpcResponse _notFound(String msg) => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: msg),
|
||||
);
|
||||
|
||||
class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.claude';
|
||||
@@ -95,7 +107,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: show an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.show(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
|
||||
},
|
||||
@@ -106,7 +118,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: hide an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.hide(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
|
||||
},
|
||||
@@ -117,7 +129,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: close (kill) an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
await _orchestrator?.close(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
|
||||
},
|
||||
@@ -128,7 +140,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: mute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.mute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
|
||||
},
|
||||
@@ -139,7 +151,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: unmute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.unmute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
|
||||
},
|
||||
@@ -151,9 +163,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: inject a text turn into an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
final text = args.skip(1).join(' ');
|
||||
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
|
||||
if (text.isEmpty) return _userErr('missing message text');
|
||||
_orchestrator?.injectMessage(id, text);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
|
||||
},
|
||||
@@ -169,12 +181,12 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: set permission mode for an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
final mode = args.length >= 2 ? args[1] : null;
|
||||
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
|
||||
if (mode == null) return _userErr('missing mode (default|acceptEdits|plan|bypassPermissions)');
|
||||
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
|
||||
if (!valid.contains(mode)) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
|
||||
return _userErr('unknown mode "$mode"; use one of: ${valid.join(', ')}');
|
||||
}
|
||||
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
|
||||
@@ -188,7 +200,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: Cycle permission mode',
|
||||
run: (_) async {
|
||||
final managed = _orchestrator?.byId('primary');
|
||||
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
|
||||
if (managed == null) return _notFound('no primary session');
|
||||
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
|
||||
managed.session.setPermissionMode(next);
|
||||
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
|
||||
@@ -200,11 +212,12 @@ class ClaudeExtension extends ClideExtension {
|
||||
command: 'claude.task.reassign',
|
||||
title: 'Claude: reassign a shared task to an agent',
|
||||
run: (args) async {
|
||||
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
|
||||
if (args.length < 2) return _userErr('usage: <taskId> <sessionId>');
|
||||
final taskId = args[0];
|
||||
final toId = args[1];
|
||||
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
|
||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
|
||||
if (!ok) return _notFound('could not reassign task "$taskId" to "$toId"');
|
||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': true});
|
||||
},
|
||||
),
|
||||
// T-180: full team chat pane opened as a workspace tab.
|
||||
@@ -241,7 +254,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
command: 'claude.team-chat.post',
|
||||
title: 'Claude: post a message into the team channel as the user',
|
||||
run: (args) async {
|
||||
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
|
||||
if (args.isEmpty) return _userErr('usage: [@name] <text>');
|
||||
final raw = args.join(' ');
|
||||
String? recipient;
|
||||
String body = raw;
|
||||
@@ -269,15 +282,18 @@ class ClaudeExtension extends ClideExtension {
|
||||
run: (args) async {
|
||||
final sourceId = args.firstOrNull;
|
||||
if (sourceId == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
|
||||
return _userErr('usage: claude.agent.fork <sourceSessionId> [<cwd>]');
|
||||
}
|
||||
final orch = _orchestrator;
|
||||
if (orch == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'orchestrator unavailable'),
|
||||
);
|
||||
}
|
||||
final source = orch.byId(sourceId);
|
||||
if (source == null) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
|
||||
return _notFound('unknown session "$sourceId"');
|
||||
}
|
||||
final cwd = args.length >= 2 ? args[1] : source.cwd;
|
||||
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/// Read-only file follower for the Bash live-tail sub-card (T-325).
|
||||
///
|
||||
/// clide can't see a running Bash command's stdout (Claude Code owns the
|
||||
/// process), so to "watch the same output" we open our OWN read-only follower
|
||||
/// on the file the command tails. This never spawns a process and never
|
||||
/// touches Claude's command — it just reads the file as it grows, like
|
||||
/// `tail -f`, and hands new bytes to [onData].
|
||||
///
|
||||
/// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather
|
||||
/// than using a watcher so it works uniformly across platforms and survives
|
||||
/// truncation/rotation (size shrinking → re-read from the top).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class FileTailFollower {
|
||||
FileTailFollower(this.path, {required this.onData, this.tailBytes = 16384, this.interval = const Duration(milliseconds: 300)});
|
||||
|
||||
/// Absolute path of the file to follow.
|
||||
final String path;
|
||||
|
||||
/// New bytes appended since the last read (or the initial tail window).
|
||||
final void Function(Uint8List bytes) onData;
|
||||
|
||||
/// On first read, start this many bytes from the end (a `tail -c` window)
|
||||
/// rather than dumping the whole file.
|
||||
final int tailBytes;
|
||||
|
||||
final Duration interval;
|
||||
|
||||
int _pos = 0;
|
||||
bool _primed = false;
|
||||
bool _stopped = false;
|
||||
Timer? _timer;
|
||||
|
||||
/// Begin following: emit the initial tail window, then poll for growth.
|
||||
Future<void> start() async {
|
||||
await pollOnce();
|
||||
if (_stopped) return;
|
||||
_timer = Timer.periodic(interval, (_) => pollOnce());
|
||||
}
|
||||
|
||||
/// One read cycle. Public so tests can drive it deterministically without
|
||||
/// waiting on the timer. Reads any bytes appended since the last position
|
||||
/// (or, on the first call, the trailing [tailBytes]); resets to the top if
|
||||
/// the file shrank (truncated/rotated).
|
||||
Future<void> pollOnce() async {
|
||||
if (_stopped) return;
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return; // not created yet — keep waiting
|
||||
final length = await file.length();
|
||||
|
||||
if (!_primed) {
|
||||
_pos = length > tailBytes ? length - tailBytes : 0;
|
||||
_primed = true;
|
||||
} else if (length < _pos) {
|
||||
_pos = 0; // truncated / rotated → re-read from the top
|
||||
}
|
||||
if (length <= _pos) return;
|
||||
|
||||
final raf = await file.open();
|
||||
try {
|
||||
await raf.setPosition(_pos);
|
||||
final bytes = await raf.read(length - _pos);
|
||||
_pos = length;
|
||||
if (!_stopped && bytes.isNotEmpty) onData(Uint8List.fromList(bytes));
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop following and release the timer. Idempotent.
|
||||
void stop() {
|
||||
_stopped = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/// The Activity tab: usage stats (stats-cache.json) + the primary
|
||||
/// session's live runtime row. Split out of claude_meta_sidebar.dart
|
||||
/// (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_stats.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ActivityTabView extends StatelessWidget {
|
||||
const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config});
|
||||
|
||||
final ClaudeStats stats;
|
||||
final SessionStatus? primaryStatus;
|
||||
final ClaudeConfig? config;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final latest = stats.latest;
|
||||
final sections = <MetaSection>[
|
||||
if (latest != null)
|
||||
MetaSection('TODAY', [
|
||||
MetaRow('messages', '${latest.messageCount}'),
|
||||
MetaRow('sessions', '${latest.sessionCount}'),
|
||||
MetaRow('tool calls', '${latest.toolCallCount}'),
|
||||
]),
|
||||
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
|
||||
..._runtimeSection(tokens),
|
||||
];
|
||||
if (sections.isEmpty) {
|
||||
return metaPlaceholder('No activity recorded yet.');
|
||||
}
|
||||
return buildMetaTable(tokens, sections);
|
||||
}
|
||||
|
||||
List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
|
||||
final st = primaryStatus;
|
||||
final skills = config?.skills.length;
|
||||
final rows = <MetaRow>[
|
||||
if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
|
||||
if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
|
||||
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
|
||||
if (skills != null) MetaRow('skills', '$skills'),
|
||||
];
|
||||
return rows.isEmpty ? const [] : [MetaSection('RUNTIME · primary', rows)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/// The Config tab (T-183): the pinned settings table over [ClaudeConfig]
|
||||
/// plus the skills/agents/commands/hooks/permissions/MCP accordion.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395). The accordion's
|
||||
/// expansion state lives in the parent (it survives tab switches) and
|
||||
/// arrives as a prop + toggle callback.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ConfigTabView extends StatelessWidget {
|
||||
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection});
|
||||
|
||||
final ClaudeConfig? config;
|
||||
|
||||
/// Sections currently expanded — owned by the parent state.
|
||||
final Set<ConfigSection> expanded;
|
||||
final void Function(ConfigSection section) onToggleSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final cfg = config;
|
||||
if (cfg == null) {
|
||||
return metaPlaceholder('Claude environment not loaded.');
|
||||
}
|
||||
final settings = cfg.settings;
|
||||
final model = cfg.probe?.model ?? settings['model']?.toString() ?? '—';
|
||||
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
|
||||
final mode = cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
|
||||
|
||||
final children = <Widget>[
|
||||
// Pinned SETTINGS table — not collapsible.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
_configRow(tokens, 'model', model, valueColor: tokens.globalFocus),
|
||||
_configRow(tokens, 'output style', outputStyle),
|
||||
_configRow(tokens, 'permission mode', permissionModeLabel(mode)),
|
||||
_configRow(tokens, 'source', '~/.claude + .claude'),
|
||||
|
||||
// ---- Accordion sections ----
|
||||
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section),
|
||||
|
||||
// Footer hint.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
];
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
/// One key→value row in the pinned SETTINGS table.
|
||||
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(label, muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(value, fontSize: clideFontSmall, color: valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _sectionLabel(ConfigSection section) => switch (section) {
|
||||
ConfigSection.skills => 'SKILLS',
|
||||
ConfigSection.agents => 'AGENTS',
|
||||
ConfigSection.commands => 'COMMANDS',
|
||||
ConfigSection.hooks => 'HOOKS',
|
||||
ConfigSection.permissions => 'PERMISSIONS',
|
||||
ConfigSection.mcpServers => 'MCP SERVERS',
|
||||
};
|
||||
|
||||
int _sectionCount(ClaudeConfig config, ConfigSection section) => switch (section) {
|
||||
ConfigSection.skills => config.skills.length,
|
||||
ConfigSection.agents => config.agents.length,
|
||||
ConfigSection.commands => config.commands.length,
|
||||
ConfigSection.hooks => config.hooks.length,
|
||||
ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
|
||||
ConfigSection.mcpServers => config.mcpServers.length,
|
||||
};
|
||||
|
||||
Widget _accordion(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||
final isExpanded = expanded.contains(section);
|
||||
final children = isExpanded ? _sectionChildren(context, tokens, config, section) : const <Widget>[];
|
||||
return ClideAccordion(
|
||||
label: _sectionLabel(section),
|
||||
count: _sectionCount(config, section),
|
||||
expanded: isExpanded,
|
||||
onToggle: () => onToggleSection(section),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _sectionChildren(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||
switch (section) {
|
||||
case ConfigSection.skills:
|
||||
return [for (final skill in config.skills) _fileRow(context, tokens, skill.name, skill.path)];
|
||||
case ConfigSection.agents:
|
||||
return [for (final agent in config.agents) _fileRow(context, tokens, agent.name, agent.path)];
|
||||
case ConfigSection.commands:
|
||||
return [for (final cmd in config.commands) _fileRow(context, tokens, cmd.name, cmd.path)];
|
||||
case ConfigSection.hooks:
|
||||
return [
|
||||
for (final hook in config.hooks)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(hook.event, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
for (final cmd in hook.commands)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, top: 1),
|
||||
child: ClideText(cmd, fontSize: clideFontSmall, muted: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
case ConfigSection.permissions:
|
||||
return _permissionRows(tokens, config.permissions);
|
||||
case ConfigSection.mcpServers:
|
||||
return [
|
||||
for (final srv in config.mcpServers)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(srv.name, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row for file-backed items (skills, agents, commands).
|
||||
/// All config items are .md files — opens in the markdown reader panel
|
||||
/// via the kernel MessageBus (D-6, T-183).
|
||||
Widget _fileRow(BuildContext context, SurfaceTokens tokens, String name, String? path) {
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
|
||||
);
|
||||
if (path == null) return row;
|
||||
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: name,
|
||||
excludeSemantics: true,
|
||||
onTap: openMarkdown,
|
||||
child: ClideTappable(
|
||||
tooltip: path,
|
||||
onTap: openMarkdown,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders grouped allow/ask/deny permission rows, colour-coded by kind.
|
||||
List<Widget> _permissionRows(SurfaceTokens tokens, ClaudePermissions perms) {
|
||||
// allow → statusSuccess, ask → statusWarning, deny → statusError
|
||||
Color kindColor(ConfigPermKind k) => switch (k) {
|
||||
ConfigPermKind.allow => tokens.statusSuccess,
|
||||
ConfigPermKind.ask => tokens.statusWarning,
|
||||
ConfigPermKind.deny => tokens.statusError,
|
||||
};
|
||||
|
||||
String kindLabel(ConfigPermKind k) => switch (k) {
|
||||
ConfigPermKind.allow => 'allow',
|
||||
ConfigPermKind.ask => 'ask',
|
||||
ConfigPermKind.deny => 'deny',
|
||||
};
|
||||
|
||||
final groups = [(ConfigPermKind.allow, perms.allow), (ConfigPermKind.ask, perms.ask), (ConfigPermKind.deny, perms.deny)];
|
||||
|
||||
final rows = <Widget>[];
|
||||
for (final (kind, rules) in groups) {
|
||||
if (rules.isEmpty) continue;
|
||||
final color = kindColor(kind);
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText(kindLabel(kind), fontSize: clideFontSmall, color: color),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final rule in rules)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: ClideText(rule, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// A single icon-button used by the roster row controls + task rows.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395). Promote to
|
||||
/// lib/widgets/ only when a second consumer appears.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MetaIconButton extends StatelessWidget {
|
||||
const MetaIconButton({super.key, required this.painter, required this.tooltip, required this.color, required this.onTap});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Icon-only button: expose the tooltip text as the Semantics button label
|
||||
// so AT (and widget tests) can find and activate it by name.
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
excludeSemantics: true,
|
||||
onTap: onTap,
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideIcon(painter, size: 12, color: hovered ? ClideTheme.of(ctx).surface.globalForeground : color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Inline text input for injecting a message into a session (T-171).
|
||||
/// Submits on Enter; Cancel is handled by the parent's icon button.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class InjectTextField extends StatelessWidget {
|
||||
const InjectTextField({super.key, required this.controller, required this.tokens, required this.onSubmit});
|
||||
|
||||
final TextEditingController controller;
|
||||
final SurfaceTokens tokens;
|
||||
final void Function(String text) onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
|
||||
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: onSubmit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/// Shared models + table geometry for the Claude meta sidebar's tabs.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The shared label-column width + row pitch the Activity and Config tables
|
||||
/// both use, so toggling between tabs keeps every value at the same x and y.
|
||||
const double kMetaLabelColumnWidth = 110;
|
||||
const double kMetaRowPitch = 4;
|
||||
|
||||
/// The sidebar's sub-tabs.
|
||||
enum SidebarTab { activity, team, config }
|
||||
|
||||
// T-183: accordion sections for the Config tab.
|
||||
enum ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
|
||||
|
||||
/// Permission kind for colour-coding in the Config tab (T-183).
|
||||
enum ConfigPermKind { allow, ask, deny }
|
||||
|
||||
class MetaSection {
|
||||
const MetaSection(this.header, this.rows);
|
||||
final String header;
|
||||
final List<MetaRow> rows;
|
||||
}
|
||||
|
||||
class MetaRow {
|
||||
const MetaRow(this.label, this.value, {this.valueColor});
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
}
|
||||
|
||||
/// The muted empty-state body shared by every tab.
|
||||
Widget metaPlaceholder(String text) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(text, muted: true, fontSize: clideFontSmall),
|
||||
);
|
||||
|
||||
/// Key→value sections on the shared table geometry (Activity + Config).
|
||||
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
final s = sections[i];
|
||||
children.add(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
|
||||
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
);
|
||||
for (final r in s.rows) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// Clickable permission-mode badge shown in each roster row (T-181).
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Maps a permission-mode string to a single-letter badge label.
|
||||
String permissionModeBadgeLabel(String mode) => switch (mode) {
|
||||
'acceptEdits' => 'A',
|
||||
'plan' => 'P',
|
||||
'bypassPermissions' => 'B',
|
||||
_ => 'D', // default
|
||||
};
|
||||
|
||||
/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default.
|
||||
/// - Shift-click → shows the bypass confirm inline in the parent row.
|
||||
///
|
||||
/// The badge reflects the LIVE mode from `SessionStatus.permissionMode`
|
||||
/// (T-157). It is a custom painted label (no Material), consistent with the
|
||||
/// rendering stack rules (D-7, CLAUDE.md guardrails).
|
||||
class PermissionModeBadge extends StatelessWidget {
|
||||
const PermissionModeBadge({super.key, required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
|
||||
|
||||
final String mode;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
/// Called on a plain click — the parent cycles to the next safe mode.
|
||||
final VoidCallback onCycle;
|
||||
|
||||
/// Called on a shift-click — the parent shows the bypass confirm.
|
||||
final VoidCallback onBypass;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = permissionModeBadgeLabel(mode);
|
||||
final isBypass = mode == 'bypassPermissions';
|
||||
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
|
||||
|
||||
final tooltip =
|
||||
'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Permission mode: $label',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
width: 16,
|
||||
height: 14,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withAlpha(hovered ? 51 : 26),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
|
||||
),
|
||||
child: ClideText(label, fontSize: 9, color: badgeColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/// A single agent roster row: color dot + name + status sub-text +
|
||||
/// controls (T-171). Split out of claude_meta_sidebar.dart (T-395).
|
||||
///
|
||||
/// Controls (trailing region):
|
||||
/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click
|
||||
/// reaches bypassPermissions behind a confirm
|
||||
/// - eye / eye-slash — show / hide the session pane
|
||||
/// - speaker / speaker-slash — mute / unmute broker delivery
|
||||
/// - inject (chat icon) — expand the inline message input
|
||||
/// - fork (git-branch icon) — open a new pane branching from this session (T-172)
|
||||
/// - close (×) — kill the session
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/inject_field.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/permission_badge.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AgentRosterRow extends StatefulWidget {
|
||||
const AgentRosterRow({
|
||||
super.key,
|
||||
required this.member,
|
||||
required this.status,
|
||||
required this.orchestrator,
|
||||
required this.injectingAgentId,
|
||||
required this.injectController,
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
required this.onFork,
|
||||
});
|
||||
|
||||
final TeamMemberJoined member;
|
||||
final SessionStatus? status;
|
||||
final ClaudeSessionOrchestrator? orchestrator;
|
||||
|
||||
/// The member name currently in inject mode (null = none).
|
||||
final String? injectingAgentId;
|
||||
|
||||
/// Shared text controller for the inject field (cleared on submit/cancel).
|
||||
final TextEditingController injectController;
|
||||
|
||||
final void Function(String memberName) onToggleInject;
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
|
||||
/// Called when the badge cycles to a new [mode] string for this member.
|
||||
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
|
||||
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
|
||||
/// Called when the fork button is tapped (T-172). The session id of the
|
||||
/// member's managed session is passed so the host can open a fork pane.
|
||||
final void Function(String memberName) onFork;
|
||||
|
||||
@override
|
||||
State<AgentRosterRow> createState() => _AgentRosterRowState();
|
||||
}
|
||||
|
||||
class _AgentRosterRowState extends State<AgentRosterRow> {
|
||||
/// Whether the bypass-confirm inline prompt is showing.
|
||||
bool _confirmingBypass = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final managed = widget.orchestrator?.byMemberName(widget.member.name);
|
||||
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
|
||||
final st = widget.status;
|
||||
final model = st?.model ?? widget.member.model;
|
||||
final sub = [
|
||||
widget.member.agentType,
|
||||
if (model != null) shortModelLabel(model),
|
||||
if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!),
|
||||
if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx',
|
||||
].join(' · ');
|
||||
|
||||
final isVisible = managed?.visible ?? true;
|
||||
final isMuted = managed?.muted ?? false;
|
||||
final isInjecting = widget.injectingAgentId == widget.member.name;
|
||||
final currentMode = st?.permissionMode ?? 'default';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Color dot
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Name + status
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
// T-181: permission-mode badge (inline below the status sub-text).
|
||||
if (managed != null)
|
||||
PermissionModeBadge(
|
||||
mode: currentMode,
|
||||
tokens: tokens,
|
||||
onCycle: () {
|
||||
final next = _nextSafeMode(currentMode);
|
||||
widget.onSetPermissionMode(widget.member.name, next);
|
||||
},
|
||||
onBypass: () => setState(() => _confirmingBypass = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Trailing controls (T-171).
|
||||
// T-172 seam: append a fork icon button to this row.
|
||||
if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting),
|
||||
],
|
||||
),
|
||||
// Bypass confirm: replaces inject field area when active.
|
||||
if (_confirmingBypass) _buildBypassConfirm(tokens),
|
||||
// Inline inject-message field — visible only when toggled.
|
||||
if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Safe-mode cycle: default → acceptEdits → plan → default (T-181).
|
||||
static String _nextSafeMode(String current) {
|
||||
const cycle = ['default', 'acceptEdits', 'plan'];
|
||||
final idx = cycle.indexOf(current);
|
||||
return cycle[(idx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
Widget _buildBypassConfirm(SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Confirm
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Confirm bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: 'Confirm',
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Cancel
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Cancel bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
child: ClideTappable(
|
||||
tooltip: 'Cancel',
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Show / hide
|
||||
MetaIconButton(
|
||||
painter: isVisible ? PhosphorIcons.byName('eye') : PhosphorIcons.byName('eye-slash'),
|
||||
tooltip: isVisible ? 'Hide pane' : 'Show pane',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
|
||||
),
|
||||
// Mute / unmute
|
||||
MetaIconButton(
|
||||
painter: isMuted ? PhosphorIcons.byName('eye-slash') : PhosphorIcons.byName('eye'),
|
||||
// NOTE: We use eye/eyeSlash as stand-ins until a dedicated speaker
|
||||
// icon is added to PhosphorIcons (no speaker codepoint yet).
|
||||
// The semantic tooltip still says mute/unmute so AT users are clear.
|
||||
tooltip: isMuted ? 'Unmute messages' : 'Mute messages',
|
||||
color: isMuted ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id),
|
||||
),
|
||||
// Inject message
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('chat-circle'),
|
||||
tooltip: 'Inject message',
|
||||
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
// Fork session (T-172): branch into a new pane without touching the original.
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('git-branch'),
|
||||
tooltip: 'Fork session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onFork(widget.member.name),
|
||||
),
|
||||
// Close session
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('x'),
|
||||
tooltip: 'Close session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onClose(widget.member.name),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInjectField(BuildContext context, SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InjectTextField(
|
||||
controller: widget.injectController,
|
||||
tokens: tokens,
|
||||
onSubmit: (text) {
|
||||
if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('x'),
|
||||
tooltip: 'Cancel',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/// The Activity / Team / Config sub-tab strip — same interaction as the pql
|
||||
/// panel's view tabs, with an underline under the active tab. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SidebarTabStrip extends StatelessWidget {
|
||||
const SidebarTabStrip({super.key, required this.current, required this.memberCount, required this.onPick});
|
||||
final SidebarTab current;
|
||||
final int memberCount;
|
||||
final ValueChanged<SidebarTab> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final t in SidebarTab.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: t == current,
|
||||
label: _label(t),
|
||||
excludeSemantics: true,
|
||||
onTap: () => onPick(t),
|
||||
child: ClideTappable(
|
||||
onTap: () => onPick(t),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: ClideText(_label(t), fontSize: clideFontSmall, color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(SidebarTab t) => switch (t) {
|
||||
SidebarTab.activity => 'Activity',
|
||||
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
|
||||
SidebarTab.config => 'Config',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/// One row in the Team tab's TASKS section: status marker + title +
|
||||
/// owner + reassign control (T-171). Split out of
|
||||
/// claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TaskRow extends StatelessWidget {
|
||||
const TaskRow({super.key, required this.task, required this.members, required this.broker});
|
||||
|
||||
final TeamTask task;
|
||||
final List<TeamMemberJoined> members;
|
||||
final TeamBroker? broker;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final marker = switch (task.status) {
|
||||
'done' => '✓',
|
||||
'claimed' => '◈',
|
||||
_ => '○',
|
||||
};
|
||||
final markerColor = switch (task.status) {
|
||||
'done' => tokens.globalTextMuted,
|
||||
'claimed' => tokens.globalFocus,
|
||||
_ => tokens.globalForeground,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(marker, fontSize: clideFontSmall, color: markerColor),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
task.title,
|
||||
fontSize: clideFontSmall,
|
||||
color: task.status == 'done' ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (task.owner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: ClideText(task.owner!, fontSize: clideFontSmall, color: tokens.globalFocus),
|
||||
),
|
||||
// Reassign: cycle to the next roster member.
|
||||
if (broker != null && broker!.members.length > 1)
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('arrow-clockwise'),
|
||||
tooltip: 'Reassign task',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => _reassign(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reassign(BuildContext context) {
|
||||
final b = broker;
|
||||
if (b == null || members.isEmpty) return;
|
||||
final brokerMembers = b.members;
|
||||
if (brokerMembers.isEmpty) return;
|
||||
// Cycle to the next member after the current owner.
|
||||
final currentIndex = brokerMembers.indexWhere((m) => m.name == task.owner);
|
||||
final nextIndex = (currentIndex + 1) % brokerMembers.length;
|
||||
b.reassignTask(task.id, brokerMembers[nextIndex].id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/// The Team tab: the roster cockpit (T-171) — per-member rows with
|
||||
/// controls, the TASKS section, and the MESSAGES chat feed (T-180).
|
||||
/// Stateless and props-driven; the parent owns the member list, inject
|
||||
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
|
||||
/// (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/task_row.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamTask;
|
||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TeamTabView extends StatelessWidget {
|
||||
const TeamTabView({
|
||||
super.key,
|
||||
required this.members,
|
||||
required this.memberStatus,
|
||||
required this.orchestrator,
|
||||
required this.tasks,
|
||||
required this.injectingAgentId,
|
||||
required this.injectController,
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
required this.onFork,
|
||||
required this.onOpenChatPane,
|
||||
});
|
||||
|
||||
final List<TeamMemberJoined> members;
|
||||
final Map<String, SessionStatus> memberStatus;
|
||||
final ClaudeSessionOrchestrator? orchestrator;
|
||||
final List<TeamTask> tasks;
|
||||
final String? injectingAgentId;
|
||||
final TextEditingController injectController;
|
||||
final void Function(String memberName) onToggleInject;
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
final void Function(String memberName) onFork;
|
||||
final VoidCallback onOpenChatPane;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (members.isEmpty) {
|
||||
return metaPlaceholder('No team active.');
|
||||
}
|
||||
final children = <Widget>[
|
||||
for (final m in members)
|
||||
AgentRosterRow(
|
||||
key: ValueKey(m.agentId),
|
||||
member: m,
|
||||
status: memberStatus[m.agentId],
|
||||
orchestrator: orchestrator,
|
||||
injectingAgentId: injectingAgentId,
|
||||
injectController: injectController,
|
||||
onToggleInject: onToggleInject,
|
||||
onInjectSubmit: onInjectSubmit,
|
||||
onClose: onClose,
|
||||
onSetPermissionMode: onSetPermissionMode,
|
||||
onFork: onFork,
|
||||
),
|
||||
];
|
||||
|
||||
if (tasks.isNotEmpty) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(_taskSection(tokens));
|
||||
}
|
||||
|
||||
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
|
||||
final chatModel = orchestrator?.chatModel;
|
||||
final broker = orchestrator?.broker;
|
||||
if (chatModel != null && broker != null) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: onOpenChatPane));
|
||||
}
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
Widget _taskSection(SurfaceTokens tokens) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('TASKS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
const SizedBox(height: 4),
|
||||
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,28 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
/// (T-269): the existing session belongs to the old repo, so it is torn down
|
||||
/// and a fresh one spawned for the new repo — a pane must never inherit
|
||||
/// another workspace's conversation.
|
||||
Future<ManagedSession> spawn(SpawnSpec spec) async {
|
||||
Future<ManagedSession> spawn(SpawnSpec spec) {
|
||||
// Serialize concurrent spawns per id (T-374): the body check-then-acts
|
||||
// on _sessions across two awaits, so two racing callers would both
|
||||
// pass the check and the loser's live claude process would be orphaned.
|
||||
// The first caller installs the future synchronously; the rest await
|
||||
// it. (A racing different-cwd spawn for the same id also coalesces —
|
||||
// the workspace-switch flow is sequential, so that pair never races.)
|
||||
final inFlight = _spawning[spec.id];
|
||||
if (inFlight != null) return inFlight;
|
||||
final f = _spawn(spec);
|
||||
_spawning[spec.id] = f;
|
||||
unawaited(
|
||||
f.then<void>((_) {}, onError: (Object _) {}).whenComplete(() {
|
||||
if (identical(_spawning[spec.id], f)) _spawning.remove(spec.id);
|
||||
}),
|
||||
);
|
||||
return f;
|
||||
}
|
||||
|
||||
final Map<String, Future<ManagedSession>> _spawning = {};
|
||||
|
||||
Future<ManagedSession> _spawn(SpawnSpec spec) async {
|
||||
final existing = _sessions[spec.id];
|
||||
if (existing != null) {
|
||||
if (existing.cwd == spec.cwd) return existing;
|
||||
|
||||
@@ -19,8 +19,11 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/src/util/value_stream.dart';
|
||||
|
||||
/// The claude subprocess, abstracted so tests drive it without spawning.
|
||||
/// Fakes `extend` this and override what they drive; the defaults below
|
||||
/// describe a process with no real child behind it.
|
||||
abstract class StreamJsonProcess {
|
||||
/// stdout, one JSON event per line.
|
||||
Stream<String> get lines;
|
||||
@@ -30,13 +33,43 @@ abstract class StreamJsonProcess {
|
||||
|
||||
/// Terminate the process.
|
||||
Future<void> kill();
|
||||
|
||||
/// The last lines of the child's stderr, drained continuously so the pipe
|
||||
/// can never fill and block the child mid-turn (T-361). Default: none.
|
||||
List<String> get stderrTail => const [];
|
||||
|
||||
/// Completes with the child's exit code, or null when there is no real
|
||||
/// process to watch (fakes that never "exit").
|
||||
Future<int>? get exitCode => null;
|
||||
}
|
||||
|
||||
/// A bounded FIFO of the most recent lines — the stderr tail kept for
|
||||
/// post-mortem diagnostics while the stream itself is drained and dropped.
|
||||
class BoundedLineBuffer {
|
||||
BoundedLineBuffer({this.cap = 100});
|
||||
|
||||
final int cap;
|
||||
final List<String> _lines = [];
|
||||
|
||||
void add(String line) {
|
||||
_lines.add(line);
|
||||
if (_lines.length > cap) _lines.removeAt(0);
|
||||
}
|
||||
|
||||
List<String> get lines => List.unmodifiable(_lines);
|
||||
}
|
||||
|
||||
/// Production [StreamJsonProcess] backed by a real `claude` process.
|
||||
class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc);
|
||||
class ClaudeStreamJsonProcess extends StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc) {
|
||||
// Drain stderr from the moment the process exists — with --verbose the
|
||||
// CLI chats on stderr, and an undrained 64KB pipe blocks the child
|
||||
// mid-turn with zero diagnostics (T-361). Keep a tail for post-mortems.
|
||||
_proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(_stderr.add, onError: (Object _) {});
|
||||
}
|
||||
|
||||
final Process _proc;
|
||||
final BoundedLineBuffer _stderr = BoundedLineBuffer();
|
||||
|
||||
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
|
||||
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
|
||||
@@ -75,6 +108,12 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
Future<void> kill() async {
|
||||
_proc.kill();
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get stderrTail => _stderr.lines;
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _proc.exitCode;
|
||||
}
|
||||
|
||||
/// An in-process MCP server clide hosts for a session, entirely over the
|
||||
@@ -194,6 +233,15 @@ final class DenyTool extends ToolDecision {
|
||||
|
||||
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
|
||||
/// answers control-channel prompts, and sends user messages.
|
||||
/// Terminal session end: the claude process exited (crash or otherwise).
|
||||
/// Carries the exit code and the drained stderr tail for diagnostics.
|
||||
class SessionEnd {
|
||||
const SessionEnd({required this.exitCode, required this.stderrTail});
|
||||
|
||||
final int exitCode;
|
||||
final List<String> stderrTail;
|
||||
}
|
||||
|
||||
class StreamJsonSession {
|
||||
StreamJsonSession(this._proc, {List<McpServer> mcpServers = const []}) : _mcpServers = mcpServers;
|
||||
|
||||
@@ -204,7 +252,9 @@ class StreamJsonSession {
|
||||
/// round-trips are answered by [_handleMcpMessage].
|
||||
final List<McpServer> _mcpServers;
|
||||
final _items = StreamController<ConversationItem>.broadcast();
|
||||
final _statusCtl = StreamController<SessionStatus>.broadcast();
|
||||
// State, not events — replay-latest so a subscriber that binds after the
|
||||
// init event still sees the current status (T-386; root cause of T-274).
|
||||
final _statusCtl = ValueStream<SessionStatus>();
|
||||
final _sessionIdCtl = StreamController<String>.broadcast();
|
||||
StreamSubscription<String>? _sub;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
@@ -237,7 +287,7 @@ class StreamJsonSession {
|
||||
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
|
||||
/// is the one currently shown in the composer zone.
|
||||
final _queue = <ToolPrompt>[];
|
||||
final _pendingCtl = StreamController<ToolPrompt?>.broadcast();
|
||||
final _pendingCtl = ValueStream<ToolPrompt?>.seeded(null);
|
||||
|
||||
/// tool_use_ids that surfaced as a prompt — the view hides their raw
|
||||
/// tool-use card while pending (it shows as a prompt) but keeps the result.
|
||||
@@ -262,7 +312,7 @@ class StreamJsonSession {
|
||||
/// Whether a turn is in flight (between a send and claude's `result`). Drives
|
||||
/// the composer's Stop affordance.
|
||||
bool _busy = false;
|
||||
final _busyCtl = StreamController<bool>.broadcast();
|
||||
final _busyCtl = ValueStream<bool>.seeded(false);
|
||||
bool get busy => _busy;
|
||||
Stream<bool> get busyStream => _busyCtl.stream;
|
||||
|
||||
@@ -299,9 +349,25 @@ class StreamJsonSession {
|
||||
/// The latest known status — the current value [statusStream] last emitted.
|
||||
SessionStatus get status => _status;
|
||||
|
||||
/// Non-null once the claude process has exited (T-361). Late binders read
|
||||
/// this; live listeners get [endedStream]. Never set by a deliberate
|
||||
/// [dispose] — only by the process dying underneath a live session.
|
||||
SessionEnd? get end => _end;
|
||||
SessionEnd? _end;
|
||||
final _endCtl = StreamController<SessionEnd>.broadcast();
|
||||
bool _disposed = false;
|
||||
|
||||
/// Fires once when the process exits while the session is still live —
|
||||
/// a crashed/dead session must not just look thoughtful (T-361).
|
||||
Stream<SessionEnd> get endedStream => _endCtl.stream;
|
||||
|
||||
/// Begin consuming the process's event stream.
|
||||
void start() {
|
||||
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
|
||||
// Watch the process itself: stdout EOF alone is ambiguous, the exit
|
||||
// code is not (T-361).
|
||||
final exit = _proc.exitCode;
|
||||
if (exit != null) unawaited(exit.then(_onExit));
|
||||
// Declaring our in-process MCP servers in the `initialize` handshake is what
|
||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
|
||||
// when we actually host a server, so a plain session is unchanged.
|
||||
@@ -570,6 +636,14 @@ class StreamJsonSession {
|
||||
}),
|
||||
);
|
||||
if (decision is AllowTool) {
|
||||
// Approving ExitPlanMode leaves plan mode. The CLI performs the
|
||||
// transition itself on the approval, so we don't send a
|
||||
// set_permission_mode control request — we just sync our tracked status
|
||||
// (exits to 'default', matching Claude Code) so the permission-mode
|
||||
// indicator and composer reflect the change (T-337).
|
||||
if (prompt.toolName == 'ExitPlanMode') {
|
||||
_mergeStatus(const SessionStatus(permissionMode: 'default'));
|
||||
}
|
||||
// The prompt card is ephemeral (it vanishes once resolved), so leave a
|
||||
// compact record of an answered question in the conversation log (D-78).
|
||||
if (prompt.isQuestion) {
|
||||
@@ -704,7 +778,23 @@ class StreamJsonSession {
|
||||
_mergeStatus(SessionStatus(permissionMode: mode));
|
||||
}
|
||||
|
||||
/// The process exited under a live session. Flip every "in flight"
|
||||
/// surface off so the pane reflects reality instead of spinning forever.
|
||||
void _onExit(int code) {
|
||||
if (_disposed || _end != null) return;
|
||||
_end = SessionEnd(exitCode: code, stderrTail: _proc.stderrTail);
|
||||
_setBusy(false);
|
||||
// A prompt pending against a dead process can never be answered —
|
||||
// clear it so the composer comes back.
|
||||
if (_queue.isNotEmpty) {
|
||||
_queue.clear();
|
||||
_pendingCtl.add(null);
|
||||
}
|
||||
_endCtl.add(_end!);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true; // deliberate teardown — suppress the exit-watch path
|
||||
await _sub?.cancel();
|
||||
await _proc.kill();
|
||||
await _items.close();
|
||||
@@ -712,5 +802,6 @@ class StreamJsonSession {
|
||||
await _sessionIdCtl.close();
|
||||
await _pendingCtl.close();
|
||||
await _busyCtl.close();
|
||||
await _endCtl.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
/// Bridges a [TranscriptReader] onto the kernel [MessageBus] (epic T-132,
|
||||
/// D-75).
|
||||
/// Bus addressing for Claude conversation content (epic T-132, D-75).
|
||||
///
|
||||
/// One reader tails a workspace transcript; this publisher republishes
|
||||
/// every [ConversationItem] as a bus [Message]. Any number of Claude
|
||||
/// panels can then subscribe to the same conversation via the bus instead
|
||||
/// of each owning its own reader — the decoupling the team panels
|
||||
/// (T-139/T-140) need, where a single observer feeds the lead tile plus a
|
||||
/// tile per teammate.
|
||||
/// The tmux-era `TranscriptPublisher` that used to live here (one reader
|
||||
/// tailing a transcript, republished onto the bus) had no production
|
||||
/// constructor calls after the stream-json pivot (D-77) and was removed
|
||||
/// in the T-385 dead-code sweep. The [ClaudeConversation] channel/key
|
||||
/// constants remain — the meta sidebar and team panel host still consume
|
||||
/// them for member-status messages.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
|
||||
/// Bus addressing for Claude conversation content.
|
||||
abstract final class ClaudeConversation {
|
||||
@@ -29,7 +25,7 @@ abstract final class ClaudeConversation {
|
||||
/// Channel for a teammate's conversation (team work, T-139/T-140).
|
||||
static String teammateChannel(String agentId) => 'conversation/$agentId';
|
||||
|
||||
/// Key under which the [ConversationItem] travels in a [Message]'s data.
|
||||
/// Key under which the [ConversationItem] travels in a bus message's data.
|
||||
static const itemKey = 'item';
|
||||
|
||||
/// Shared channel carrying each team member's live status (T-157). Every
|
||||
@@ -44,32 +40,3 @@ abstract final class ClaudeConversation {
|
||||
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
class TranscriptPublisher {
|
||||
/// Starts republishing [reader]'s items onto [messages] under
|
||||
/// [ClaudeConversation.publisher] / [channel]. The subscription is
|
||||
/// attached synchronously, so a controller that subscribes before the
|
||||
/// reader's first poll never misses the initial tail.
|
||||
TranscriptPublisher({required MessageBus messages, required TranscriptReader reader, this.channel = ClaudeConversation.leadChannel})
|
||||
: _messages = messages,
|
||||
_reader = reader {
|
||||
_sub = _reader.stream.listen((item) {
|
||||
_messages.publish(ClaudeConversation.publisher, channel, {ClaudeConversation.itemKey: item});
|
||||
});
|
||||
}
|
||||
|
||||
final MessageBus _messages;
|
||||
final TranscriptReader _reader;
|
||||
final String channel;
|
||||
late final StreamSubscription<ConversationItem> _sub;
|
||||
|
||||
/// Live session status (model / permission-mode / context) from the
|
||||
/// underlying reader — passed through for the status strip (T-145).
|
||||
Stream<SessionStatus> get statusStream => _reader.statusStream;
|
||||
|
||||
/// Stops publishing and tears down the underlying reader.
|
||||
Future<void> dispose() async {
|
||||
await _sub.cancel();
|
||||
await _reader.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class GraphView extends StatefulWidget {
|
||||
const GraphView({super.key});
|
||||
|
||||
@override
|
||||
State<GraphView> createState() => _GraphViewState();
|
||||
}
|
||||
|
||||
class _GraphViewState extends State<GraphView> {
|
||||
List<_GraphNode> _nodes = [];
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_loading || _nodes.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request(
|
||||
'pql.exec',
|
||||
args: {
|
||||
'argv': ['search', '--connections', '--limit', '50'],
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'failed to load graph';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
_nodes = list.map(_GraphNode.fromJson).toList();
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'parse error: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) {
|
||||
return const Center(child: ClideText('Loading graph...', muted: true));
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
}
|
||||
if (_nodes.isEmpty) {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _nodes.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final n = _nodes[i];
|
||||
return _NodeRow(node: n, tokens: tokens);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GraphNode {
|
||||
const _GraphNode({required this.path, this.inbound = 0, this.outbound = 0});
|
||||
final String path;
|
||||
final int inbound;
|
||||
final int outbound;
|
||||
|
||||
factory _GraphNode.fromJson(Map<String, dynamic> json) => _GraphNode(
|
||||
path: json['path'] as String? ?? json['relative_path'] as String? ?? '',
|
||||
inbound: (json['inbound_count'] as num?)?.toInt() ?? 0,
|
||||
outbound: (json['outbound_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _NodeRow extends StatelessWidget {
|
||||
const _NodeRow({required this.node, required this.tokens});
|
||||
final _GraphNode node;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(node.path, fontSize: clideFontCaption)),
|
||||
ClideText('${node.inbound}in ${node.outbound}out', color: tokens.globalTextMuted, fontSize: clideFontSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
String? _error;
|
||||
int _pid = 0;
|
||||
|
||||
/// Cached in didChangeDependencies — ancestor lookups are illegal in
|
||||
/// dispose(), and the old lookup-and-swallow there meant pane.close
|
||||
/// was never sent, leaking the backend PTY + daemon pane (T-366).
|
||||
KernelServices? _kernel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -45,6 +50,12 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _spawn());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_kernel = ClideKernel.of(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
@@ -53,21 +64,23 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
_paneId = null;
|
||||
if (id != null) {
|
||||
// Fire-and-forget. Daemon-side pane.close is idempotent.
|
||||
unawaited(_kernelIpc()?.request('pane.close', args: {'id': id}));
|
||||
unawaited(_kernel?.ipc.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
final ipc = _kernelIpc();
|
||||
final ipc = _kernel?.ipc;
|
||||
if (ipc == null || !ipc.isConnected) {
|
||||
setState(() => _error = 'Backend not connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
final shell = Platform.environment['SHELL'] ?? '/bin/bash';
|
||||
final cwd = Directory.current.path;
|
||||
// The open workspace, not Directory.current — a desktop launch starts
|
||||
// in $HOME and a project switch doesn't move the process CWD (T-381).
|
||||
final cwd = _kernel?.project.current?.path ?? Directory.current.path;
|
||||
|
||||
final response = await ipc.request(
|
||||
'pane.spawn',
|
||||
@@ -92,7 +105,7 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
}
|
||||
|
||||
void _subscribeToPaneEvents() {
|
||||
final kernel = _kernel();
|
||||
final kernel = _kernel;
|
||||
if (kernel == null) return;
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((event) {
|
||||
if (event.subsystem != 'pane') return;
|
||||
@@ -101,8 +114,9 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
case 'pane.output':
|
||||
final b64 = event.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
final bytes = base64Decode(b64);
|
||||
_terminal.write(utf8.decode(bytes, allowMalformed: true));
|
||||
// writeBytes keeps UTF-8 decode state across chunks — a rune
|
||||
// split across PTY reads must not become U+FFFD (T-373).
|
||||
_terminal.writeBytes(base64Decode(b64));
|
||||
}
|
||||
case 'pane.exit':
|
||||
setState(() => _error = 'Shell exited.');
|
||||
@@ -117,23 +131,13 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
void _onTerminalOutput(String text) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
_kernel?.ipc.request('pane.write', args: {'id': id, 'text': text});
|
||||
}
|
||||
|
||||
void _onTerminalResize(int cols, int rows, int pixelWidth, int pixelHeight) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
}
|
||||
|
||||
DaemonClient? _kernelIpc() => _kernel()?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
try {
|
||||
return ClideKernel.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
_kernel?.ipc.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -74,13 +74,16 @@ class _TipsCard extends StatelessWidget {
|
||||
const _TipsCard({required this.tokens});
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
// Every tip mirrors a binding that actually exists in the default
|
||||
// preset / contributed commands (T-383) — ctrl-based on the shipped
|
||||
// default keymap, hence ⌃ glyphs. If a binding moves, move the tip.
|
||||
static const _tips = <(String, String)>[
|
||||
('Quick open', '⌘P'),
|
||||
('Command palette', '⌘⇧P'),
|
||||
('Toggle sidebar', '⌘B'),
|
||||
('Toggle context', '⌘J'),
|
||||
('Switch theme', '⌘K ⌘T'),
|
||||
('New Claude session', '⌘⇧C'),
|
||||
('Quick open', '⌃P'),
|
||||
('Command palette', '⌃⇧P'),
|
||||
('Toggle sidebar', '⌃⇧1'),
|
||||
('Toggle context', '⌃⇧3'),
|
||||
('Find in files', '⌃⇧F'),
|
||||
('Focus mode', '⌃.'),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -169,9 +172,10 @@ class _StartColumn extends StatelessWidget {
|
||||
children: [
|
||||
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 20),
|
||||
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌘O', tokens: tokens, onTap: () => _openFolder(context)),
|
||||
_ActionRow(icon: PhosphorIcons.byName('git-branch'), label: 'Clone from git…', shortcut: '⌘G', tokens: tokens, onTap: () {}),
|
||||
_ActionRow(icon: PhosphorIcons.byName('chat-circle'), label: 'Start a Claude session', shortcut: '⌘C', tokens: tokens, onTap: () {}),
|
||||
// Only flows that exist get a tile — the old Clone-from-git and
|
||||
// Start-a-Claude-session rows were inert and advertised shortcuts
|
||||
// that were never registered (T-383). Re-add each WITH its flow.
|
||||
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌃O', tokens: tokens, onTap: () => _openFolder(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,10 @@ export 'src/pql/client.dart' show PqlClient, PqlException;
|
||||
export 'src/ipc/envelope.dart';
|
||||
export 'src/ipc/paths.dart';
|
||||
export 'src/ipc/schema_v1.dart';
|
||||
export 'src/ipc/transport.dart' show DaemonTransport, DaemonConnection, LocalSocketTransport;
|
||||
export 'src/panes/event_sink.dart';
|
||||
export 'src/panes/pane.dart' show Pane, PaneKind;
|
||||
export 'src/util/value_stream.dart' show ValueStream;
|
||||
|
||||
// clideName, clideTagline, clideVersion, clideRepository, clideCommit,
|
||||
// clideDate live in lib/src/build_info.g.dart, regenerated by every
|
||||
|
||||
@@ -27,6 +27,7 @@ export 'src/keymap/intents.dart';
|
||||
export 'src/keymap/key_chord.dart';
|
||||
export 'src/keymap/keymap.dart';
|
||||
export 'src/keymap/keymap_service.dart';
|
||||
export 'src/keymap/modifier_tap.dart';
|
||||
export 'src/keymap/sequence_matcher.dart';
|
||||
export 'src/keymap/when_clause.dart';
|
||||
export 'src/dialog.dart';
|
||||
@@ -64,3 +65,4 @@ export 'src/theme/semantic.dart';
|
||||
export 'src/theme/tokens.dart';
|
||||
export 'src/toolchain.dart';
|
||||
export 'src/window_controls.dart';
|
||||
export 'src/workspace_ref.dart';
|
||||
|
||||
@@ -144,10 +144,17 @@ class ExtensionManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
final ctx = _ExtensionContext(manager: this, id: ext.id);
|
||||
// Transactional: a throw mid-activation must leave NOTHING mounted —
|
||||
// the old path left earlier contributions live while the extension
|
||||
// recorded as failed, and a retry double-applied them (T-377).
|
||||
final applied = <ContributionPoint>[];
|
||||
var extActivated = false;
|
||||
try {
|
||||
await ext.activate(ctx);
|
||||
extActivated = true;
|
||||
for (final c in ext.contributions) {
|
||||
_applyContribution(c);
|
||||
applied.add(c);
|
||||
}
|
||||
// Eagerly load the i18n catalog for any localized tab this extension
|
||||
// contributes, so its title resolves without a "namespace not
|
||||
@@ -165,6 +172,22 @@ class ExtensionManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
log.info('extensions', 'activated $id');
|
||||
} catch (e, st) {
|
||||
for (final c in applied.reversed) {
|
||||
try {
|
||||
_removeContribution(c);
|
||||
} catch (e2) {
|
||||
log.warn('extensions', 'unwind of ${c.id} failed during $id rollback: $e2');
|
||||
}
|
||||
}
|
||||
if (extActivated) {
|
||||
// The extension's own activate() succeeded — give it the matching
|
||||
// teardown so it doesn't hold resources for a failed activation.
|
||||
try {
|
||||
await ext.deactivate();
|
||||
} catch (e2) {
|
||||
log.warn('extensions', 'deactivate during $id rollback failed: $e2');
|
||||
}
|
||||
}
|
||||
_failed[id] = e;
|
||||
log.error('extensions', 'activate failed for $id', error: e, stackTrace: st);
|
||||
notifyListeners();
|
||||
@@ -175,6 +198,17 @@ class ExtensionManager extends ChangeNotifier {
|
||||
if (!_activated.contains(id)) return;
|
||||
final ext = _known[id];
|
||||
if (ext == null) return;
|
||||
// Refuse while active extensions depend on this one — deactivating
|
||||
// underneath them leaves them running against missing services (T-377).
|
||||
// Disable the dependents first.
|
||||
final dependents = [
|
||||
for (final e in _known.values)
|
||||
if (_activated.contains(e.id) && e.dependsOn.contains(id)) e.id,
|
||||
];
|
||||
if (dependents.isNotEmpty) {
|
||||
log.warn('extensions', 'refusing to deactivate $id: active dependents: ${dependents.join(', ')}');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ext.deactivate();
|
||||
for (final c in ext.contributions) {
|
||||
@@ -196,8 +230,17 @@ class ExtensionManager extends ChangeNotifier {
|
||||
case TabContribution _:
|
||||
case StatusItemContribution _:
|
||||
case ToolbarButtonContribution _:
|
||||
// Reject duplicates instead of silently mounting a second copy —
|
||||
// benign among curated builtins, hazardous once third-party
|
||||
// extensions land (T-377). The throw rolls the activation back.
|
||||
if (panels.hasContribution(c.id)) {
|
||||
throw StateError('duplicate contribution id: ${c.id}');
|
||||
}
|
||||
panels.contribute(c);
|
||||
case CommandContribution cmd:
|
||||
if (commands.get(cmd.command) != null) {
|
||||
throw StateError('duplicate command id: ${cmd.command}');
|
||||
}
|
||||
commands.register(cmd);
|
||||
final binding = cmd.defaultBinding;
|
||||
if (binding != null) {
|
||||
|
||||
@@ -144,7 +144,7 @@ class KernelServices {
|
||||
final messages = MessageBus();
|
||||
final filterStates = FilterStateCache(messages: messages);
|
||||
|
||||
final settings = SettingsStore(appDir: appDir);
|
||||
final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m));
|
||||
await settings.load();
|
||||
|
||||
final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
|
||||
@@ -166,7 +166,7 @@ class KernelServices {
|
||||
final readerNav = ReaderNavRegistry(messages);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
final notify = Notifications();
|
||||
final notify = Notifications(messages: messages);
|
||||
final dialog = DialogRouter();
|
||||
final tray = TrayRegistry();
|
||||
final secrets = SecretsVault();
|
||||
@@ -191,7 +191,7 @@ class KernelServices {
|
||||
isolateClient ??
|
||||
(daemonClientFactory != null
|
||||
? daemonClientFactory(log, events, arrangement, panels)
|
||||
: DaemonClient(
|
||||
: DaemonClient.unixSocket(
|
||||
// Legacy socket-client fallback — kept until T-127
|
||||
// replaces it with the in-process socket loopback.
|
||||
// Today nothing in production hits this branch
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
@@ -10,14 +8,24 @@ import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class DaemonClient extends ChangeNotifier {
|
||||
DaemonClient({required String socketPath, required Logger log, required DaemonBus events}) : _socketPath = socketPath, _log = log, _events = events;
|
||||
/// Connects through [transport] (T-331). The local app passes a
|
||||
/// [LocalSocketTransport]; a remote workspace will pass an SSH-backed
|
||||
/// transport without this class changing.
|
||||
DaemonClient({required DaemonTransport transport, required Logger log, required DaemonBus events}) : _transport = transport, _log = log, _events = events;
|
||||
|
||||
String _socketPath;
|
||||
String get socketPath => _socketPath;
|
||||
/// Convenience for the local unix-socket path — today's only
|
||||
/// production shape.
|
||||
DaemonClient.unixSocket({required String socketPath, required Logger log, required DaemonBus events})
|
||||
: this(transport: LocalSocketTransport(socketPath), log: log, events: events);
|
||||
|
||||
DaemonTransport _transport;
|
||||
|
||||
/// The backend endpoint description — the unix socket path locally.
|
||||
String get socketPath => _transport.endpoint;
|
||||
final Logger _log;
|
||||
final DaemonBus _events;
|
||||
|
||||
Socket? _socket;
|
||||
DaemonConnection? _conn;
|
||||
bool _connected = false;
|
||||
bool _disposed = false;
|
||||
bool _started = false;
|
||||
@@ -48,30 +56,33 @@ class DaemonClient extends ChangeNotifier {
|
||||
_started = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
final s = _socket;
|
||||
_socket = null;
|
||||
await s?.close();
|
||||
final c = _conn;
|
||||
_conn = null;
|
||||
await c?.close();
|
||||
_failPending('client stopped');
|
||||
_wakeConnectWaiters();
|
||||
_setConnected(false);
|
||||
}
|
||||
|
||||
/// Point the client at a different socket path and reconnect.
|
||||
/// Point the client at a different local socket path and reconnect.
|
||||
/// Used on project switch — the workspace-derived socket path
|
||||
/// (D-70) changes when the user opens a different project, so the
|
||||
/// client follows. Cancels the reconnect timer, closes the live
|
||||
/// socket (failing in-flight requests with `disconnect`), updates
|
||||
/// the path, and re-arms the connect loop. Idempotent if the new
|
||||
/// path equals the current one.
|
||||
Future<void> reconnectAt(String newPath) async {
|
||||
if (newPath == _socketPath && _connected) return;
|
||||
_socketPath = newPath;
|
||||
/// client follows. Sugar over [reconnectWith].
|
||||
Future<void> reconnectAt(String newPath) => reconnectWith(LocalSocketTransport(newPath));
|
||||
|
||||
/// Swap the backend transport and reconnect. Cancels the reconnect
|
||||
/// timer, closes the live connection (failing in-flight requests with
|
||||
/// `disconnect`), swaps the transport, and re-arms the connect loop.
|
||||
/// Idempotent if the new endpoint equals the current connected one.
|
||||
Future<void> reconnectWith(DaemonTransport transport) async {
|
||||
if (transport.endpoint == _transport.endpoint && _connected) return;
|
||||
_transport = transport;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
final s = _socket;
|
||||
_socket = null;
|
||||
await s?.close();
|
||||
_failPending('socket path changed');
|
||||
final c = _conn;
|
||||
_conn = null;
|
||||
await c?.close();
|
||||
_failPending('backend endpoint changed');
|
||||
_setConnected(false);
|
||||
_disposed = false;
|
||||
_started = true;
|
||||
@@ -80,7 +91,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) async {
|
||||
if (!_connected || _socket == null) {
|
||||
if (!_connected || _conn == null) {
|
||||
// A connection attempt is in flight (startup or reconnect) — wait
|
||||
// for it rather than failing instantly, so queries issued during
|
||||
// the startup window don't get a spurious not-connected error.
|
||||
@@ -88,7 +99,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
if (_started && !_disposed) {
|
||||
await _awaitConnected(_connectWait);
|
||||
}
|
||||
if (!_connected || _socket == null) {
|
||||
if (!_connected || _conn == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'daemon not connected'),
|
||||
@@ -99,7 +110,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
final completer = Completer<IpcResponse>();
|
||||
_pending[id] = completer;
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
_socket!.writeln(req.encode());
|
||||
_conn!.writeLine(req.encode());
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@@ -126,30 +137,25 @@ class DaemonClient extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
// Already connected? Don't open a second socket. Guards against
|
||||
// Already connected? Don't open a second connection. Guards against
|
||||
// racing connect attempts (e.g. start() arming the reconnect loop
|
||||
// while swapIpcServer's reconnectAt connects on first boot).
|
||||
// while swapBackend's reconnectAt connects on first boot).
|
||||
if (_disposed || _connected) return;
|
||||
try {
|
||||
final addr = InternetAddress(_socketPath, type: InternetAddressType.unix);
|
||||
final socket = await Socket.connect(addr, 0);
|
||||
_socket = socket;
|
||||
final conn = await _transport.open();
|
||||
_conn = conn;
|
||||
_backoff = const Duration(milliseconds: 200);
|
||||
_setConnected(true);
|
||||
_log.info('ipc', 'connected to $_socketPath');
|
||||
socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
_handleLine,
|
||||
onDone: _handleDisconnect,
|
||||
onError: (Object e) {
|
||||
_log.warn('ipc', 'socket error', error: e);
|
||||
_handleDisconnect();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
_log.info('ipc', 'connected to ${_transport.endpoint}');
|
||||
conn.lines.listen(
|
||||
_handleLine,
|
||||
onDone: _handleDisconnect,
|
||||
onError: (Object e) {
|
||||
_log.warn('ipc', 'socket error', error: e);
|
||||
_handleDisconnect();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.debug('ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
|
||||
_scheduleReconnect();
|
||||
@@ -175,7 +181,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleDisconnect() {
|
||||
_socket = null;
|
||||
_conn = null;
|
||||
_failPending('daemon disconnected');
|
||||
_setConnected(false);
|
||||
_scheduleReconnect();
|
||||
@@ -218,8 +224,9 @@ class DaemonClient extends ChangeNotifier {
|
||||
_disposed = true;
|
||||
_started = false;
|
||||
_reconnectTimer?.cancel();
|
||||
unawaited(_socket?.close());
|
||||
_socket = null;
|
||||
final c = _conn;
|
||||
if (c != null) unawaited(c.close());
|
||||
_conn = null;
|
||||
_failPending('client disposed');
|
||||
_wakeConnectWaiters();
|
||||
super.dispose();
|
||||
|
||||
@@ -43,9 +43,34 @@ class KeyChord {
|
||||
|
||||
const KeyChord._(this.modifiers, this.key);
|
||||
|
||||
/// A bare modifier press as a chord — no modifier set, the modifier key
|
||||
/// itself as the base key. Lets a preset bind `shift` (and a double-tap
|
||||
/// as the sequence `shift shift`, e.g. JetBrains "Search Everywhere").
|
||||
/// (T-341)
|
||||
factory KeyChord.bareModifier(KeyModifier m) => KeyChord(key: _modifierKey[m]!);
|
||||
|
||||
final List<KeyModifier> modifiers;
|
||||
final LogicalKeyboardKey key;
|
||||
|
||||
/// The [KeyModifier] a bare modifier-key press maps to (left/right/generic
|
||||
/// variants collapse to one), or null if [logical] isn't a modifier key.
|
||||
/// Used by the global handler's double-tap detector. (T-341)
|
||||
static KeyModifier? modifierForLogicalKey(LogicalKeyboardKey logical) {
|
||||
if (logical == LogicalKeyboardKey.control || logical == LogicalKeyboardKey.controlLeft || logical == LogicalKeyboardKey.controlRight) {
|
||||
return KeyModifier.ctrl;
|
||||
}
|
||||
if (logical == LogicalKeyboardKey.alt || logical == LogicalKeyboardKey.altLeft || logical == LogicalKeyboardKey.altRight) {
|
||||
return KeyModifier.alt;
|
||||
}
|
||||
if (logical == LogicalKeyboardKey.shift || logical == LogicalKeyboardKey.shiftLeft || logical == LogicalKeyboardKey.shiftRight) {
|
||||
return KeyModifier.shift;
|
||||
}
|
||||
if (logical == LogicalKeyboardKey.meta || logical == LogicalKeyboardKey.metaLeft || logical == LogicalKeyboardKey.metaRight) {
|
||||
return KeyModifier.meta;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build from a Flutter [KeyEvent]. Returns null for non-down events
|
||||
/// or events whose logical key has no meaningful id (e.g. a bare
|
||||
/// modifier press in isolation).
|
||||
@@ -235,9 +260,30 @@ const List<LogicalKeyboardKey> _digitKeys = [
|
||||
LogicalKeyboardKey.digit9,
|
||||
];
|
||||
|
||||
LogicalKeyboardKey? _keyByName(String name) => _byName[name.toLowerCase()];
|
||||
/// Canonical logical key for each bare modifier (left/right variants
|
||||
/// collapse to the side-agnostic key). Drives [KeyChord.bareModifier] and
|
||||
/// the `shift` / `ctrl` / `alt` / `meta` base-key names. (T-341)
|
||||
const Map<KeyModifier, LogicalKeyboardKey> _modifierKey = {
|
||||
KeyModifier.ctrl: LogicalKeyboardKey.control,
|
||||
KeyModifier.alt: LogicalKeyboardKey.alt,
|
||||
KeyModifier.shift: LogicalKeyboardKey.shift,
|
||||
KeyModifier.meta: LogicalKeyboardKey.meta,
|
||||
};
|
||||
|
||||
LogicalKeyboardKey? _keyByName(String name) {
|
||||
final n = name.toLowerCase();
|
||||
// A bare modifier name as the base key (`shift`, `ctrl`, `cmd`, …) — so
|
||||
// `parseSequence('shift shift')` yields a double-tap binding. (T-341)
|
||||
final mod = _modByName(n);
|
||||
if (mod != null) return _modifierKey[mod];
|
||||
return _byName[n];
|
||||
}
|
||||
|
||||
String _keyName(LogicalKeyboardKey key) {
|
||||
// Bare-modifier keys reverse to their canonical modifier name.
|
||||
for (final entry in _modifierKey.entries) {
|
||||
if (entry.value == key) return entry.key.yaml;
|
||||
}
|
||||
// Reverse lookup; prefer the canonical (first) name for each key.
|
||||
for (final entry in _byName.entries) {
|
||||
if (entry.value == key) return entry.key;
|
||||
|
||||
@@ -157,20 +157,47 @@ class KeymapService extends ChangeNotifier {
|
||||
return km.resolve(chord, _scope);
|
||||
}
|
||||
|
||||
/// Resolve a complete chord [sequence] (e.g. a double-tapped modifier,
|
||||
/// `[shift, shift]`) against the active keymap and current scope. Returns
|
||||
/// the bound intent only on an exact full-sequence match, else null.
|
||||
/// Used by the global handler's double-tap detector (T-341).
|
||||
Intent? resolveSequence(List<KeyChord> sequence) {
|
||||
final km = _active;
|
||||
if (km == null) return null;
|
||||
return km.match(sequence, _scope).exact;
|
||||
}
|
||||
|
||||
/// Scope-flag producers clear their flags from widget dispose() — which
|
||||
/// during app teardown runs AFTER KernelServices.dispose() has disposed
|
||||
/// this notifier. Tolerate that ordering instead of asserting (the same
|
||||
/// fire-and-forget pattern SettingsStore uses).
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _safeNotify() {
|
||||
if (_disposed) return;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set a named scope flag. Producers should call this when their
|
||||
/// state changes so when-clauses re-evaluate correctly. Notifies
|
||||
/// listeners when the value actually changes.
|
||||
void setScopeFlag(String name, bool value) {
|
||||
if (_scope[name] == value) return;
|
||||
_scope[name] = value;
|
||||
notifyListeners();
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Clear a named scope flag.
|
||||
void clearScopeFlag(String name) {
|
||||
if (!_scope.containsKey(name)) return;
|
||||
_scope.remove(name);
|
||||
notifyListeners();
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Switch presets. Persists the new preset name to settings and
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/// Detects a double-tapped bare modifier (e.g. JetBrains "Search
|
||||
/// Everywhere" = double-Shift). (T-341)
|
||||
///
|
||||
/// Headless and clock-injected: the caller (the global key handler) passes
|
||||
/// the event time so it neither reads a clock nor consumes events. Feed it
|
||||
/// every [KeyDownEvent]: a bare modifier press via [tap], any other key via
|
||||
/// [reset] (an intervening key breaks the gesture, e.g. `Shift a Shift`).
|
||||
library;
|
||||
|
||||
import 'key_chord.dart';
|
||||
|
||||
class ModifierTapTracker {
|
||||
ModifierTapTracker({this.window = const Duration(milliseconds: 350)});
|
||||
|
||||
/// Max gap between the two taps to count as a double-tap.
|
||||
final Duration window;
|
||||
|
||||
KeyModifier? _last;
|
||||
DateTime? _lastAt;
|
||||
|
||||
/// Record a bare-modifier press at [now]. Returns the modifier when this
|
||||
/// press completes a double-tap of the *same* modifier within [window];
|
||||
/// otherwise records it as the first tap and returns null.
|
||||
KeyModifier? tap(KeyModifier m, DateTime now) {
|
||||
final last = _last;
|
||||
final lastAt = _lastAt;
|
||||
if (last == m && lastAt != null) {
|
||||
final gap = now.difference(lastAt);
|
||||
if (gap >= Duration.zero && gap <= window) {
|
||||
reset();
|
||||
return m;
|
||||
}
|
||||
}
|
||||
_last = m;
|
||||
_lastAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Break the gesture — any non-modifier key press resets the tracker.
|
||||
void reset() {
|
||||
_last = null;
|
||||
_lastAt = null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/toast.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum NotificationLevel { info, warning, error, success }
|
||||
@@ -18,6 +20,14 @@ class ClideNotification {
|
||||
}
|
||||
|
||||
class Notifications extends ChangeNotifier {
|
||||
Notifications({MessageBus? messages}) : _messages = messages;
|
||||
|
||||
/// When wired (the facade passes the kernel bus), every notification is
|
||||
/// also published to the toast channel so it actually renders — the
|
||||
/// in-memory list had zero widget consumers and messages vanished
|
||||
/// silently (T-382).
|
||||
final MessageBus? _messages;
|
||||
|
||||
final List<ClideNotification> _active = [];
|
||||
final Map<String, Timer> _timers = {};
|
||||
int _seq = 0;
|
||||
@@ -41,6 +51,21 @@ class Notifications extends ChangeNotifier {
|
||||
final n = ClideNotification(id: id, level: level, message: message, title: title, duration: duration ?? const Duration(seconds: 4));
|
||||
_active.add(n);
|
||||
_timers[id] = Timer(n.duration, () => dismiss(id));
|
||||
final bus = _messages;
|
||||
if (bus != null) {
|
||||
publishToast(
|
||||
bus,
|
||||
'kernel.notify',
|
||||
title == null ? message : '$title — $message',
|
||||
severity: switch (level) {
|
||||
NotificationLevel.info => ToastSeverity.info,
|
||||
NotificationLevel.warning => ToastSeverity.warning,
|
||||
NotificationLevel.error => ToastSeverity.error,
|
||||
NotificationLevel.success => ToastSeverity.success,
|
||||
},
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ class PanelRegistry extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Whether any slot already mounts a contribution with [id]. Used by the
|
||||
/// extension manager to reject duplicate ids instead of silently mounting
|
||||
/// a second copy (T-377).
|
||||
bool hasContribution(String id) => _mounts.values.any((list) => list.any((c) => c.id == id));
|
||||
|
||||
void contribute(ContributionPoint point) {
|
||||
final slot = point.slot;
|
||||
if (slot == null) return;
|
||||
|
||||
@@ -6,10 +6,20 @@ import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/kernel/src/workspace_ref.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class RecentProject {
|
||||
const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened, this.startupSticky = false});
|
||||
const RecentProject({
|
||||
required this.path,
|
||||
required this.name,
|
||||
this.branch,
|
||||
required this.lastOpened,
|
||||
this.startupSticky = false,
|
||||
this.host,
|
||||
this.port,
|
||||
this.user,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String name;
|
||||
@@ -21,12 +31,27 @@ class RecentProject {
|
||||
/// opens it directly; otherwise the welcome screen takes over (T-115).
|
||||
final bool startupSticky;
|
||||
|
||||
/// Remote workspace identity (T-332/T-329): the SSH host (or
|
||||
/// `~/.ssh/config` alias) the repo lives on. Absent = local — older
|
||||
/// persisted recents deserialize as local automatically.
|
||||
final String? host;
|
||||
final int? port;
|
||||
final String? user;
|
||||
|
||||
bool get isRemote => host != null;
|
||||
|
||||
/// This recent's location as a [WorkspaceRef].
|
||||
WorkspaceRef get ref => host == null ? WorkspaceRef.local(path) : WorkspaceRef.remote(host: host!, path: path, port: port, user: user);
|
||||
|
||||
RecentProject copyWith({bool? startupSticky, DateTime? lastOpened, String? branch}) => RecentProject(
|
||||
path: path,
|
||||
name: name,
|
||||
branch: branch ?? this.branch,
|
||||
lastOpened: lastOpened ?? this.lastOpened,
|
||||
startupSticky: startupSticky ?? this.startupSticky,
|
||||
host: host,
|
||||
port: port,
|
||||
user: user,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -35,6 +60,9 @@ class RecentProject {
|
||||
'branch': branch,
|
||||
'lastOpened': lastOpened.toIso8601String(),
|
||||
if (startupSticky) 'startupSticky': true,
|
||||
if (host != null) 'host': host,
|
||||
if (port != null) 'port': port,
|
||||
if (user != null) 'user': user,
|
||||
};
|
||||
|
||||
factory RecentProject.fromJson(Map<String, dynamic> json) => RecentProject(
|
||||
@@ -43,9 +71,13 @@ class RecentProject {
|
||||
branch: json['branch'] as String?,
|
||||
lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(),
|
||||
startupSticky: json['startupSticky'] as bool? ?? false,
|
||||
host: json['host'] as String?,
|
||||
port: json['port'] as int?,
|
||||
user: json['user'] as String?,
|
||||
);
|
||||
|
||||
String get relativePath {
|
||||
if (isRemote) return '$host:$path';
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
if (home.isNotEmpty && path.startsWith(home)) return '~${path.substring(home.length)}';
|
||||
return path;
|
||||
|
||||
@@ -6,11 +6,16 @@ import 'package:yaml/yaml.dart';
|
||||
enum SettingsScope { app, project, ext }
|
||||
|
||||
class SettingsStore extends ChangeNotifier {
|
||||
SettingsStore({required this.appDir, this.projectDir});
|
||||
SettingsStore({required this.appDir, this.projectDir, this.onError});
|
||||
|
||||
final Directory appDir;
|
||||
Directory? projectDir;
|
||||
|
||||
/// Surfaces load/parse problems (wired to the kernel Logger by the
|
||||
/// facade). A parse failure must not pass silently — it used to reset
|
||||
/// every setting on the next write (T-376).
|
||||
final void Function(String message)? onError;
|
||||
|
||||
final Map<String, Object?> _appValues = <String, Object?>{};
|
||||
final Map<String, Object?> _projectValues = <String, Object?>{};
|
||||
|
||||
@@ -93,17 +98,29 @@ class SettingsStore extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readFile(File f) async {
|
||||
String txt;
|
||||
try {
|
||||
if (!await f.exists()) return <String, Object?>{};
|
||||
final txt = await f.readAsString();
|
||||
if (txt.trim().isEmpty) return <String, Object?>{};
|
||||
txt = await f.readAsString();
|
||||
} catch (_) {
|
||||
// On web (or in sandboxes where the path isn't readable) silently
|
||||
// degrade to an empty in-memory catalog. `set` will no-op too.
|
||||
return <String, Object?>{};
|
||||
}
|
||||
if (txt.trim().isEmpty) return <String, Object?>{};
|
||||
try {
|
||||
final yaml = loadYaml(txt);
|
||||
final out = <String, Object?>{};
|
||||
if (yaml is Map) _flatten(yaml, '', out);
|
||||
return out;
|
||||
} catch (_) {
|
||||
// On web (or in sandboxes where the path isn't writable) silently
|
||||
// degrade to an empty in-memory catalog. `set` will no-op too.
|
||||
} catch (e) {
|
||||
// A parse failure must not silently reset the user's settings — the
|
||||
// next `set` overwrites the file with the (now empty) in-memory map.
|
||||
// Preserve the original for recovery and say so (T-376).
|
||||
try {
|
||||
await File('${f.path}.broken').writeAsString(txt);
|
||||
} catch (_) {}
|
||||
onError?.call('failed to parse ${f.path}: $e — original preserved at ${f.path}.broken');
|
||||
return <String, Object?>{};
|
||||
}
|
||||
}
|
||||
@@ -111,7 +128,11 @@ class SettingsStore extends ChangeNotifier {
|
||||
Future<void> _writeFile(File f, Map<String, Object?> flat) async {
|
||||
try {
|
||||
await f.parent.create(recursive: true);
|
||||
await f.writeAsString(_emitYaml(_unflatten(flat)));
|
||||
// Temp-file + rename: a crash mid-write must not truncate the live
|
||||
// settings file (T-376).
|
||||
final tmp = File('${f.path}.tmp');
|
||||
await tmp.writeAsString(_emitYaml(_unflatten(flat)));
|
||||
await tmp.rename(f.path);
|
||||
} catch (_) {
|
||||
// Web / read-only sandbox: in-memory update remains valid, we
|
||||
// just can't persist. Callers already called notifyListeners.
|
||||
@@ -214,6 +235,20 @@ void _emitScalar(StringBuffer buf, Object? v) {
|
||||
_emitScalar(buf, v[i]);
|
||||
}
|
||||
buf.write(']');
|
||||
} else if (v is Map) {
|
||||
// YAML flow mapping — maps nested inside lists (e.g. keymap overlay
|
||||
// entries) used to fall through to toString() and corrupt on the
|
||||
// next read (T-376).
|
||||
buf.write('{');
|
||||
var first = true;
|
||||
v.forEach((k, vv) {
|
||||
if (!first) buf.write(', ');
|
||||
first = false;
|
||||
_emitScalar(buf, '$k');
|
||||
buf.write(': ');
|
||||
_emitScalar(buf, vv);
|
||||
});
|
||||
buf.write('}');
|
||||
} else {
|
||||
buf.write('"${v.toString()}"');
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../src/pty/env.dart';
|
||||
|
||||
class ToolCheck extends ChangeNotifier {
|
||||
bool pqlOk = false;
|
||||
bool tmuxOk = false;
|
||||
bool gitOk = false;
|
||||
bool checked = false;
|
||||
|
||||
bool get allOk => pqlOk && tmuxOk && gitOk;
|
||||
|
||||
List<String> get errors => [if (!pqlOk) 'pql not found', if (!tmuxOk) 'tmux not found', if (!gitOk) 'git not found'];
|
||||
|
||||
/// Workspace root, set by the app at boot. Falls back to cwd.
|
||||
static String? workspaceRoot;
|
||||
|
||||
Future<void> check() async {
|
||||
pqlOk = _existsOnPath('pql');
|
||||
tmuxOk = _existsOnPath('tmux');
|
||||
gitOk = _existsOnPath('git');
|
||||
checked = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if [name] exists as an executable in any PATH directory.
|
||||
/// Uses direct file-existence checks — works inside a macOS sandbox
|
||||
/// without needing to exec `which`.
|
||||
static bool _existsOnPath(String name) {
|
||||
for (final dir in expandedPath.split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
if (File('$dir/$name').existsSync()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/// WorkspaceRef (T-332): where a workspace lives — a local repo root or
|
||||
/// a repo on a remote host reached over SSH (T-329).
|
||||
///
|
||||
/// The remote form is written `ssh://[user@]host[:port]/abs/remote/path`
|
||||
/// (host may be a `~/.ssh/config` alias — resolution happens at connect
|
||||
/// time, not here). A bare string with no scheme is a local path.
|
||||
library;
|
||||
|
||||
/// A reference to a workspace root. Immutable value type.
|
||||
class WorkspaceRef {
|
||||
const WorkspaceRef.local(this.path) : host = null, port = null, user = null;
|
||||
|
||||
const WorkspaceRef.remote({required String this.host, required this.path, this.port, this.user});
|
||||
|
||||
/// Remote host (or `~/.ssh/config` alias). Null means local.
|
||||
final String? host;
|
||||
|
||||
/// SSH port; null means the ssh default / config-resolved port.
|
||||
final int? port;
|
||||
|
||||
/// SSH user; null means the local username / config-resolved user.
|
||||
final String? user;
|
||||
|
||||
/// Absolute workspace path — on [host] when remote, locally otherwise.
|
||||
final String path;
|
||||
|
||||
bool get isRemote => host != null;
|
||||
|
||||
/// Parse either a plain local path or an `ssh://` URI. Returns null
|
||||
/// for a malformed `ssh://` form (no host, or no absolute path).
|
||||
static WorkspaceRef? parse(String input) {
|
||||
if (!input.startsWith('ssh://')) return WorkspaceRef.local(input);
|
||||
final Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(input);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
if (uri.host.isEmpty || uri.path.isEmpty || uri.path == '/') return null;
|
||||
return WorkspaceRef.remote(host: uri.host, path: uri.path, port: uri.hasPort ? uri.port : null, user: uri.userInfo.isEmpty ? null : uri.userInfo);
|
||||
}
|
||||
|
||||
/// The canonical string form: the bare path locally, the full
|
||||
/// `ssh://` URI remotely. `parse(uri) == ref` round-trips.
|
||||
String get uri {
|
||||
if (!isRemote) return path;
|
||||
final auth = user == null ? host! : '$user@$host';
|
||||
final p = port == null ? '' : ':$port';
|
||||
return 'ssh://$auth$p$path';
|
||||
}
|
||||
|
||||
/// Compact human form for recents/switcher rows: `host:path` remotely
|
||||
/// (e.g. `buildbox:/srv/repo`), the bare path locally.
|
||||
String get display => isRemote ? '$host:$path' : path;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is WorkspaceRef && other.host == host && other.port == port && other.user == user && other.path == path;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(host, port, user, path);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkspaceRef($uri)';
|
||||
}
|
||||
+48
-14
@@ -132,11 +132,17 @@ Future<void> main() async {
|
||||
McpServer? mcpServer;
|
||||
final ipcLog = Logger();
|
||||
|
||||
// IPC-server swaps must run one-at-a-time — see the swapIpcServer wrapper
|
||||
// below doSwapIpcServer for why. (T-352)
|
||||
// Backend swaps must run one-at-a-time — see the swapBackend wrapper
|
||||
// below doSwapBackend for why. (T-352)
|
||||
Future<void> swapChain = Future<void>.value();
|
||||
|
||||
Future<void> doSwapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
|
||||
// Teardown of the service set behind the currently-served dispatcher
|
||||
// (pane PTYs, file watcher, in-flight searches, editor buffers). Swapped
|
||||
// alongside the IPC server so a project switch can't leak the previous
|
||||
// workspace's watchers into the new one's bus (T-367).
|
||||
Future<void> Function()? activeSubsystemTeardown;
|
||||
|
||||
Future<void> doSwapBackend(DaemonDispatcher dispatcher, Future<void> Function() teardown, Directory workRoot) async {
|
||||
if (kIsWeb) return;
|
||||
// Already serving this exact workspace? Reuse the live server.
|
||||
// The startup factory binds the launch CWD, then the project-open
|
||||
@@ -148,6 +154,9 @@ Future<void> main() async {
|
||||
final live = ipcServer;
|
||||
if (live != null && live.isRunning && live.workspaceRoot == workRoot.path) {
|
||||
ipcLog.info('ipc', 'already serving ${workRoot.path}; reusing the live server');
|
||||
// The freshly built dispatcher is dropped unused — its services are
|
||||
// inert (watchers/PTYs only start via dispatched commands), so there
|
||||
// is nothing to tear down. The live server keeps its own set.
|
||||
// Idempotent — a no-op when the client is already connected here.
|
||||
await ipcClient?.reconnectAt(live.socketPath);
|
||||
return;
|
||||
@@ -163,6 +172,16 @@ Future<void> main() async {
|
||||
} catch (e) {
|
||||
ipcLog.warn('mcp', 'stop failed during swap: $e');
|
||||
}
|
||||
// The old server is down — release the previous workspace's services
|
||||
// before the new set takes over (T-367). The shutdown() methods are
|
||||
// idempotent, so a failed swap retried later is safe.
|
||||
try {
|
||||
await activeSubsystemTeardown?.call();
|
||||
} catch (e, st) {
|
||||
ipcLog.warn('ipc', 'subsystem teardown failed during swap: $e');
|
||||
ipcLog.debug('ipc', '$st');
|
||||
}
|
||||
activeSubsystemTeardown = teardown;
|
||||
final server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot.path, log: ipcLog, events: daemonBus);
|
||||
ipcServer = server;
|
||||
try {
|
||||
@@ -197,14 +216,20 @@ Future<void> main() async {
|
||||
// load (stale/global pql.db) yet working after a manual refresh. Chaining
|
||||
// every swap makes them apply in call order; the repo swap is issued last
|
||||
// and therefore wins. (T-352)
|
||||
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) {
|
||||
final next = swapChain.then((_) => doSwapIpcServer(dispatcher, workRoot));
|
||||
Future<void> swapBackend(DaemonDispatcher dispatcher, Future<void> Function() teardown, Directory workRoot) {
|
||||
final next = swapChain.then((_) => doSwapBackend(dispatcher, teardown, workRoot));
|
||||
// A failed swap must not break the chain for the next one.
|
||||
swapChain = next.catchError((Object _) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
DaemonDispatcher buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot, LayoutArrangement arrangement, PanelRegistry panels) {
|
||||
(DaemonDispatcher, Future<void> Function()) buildDispatcher(
|
||||
DaemonBus events,
|
||||
Toolchain tc,
|
||||
Directory workRoot,
|
||||
LayoutArrangement arrangement,
|
||||
PanelRegistry panels,
|
||||
) {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _BusEventSink(events);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
@@ -297,7 +322,16 @@ Future<void> main() async {
|
||||
};
|
||||
});
|
||||
registerArgvUnwrap(dispatcher);
|
||||
return dispatcher;
|
||||
// Paired teardown for this workspace's stateful services — the swap
|
||||
// calls it when this dispatcher stops being served (T-367).
|
||||
Future<void> teardown() async {
|
||||
await paneRegistry.shutdown();
|
||||
await filesService.shutdown();
|
||||
await searchService.shutdown();
|
||||
await editorRegistry.shutdown();
|
||||
}
|
||||
|
||||
return (dispatcher, teardown);
|
||||
}
|
||||
|
||||
final services = await KernelServices.boot(
|
||||
@@ -314,22 +348,22 @@ Future<void> main() async {
|
||||
kernelArrangement = arrangement;
|
||||
kernelPanels = panels;
|
||||
final workRoot = startupWorkRoot;
|
||||
final dispatcher = buildDispatcher(events, toolchain, workRoot, arrangement, panels);
|
||||
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels);
|
||||
// Build the client at the workspace's socket path. The
|
||||
// server is started below (swapIpcServer) which the
|
||||
// server is started below (swapBackend) which the
|
||||
// client will then auto-connect to via its reconnect
|
||||
// loop. autoStartDaemonClient:false means we own the
|
||||
// lifecycle here.
|
||||
final client = DaemonClient(socketPath: workspaceSocketPath(workRoot.path), log: log, events: events);
|
||||
final client = DaemonClient.unixSocket(socketPath: workspaceSocketPath(workRoot.path), log: log, events: events);
|
||||
ipcClient = client;
|
||||
// start() synchronously marks the client "connecting" (so
|
||||
// requests issued during the startup window park for the
|
||||
// socket instead of failing) and arms the reconnect loop.
|
||||
// swapIpcServer then binds the server and reconnectAt makes
|
||||
// swapBackend then binds the server and reconnectAt makes
|
||||
// the connect immediate. _connect's already-connected guard
|
||||
// keeps these two paths from opening a second socket.
|
||||
unawaited(client.start());
|
||||
unawaited(swapIpcServer(dispatcher, workRoot));
|
||||
unawaited(swapBackend(dispatcher, teardown, workRoot));
|
||||
return client;
|
||||
},
|
||||
onProjectOpen: kIsWeb
|
||||
@@ -339,8 +373,8 @@ Future<void> main() async {
|
||||
final arrangement = kernelArrangement;
|
||||
final panels = kernelPanels;
|
||||
if (bus == null || arrangement == null || panels == null) return;
|
||||
final dispatcher = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels);
|
||||
await swapIpcServer(dispatcher, Directory(path));
|
||||
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels);
|
||||
await swapBackend(dispatcher, teardown, Directory(path));
|
||||
},
|
||||
);
|
||||
// Expose the reader nav to the `clide status` snapshot (T-221). Boot
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'dart:io' show FileSystemException;
|
||||
|
||||
import '../editor/buffer.dart' show Selection;
|
||||
import '../editor/registry.dart';
|
||||
import '../files/path_safety.dart' show PathOutsideRoot;
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/errno_mapping.dart';
|
||||
@@ -84,6 +85,13 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
|
||||
r.setSelection(buf.id, Selection.collapsed(_offsetForLine(buf.content, line)));
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: buf.toJson());
|
||||
} on PathOutsideRoot {
|
||||
// Same containment contract as files.read (T-363); a buffer is a
|
||||
// write surface, so no D-80 extra-root widening here.
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'),
|
||||
);
|
||||
} on FileSystemException catch (e) {
|
||||
final errno = e.osError?.errorCode;
|
||||
if (errno != null) {
|
||||
@@ -190,7 +198,17 @@ Future<IpcResponse> _setContent(IpcRequest req, EditorRegistry r) async {
|
||||
Future<IpcResponse> _save(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
final ok = await r.save(id);
|
||||
final bool ok;
|
||||
try {
|
||||
ok = await r.save(id);
|
||||
} on PathOutsideRoot {
|
||||
// Defense in depth — open already validates, but a symlink can be
|
||||
// swapped in under the buffer's path between open and save (T-363).
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace'),
|
||||
);
|
||||
}
|
||||
if (!ok) return _notFound(req.id, 'no such buffer: $id');
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'saved': true});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ class SearchService {
|
||||
_active.remove(id)?.cancel();
|
||||
}
|
||||
|
||||
/// Cancel every in-flight search. Called when the workspace service
|
||||
/// set is torn down on project switch (T-367).
|
||||
Future<void> shutdown() async {
|
||||
for (final c in _active.values) {
|
||||
c.cancel();
|
||||
}
|
||||
_active.clear();
|
||||
}
|
||||
|
||||
/// Compute (preview) or perform (apply) a search-and-replace.
|
||||
///
|
||||
/// Preview returns per-file before/after edits without touching disk.
|
||||
|
||||
@@ -9,6 +9,7 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../files/path_safety.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'buffer.dart';
|
||||
@@ -212,10 +213,13 @@ class EditorRegistry {
|
||||
events.emit(IpcEvent(subsystem: 'editor', kind: kind, timestamp: DateTime.now().toUtc(), data: data));
|
||||
}
|
||||
|
||||
/// Resolve a buffer path to disk under the workspace root, with the
|
||||
/// same traversal/symlink containment as files.* (T-363). A buffer is
|
||||
/// a WRITE surface (save), so the D-80 extra read roots do not apply —
|
||||
/// strictly workspace-confined. Throws [PathOutsideRoot] on escape.
|
||||
String _absolutePathOf(String repoRelative) {
|
||||
if (repoRelative.startsWith('/')) return repoRelative;
|
||||
final sep = Platform.pathSeparator;
|
||||
return '${workspaceRoot.absolute.path}$sep${repoRelative.replaceAll('/', sep)}';
|
||||
return resolveUnderRootFollowingSymlinks(workspaceRoot, repoRelative.replaceAll('/', sep));
|
||||
}
|
||||
|
||||
// Support JSON decode of Selection from IPC args.
|
||||
|
||||
@@ -43,6 +43,11 @@ Future<List<FileEntry>> listDir({required Directory root, required String dir, r
|
||||
await for (final e in resolved.list(followLinks: false)) {
|
||||
final name = e.uri.pathSegments.isNotEmpty ? e.uri.pathSegments.where((s) => s.isNotEmpty).last : '';
|
||||
final rel = dir.isEmpty ? name : '$dir/$name';
|
||||
// With followLinks: false the lister yields Link entities for symlinks —
|
||||
// that's the symlink signal. stat() follows the link (target type/size,
|
||||
// notFound for broken links), so its type can never be `link` and must
|
||||
// not be used for detection (T-365).
|
||||
final isLink = e is Link;
|
||||
final stat = await e.stat();
|
||||
final isDir = stat.type == FileSystemEntityType.directory;
|
||||
if (ignore.isIgnored(rel, isDirectory: isDir)) continue;
|
||||
@@ -51,7 +56,7 @@ Future<List<FileEntry>> listDir({required Directory root, required String dir, r
|
||||
name: name,
|
||||
path: rel,
|
||||
isDirectory: isDir,
|
||||
isSymlink: stat.type == FileSystemEntityType.link,
|
||||
isSymlink: isLink,
|
||||
sizeBytes: isDir ? null : stat.size,
|
||||
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
||||
),
|
||||
@@ -79,9 +84,10 @@ class WalkResult {
|
||||
|
||||
/// Recursively walk [root], returning every non-ignored *file*
|
||||
/// (directories are descended into but not emitted), pruned by
|
||||
/// [ignore]. Reuses [listDir] per directory, so ignore filtering,
|
||||
/// symlink-escape safety (`followLinks: false`), and per-directory
|
||||
/// sorting are inherited.
|
||||
/// [ignore]. Reuses [listDir] per directory, so ignore filtering and
|
||||
/// per-directory sorting are inherited. Symlinks are never descended —
|
||||
/// a symlinked directory would be an escape hatch out of the workspace
|
||||
/// and a cycle risk (T-365); symlinks to files are emitted as entries.
|
||||
///
|
||||
/// Capped at [maxFiles] to bound work on pathological trees; when the
|
||||
/// cap is hit the walk stops early and [WalkResult.truncated] is set so
|
||||
@@ -97,7 +103,7 @@ Future<WalkResult> walkFiles({required Directory root, required IgnoreSet ignore
|
||||
final entries = await listDir(root: root, dir: dir, ignore: ignore);
|
||||
for (final e in entries) {
|
||||
if (e.isDirectory) {
|
||||
stack.add(e.path);
|
||||
if (!e.isSymlink) stack.add(e.path);
|
||||
} else {
|
||||
out.add(e);
|
||||
if (out.length >= maxFiles) {
|
||||
|
||||
+6
-201
@@ -1,8 +1,9 @@
|
||||
/// Git operations — staging, committing, stashing, log, pull, push.
|
||||
///
|
||||
/// Each function shells out to `git` and returns either a typed result
|
||||
/// or throws [GitException] on failure. All operations are workspace-
|
||||
/// rooted (take a [Directory] argument).
|
||||
/// Shared git plumbing: the resolved `git` binary path, the typed
|
||||
/// failure ([GitException]), the ref-shaped-argument validator, and the
|
||||
/// log entry model. The legacy free-function operation API that used to
|
||||
/// live here duplicated [GitClient] verb-for-verb, had no non-test
|
||||
/// callers, and carried a latent pipe deadlock in its hunk-apply path —
|
||||
/// removed in the T-385 dead-code sweep; use [GitClient].
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -71,199 +72,3 @@ class GitLogEntry {
|
||||
if (body.isNotEmpty) 'body': body,
|
||||
};
|
||||
}
|
||||
|
||||
/// Stage files. Empty [paths] means stage all (`git add -A`).
|
||||
Future<void> gitStage(Directory workDir, List<String> paths) async {
|
||||
final args = ['add'];
|
||||
if (paths.isEmpty) {
|
||||
args.add('-A');
|
||||
} else {
|
||||
args.add('--');
|
||||
args.addAll(paths);
|
||||
}
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git add failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unstage files. Empty [paths] means unstage all.
|
||||
Future<void> gitUnstage(Directory workDir, List<String> paths) async {
|
||||
final args = ['reset', 'HEAD'];
|
||||
if (paths.isNotEmpty) {
|
||||
args.add('--');
|
||||
args.addAll(paths);
|
||||
}
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git reset failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage a single hunk via `git apply --cached`.
|
||||
Future<void> gitStageHunk(Directory workDir, String patch) async {
|
||||
await _applyPatch(workDir, patch, cached: true);
|
||||
}
|
||||
|
||||
/// Unstage a single hunk via `git apply --cached --reverse`.
|
||||
Future<void> gitUnstageHunk(Directory workDir, String patch) async {
|
||||
await _applyPatch(workDir, patch, cached: true, reverse: true);
|
||||
}
|
||||
|
||||
/// Discard unstaged changes for [paths]. Uses `git checkout -- <paths>`.
|
||||
Future<void> gitDiscard(Directory workDir, List<String> paths) async {
|
||||
if (paths.isEmpty) return;
|
||||
final r = await Process.run(gitBin, ['checkout', '--', ...paths], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git checkout failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit staged changes.
|
||||
Future<String> gitCommit(Directory workDir, String message, {bool amend = false}) async {
|
||||
final args = ['commit', '-m', message];
|
||||
if (amend) args.add('--amend');
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git commit failed', stderr: r.stderr as String);
|
||||
}
|
||||
// Return the new commit hash.
|
||||
final hashResult = await Process.run(gitBin, ['rev-parse', 'HEAD'], workingDirectory: workDir.path);
|
||||
return (hashResult.stdout as String).trim();
|
||||
}
|
||||
|
||||
/// Stash working changes.
|
||||
Future<void> gitStash(Directory workDir, {String? message, bool includeUntracked = false}) async {
|
||||
final args = ['stash', 'push'];
|
||||
if (message != null) {
|
||||
args.addAll(['-m', message]);
|
||||
}
|
||||
if (includeUntracked) args.add('--include-untracked');
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git stash failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the top stash entry.
|
||||
Future<void> gitStashPop(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['stash', 'pop'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git stash pop failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Git log. Returns the most recent [count] entries.
|
||||
Future<List<GitLogEntry>> gitLog(Directory workDir, {int count = 20}) async {
|
||||
final r = await Process.run(gitBin, ['log', '--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01', '-n', '$count'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return const [];
|
||||
return _parseLog(r.stdout as String);
|
||||
}
|
||||
|
||||
/// Pull from remote.
|
||||
Future<String> gitPull(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['pull'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git pull failed', stderr: r.stderr as String);
|
||||
}
|
||||
return (r.stdout as String).trim();
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
Future<String> gitPush(Directory workDir, {String? remote, String? branch, bool setUpstream = false}) async {
|
||||
if (remote != null) validateGitRef(remote, kind: 'remote');
|
||||
if (branch != null) validateGitRef(branch, kind: 'branch');
|
||||
final args = ['push'];
|
||||
if (setUpstream) args.add('-u');
|
||||
// `--` terminates option parsing — belt-and-suspenders alongside
|
||||
// the ref validator above. Without it a future caller that bypasses
|
||||
// the validator could still inject `--upload-pack=...`.
|
||||
args.add('--');
|
||||
if (remote != null) args.add(remote);
|
||||
if (branch != null) args.add(branch);
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git push failed', stderr: r.stderr as String);
|
||||
}
|
||||
return ((r.stdout as String) + (r.stderr as String)).trim();
|
||||
}
|
||||
|
||||
/// List local branches. Returns (name, isCurrent) pairs.
|
||||
Future<List<({String name, bool current})>> gitBranches(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['branch', '--format=%(refname:short)|%(HEAD)'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return const [];
|
||||
final out = <({String name, bool current})>[];
|
||||
for (final line in (r.stdout as String).split('\n')) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
final sep = line.lastIndexOf('|');
|
||||
if (sep < 0) continue;
|
||||
final name = line.substring(0, sep);
|
||||
final head = line.substring(sep + 1).trim();
|
||||
out.add((name: name, current: head == '*'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Checkout a branch.
|
||||
///
|
||||
/// `git checkout` overloads positionals: `-- <name>` means "restore
|
||||
/// pathspec `<name>`", not "checkout branch `<name>`". So this can't
|
||||
/// use `--` as an option terminator without changing semantics — the
|
||||
/// [validateGitRef] guard against `-`-prefixed values is the only
|
||||
/// argv-injection defence here. Use `gitSwitch` if/when we adopt it.
|
||||
Future<void> gitCheckout(Directory workDir, String branch) async {
|
||||
validateGitRef(branch, kind: 'branch');
|
||||
final r = await Process.run(gitBin, ['checkout', branch], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git checkout failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current branch name.
|
||||
Future<String?> gitCurrentBranch(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['symbolic-ref', '--short', 'HEAD'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return null;
|
||||
return (r.stdout as String).trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<GitLogEntry> _parseLog(String output) {
|
||||
if (output.trim().isEmpty) return const [];
|
||||
final records = output.split('\x01');
|
||||
final entries = <GitLogEntry>[];
|
||||
for (final record in records) {
|
||||
final trimmed = record.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
final fields = trimmed.split('\x00');
|
||||
if (fields.length < 5) continue;
|
||||
entries.add(
|
||||
GitLogEntry(
|
||||
hash: fields[0],
|
||||
shortHash: fields[1],
|
||||
subject: fields[2],
|
||||
author: fields[3],
|
||||
date: fields[4],
|
||||
body: fields.length > 5 ? fields[5].trim() : '',
|
||||
),
|
||||
);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<void> _applyPatch(Directory workDir, String patch, {bool cached = false, bool reverse = false}) async {
|
||||
final args = ['apply'];
|
||||
if (cached) args.add('--cached');
|
||||
if (reverse) args.add('--reverse');
|
||||
args.add('--unidiff-zero');
|
||||
args.add('-');
|
||||
|
||||
final proc = await Process.start('git', args, workingDirectory: workDir.path);
|
||||
proc.stdin.write(patch);
|
||||
await proc.stdin.close();
|
||||
final exitCode = await proc.exitCode;
|
||||
if (exitCode != 0) {
|
||||
final stderr = await proc.stderr.transform(const SystemEncoding().decoder).join();
|
||||
throw GitException('git apply failed', stderr: stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
@@ -33,6 +34,12 @@ import 'package:clide/src/ipc/envelope.dart';
|
||||
/// separate `/ide` minimum (D-68).
|
||||
const String _clideToolPrefix = 'mcp__clide__';
|
||||
|
||||
/// Auth header Claude Code's `/ide` client sends, populated from the lock
|
||||
/// file's `authToken`. Every request must carry it (T-362): the unix socket
|
||||
/// is gated by 0600 per D-71, and an unauthenticated localhost HTTP port
|
||||
/// would bypass that gate wholesale.
|
||||
const String kMcpAuthHeader = 'x-claude-code-ide-authorization';
|
||||
|
||||
/// One connected SSE client. Each session has its own response
|
||||
/// stream; POST /messages routes back to the right one via the
|
||||
/// `sessionId` query param.
|
||||
@@ -89,6 +96,7 @@ class McpServer {
|
||||
HttpServer? _http;
|
||||
String? _lockFile;
|
||||
int? _port;
|
||||
String? _authToken;
|
||||
final Map<String, _McpSession> _sessions = {};
|
||||
int _sessionCounter = 0;
|
||||
|
||||
@@ -96,11 +104,16 @@ class McpServer {
|
||||
int? get port => _port;
|
||||
String? get lockFilePath => _lockFile;
|
||||
|
||||
/// The per-start bearer token clients must present in [kMcpAuthHeader].
|
||||
/// Published to legitimate clients via the 0600 lock file only.
|
||||
String? get authToken => _authToken;
|
||||
|
||||
Future<void> start() async {
|
||||
if (isRunning) return;
|
||||
final server = await HttpServer.bind(bindHost, bindPort);
|
||||
_http = server;
|
||||
_port = server.port;
|
||||
_authToken = _generateToken();
|
||||
_lockFile = await _writeDiscoveryFile();
|
||||
server.listen(
|
||||
_route,
|
||||
@@ -136,6 +149,13 @@ class McpServer {
|
||||
// -- routing --------------------------------------------------------------
|
||||
|
||||
Future<void> _route(HttpRequest req) async {
|
||||
// Token gate first, on every path (T-362). Without it, any local
|
||||
// process could drive the entire dispatcher D-71's 0600 socket guards.
|
||||
if (req.headers.value(kMcpAuthHeader) != _authToken) {
|
||||
req.response.statusCode = HttpStatus.unauthorized;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final path = req.uri.path;
|
||||
if (path == '/sse' && req.method == 'GET') {
|
||||
await _openSseStream(req);
|
||||
@@ -327,8 +347,39 @@ class McpServer {
|
||||
dirHandle.createSync(recursive: true);
|
||||
}
|
||||
final path = '$dir/$pid.lock';
|
||||
final body = jsonEncode({'pid': pid, 'workspace': workspaceRoot, 'transport': 'sse', 'url': 'http://$bindHost:$_port/sse'});
|
||||
final body = jsonEncode({
|
||||
'pid': pid,
|
||||
'workspace': workspaceRoot,
|
||||
'transport': 'sse',
|
||||
'url': 'http://$bindHost:$_port/sse',
|
||||
// Claude Code's /ide lock format carries the bearer token here; the
|
||||
// 0600 below is what scopes it to this user (T-362).
|
||||
'authToken': _authToken,
|
||||
});
|
||||
File(path).writeAsStringSync(body);
|
||||
try {
|
||||
await _chmod(path, '600');
|
||||
} catch (e) {
|
||||
// Not fatal like the socket's chmod (D-71): the lock lives under
|
||||
// ~/.claude which the home-dir perms usually already protect. But say so.
|
||||
log.warn('mcp', 'chmod 600 on $path failed: $e — the auth token may be readable by other local users');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token.
|
||||
static String _generateToken() {
|
||||
final rng = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
|
||||
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// `chmod` via `chmod(1)` — dart:io doesn't expose mode bits (same
|
||||
/// approach as the unix-socket server, D-71).
|
||||
static Future<void> _chmod(String path, String octal) async {
|
||||
final r = await Process.run('chmod', [octal, path]);
|
||||
if (r.exitCode != 0) {
|
||||
throw ProcessException('chmod', [octal, path], r.stderr.toString(), r.exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-27
@@ -150,33 +150,26 @@ class IpcServer {
|
||||
|
||||
void _onClient(Socket client) {
|
||||
_clients.add(client);
|
||||
final buffer = StringBuffer();
|
||||
late StreamSubscription<List<int>> sub;
|
||||
sub = client.listen(
|
||||
(chunk) async {
|
||||
buffer.write(utf8.decode(chunk, allowMalformed: true));
|
||||
var idx = buffer.toString().indexOf('\n');
|
||||
while (idx >= 0) {
|
||||
final raw = buffer.toString().substring(0, idx);
|
||||
// Trim consumed bytes by rebuilding the buffer with the
|
||||
// tail — StringBuffer can't slice in place.
|
||||
final tail = buffer.toString().substring(idx + 1);
|
||||
buffer.clear();
|
||||
buffer.write(tail);
|
||||
await _handleLine(client, raw);
|
||||
idx = buffer.toString().indexOf('\n');
|
||||
}
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
},
|
||||
onDone: () {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
sub.cancel();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
unawaited(_serveClient(client));
|
||||
}
|
||||
|
||||
/// One read loop per connection: persistent UTF-8 decode, line framing,
|
||||
/// and true serial dispatch in a single `await for` (D-72, T-372). The
|
||||
/// old async onData handler never paused its subscription — pipelined
|
||||
/// requests interleaved mid-handler, the shared StringBuffer could
|
||||
/// re-frame while an await was in flight, and per-chunk decode corrupted
|
||||
/// runes split across reads.
|
||||
Future<void> _serveClient(Socket client) async {
|
||||
try {
|
||||
await for (final line in client.cast<List<int>>().transform(const Utf8Decoder(allowMalformed: true)).transform(const LineSplitter())) {
|
||||
await _handleLine(client, line);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
} finally {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLine(Socket client, String line) async {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/// DaemonTransport (T-331): the seam between the local app and its
|
||||
/// backend. The UI's [DaemonClient] talks JSON-lines through a
|
||||
/// [DaemonTransport] instead of a hard-coded unix-socket connect, so a
|
||||
/// remote transport (SSH-tunnelled agent socket or ssh-exec channel,
|
||||
/// T-329/Q-23) can slot in without touching the client's correlation,
|
||||
/// reconnect, or event-forwarding logic.
|
||||
///
|
||||
/// The wire protocol is unchanged either way: one JSON envelope
|
||||
/// (IpcRequest/IpcResponse/IpcEvent, see envelope.dart) per line.
|
||||
///
|
||||
/// Kept Flutter-free — this file runs under plain `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// How the app reaches its backend. Implementations own endpoint
|
||||
/// resolution + connection establishment; the caller owns retry policy
|
||||
/// (the client's backoff loop calls [open] again after a failure).
|
||||
abstract interface class DaemonTransport {
|
||||
/// Stable, human-readable endpoint description — the unix socket path
|
||||
/// locally, a `ssh://host/path` form remotely. Used for logs, status
|
||||
/// surfaces, and same-endpoint reconnect short-circuits.
|
||||
String get endpoint;
|
||||
|
||||
/// Establish one connection. Throws on failure (caller retries).
|
||||
Future<DaemonConnection> open();
|
||||
}
|
||||
|
||||
/// One live backend connection carrying JSON-lines both ways.
|
||||
abstract interface class DaemonConnection {
|
||||
/// Incoming lines, one JSON envelope each. Done/error signals the
|
||||
/// connection dropped.
|
||||
Stream<String> get lines;
|
||||
|
||||
/// Send one JSON envelope line (the newline is appended here).
|
||||
void writeLine(String line);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
/// Today's path: connect to the workspace-derived unix domain socket
|
||||
/// (D-70) the in-process IpcServer is bound to.
|
||||
class LocalSocketTransport implements DaemonTransport {
|
||||
LocalSocketTransport(this.socketPath);
|
||||
|
||||
final String socketPath;
|
||||
|
||||
@override
|
||||
String get endpoint => socketPath;
|
||||
|
||||
@override
|
||||
Future<DaemonConnection> open() async {
|
||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
||||
return _SocketConnection(await Socket.connect(addr, 0));
|
||||
}
|
||||
}
|
||||
|
||||
class _SocketConnection implements DaemonConnection {
|
||||
_SocketConnection(this._socket);
|
||||
|
||||
final Socket _socket;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
@override
|
||||
void writeLine(String line) => _socket.writeln(line);
|
||||
|
||||
@override
|
||||
Future<void> close() => _socket.close();
|
||||
}
|
||||
+20
-181
@@ -1,63 +1,46 @@
|
||||
/// Raw FFI bindings to the libc functions the PTY wrapper needs.
|
||||
/// Raw FFI bindings to the libc symbols the PTY layer still needs.
|
||||
///
|
||||
/// `dart:io` doesn't expose `forkpty`, `read`/`write` on raw fds,
|
||||
/// `ioctl`, or `poll` — FFI is the minimum tool for the job.
|
||||
/// `dart:io` doesn't expose `socketpair`, `close` on raw fds, `errno`,
|
||||
/// or the `poll()` event bits — FFI is the minimum tool for the job.
|
||||
/// The fd-passing-era surface that used to live here (recvmsg + the
|
||||
/// msghdr/cmsghdr/iovec structs, read/write, ioctl/winsize, fcntl
|
||||
/// non-blocking helpers) had no callers since the daemon dissolution
|
||||
/// (D-56) and was removed in the T-385 dead-code sweep; `NativePty`
|
||||
/// binds its own symbols.
|
||||
///
|
||||
/// Linux + macOS only for now. Windows is covered by platform checks
|
||||
/// higher up; when Windows support lands it'll need a parallel binding
|
||||
/// set against the Win32 API (named pipes instead of unix sockets).
|
||||
library;
|
||||
|
||||
// File-wide analyzer exceptions, with reason — see CLAUDE.md
|
||||
// no-lint-suppression rule. These are the textbook FFI-binding
|
||||
// case where the lints work against the file's purpose:
|
||||
// File-wide analyzer exception, with reason — see CLAUDE.md
|
||||
// no-lint-suppression rule. This is the textbook FFI-binding case
|
||||
// where the lint works against the file's purpose:
|
||||
//
|
||||
// * `non_constant_identifier_names` — struct field names map 1:1
|
||||
// to POSIX (`man 2 socketpair`, `recvmsg`, `iovec`, `msghdr`).
|
||||
// Keeping snake_case makes the code greppable against the spec
|
||||
// and the field offsets readable next to the C ABI. Dart FFI
|
||||
// layout depends on declaration order + types, not names, so
|
||||
// this is purely a readability call.
|
||||
// * `library_private_types_in_public_api` — the C / Dart function-
|
||||
// signature typedefs (`_SocketpairC`, `_SocketpairDart`, etc.)
|
||||
// are implementation details consumed only by the public
|
||||
// `lookupFunction<...>()` calls in this file. Promoting them
|
||||
// to public would just add noise to the import surface.
|
||||
// signature typedefs (`_SocketpairC`, `_SocketpairD`, etc.) are
|
||||
// implementation details consumed only by the public
|
||||
// `lookupFunction<...>()` calls in this file. Promoting them to
|
||||
// public would just add noise to the import surface.
|
||||
//
|
||||
// ignore_for_file: non_constant_identifier_names, library_private_types_in_public_api
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (POSIX — platform-dispatched where Linux/macOS diverge)
|
||||
// Constants (POSIX — identical numeric values on Linux + macOS for the
|
||||
// entries we touch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const int afUnix = 1;
|
||||
const int sockStream = 1;
|
||||
|
||||
final int solSocket = Platform.isMacOS ? 0xffff : 1;
|
||||
final int scmRights = Platform.isMacOS ? 0x01 : 1;
|
||||
|
||||
final int oNonblock = Platform.isMacOS ? 0x0004 : 0x0800;
|
||||
const int fGetFl = 3;
|
||||
const int fSetFl = 4;
|
||||
|
||||
final int tiocswinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
|
||||
|
||||
// poll() event bits (POSIX — same numeric values on Linux + macOS).
|
||||
// poll() event bits.
|
||||
const int pollin = 0x0001;
|
||||
const int pollerr = 0x0008;
|
||||
const int pollhup = 0x0010;
|
||||
const int pollnval = 0x0020;
|
||||
const int pollAnyErr = pollerr | pollhup | pollnval;
|
||||
|
||||
// Signal numbers used from the PTY layer (POSIX standard; identical
|
||||
// across Linux + macOS for the entries we touch).
|
||||
// Signal numbers used from the PTY layer.
|
||||
const int sighup = 1;
|
||||
const int sigkill = 9;
|
||||
const int sigwinch = 28;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,108 +50,12 @@ const int sigwinch = 28;
|
||||
typedef _SocketpairC = ffi.Int32 Function(ffi.Int32 domain, ffi.Int32 type, ffi.Int32 protocol, ffi.Pointer<ffi.Int32> sv);
|
||||
typedef _SocketpairD = int Function(int domain, int type, int protocol, ffi.Pointer<ffi.Int32> sv);
|
||||
|
||||
typedef _RecvmsgC = ffi.IntPtr Function(ffi.Int32 sockfd, ffi.Pointer<Msghdr> msg, ffi.Int32 flags);
|
||||
typedef _RecvmsgD = int Function(int sockfd, ffi.Pointer<Msghdr> msg, int flags);
|
||||
|
||||
typedef _RecvmsgDarwinC = ffi.IntPtr Function(ffi.Int32 sockfd, ffi.Pointer<MsghdrDarwin> msg, ffi.Int32 flags);
|
||||
typedef _RecvmsgDarwinD = int Function(int sockfd, ffi.Pointer<MsghdrDarwin> msg, int flags);
|
||||
|
||||
typedef _ReadC = ffi.IntPtr Function(ffi.Int32 fd, ffi.Pointer<ffi.Uint8> buf, ffi.IntPtr count);
|
||||
typedef _ReadD = int Function(int fd, ffi.Pointer<ffi.Uint8> buf, int count);
|
||||
|
||||
typedef _WriteC = ffi.IntPtr Function(ffi.Int32 fd, ffi.Pointer<ffi.Uint8> buf, ffi.IntPtr count);
|
||||
typedef _WriteD = int Function(int fd, ffi.Pointer<ffi.Uint8> buf, int count);
|
||||
|
||||
typedef _CloseC = ffi.Int32 Function(ffi.Int32 fd);
|
||||
typedef _CloseD = int Function(int fd);
|
||||
|
||||
typedef _IoctlPtrC = ffi.Int32 Function(ffi.Int32 fd, ffi.UnsignedLong request, ffi.Pointer<Winsize> argp);
|
||||
typedef _IoctlPtrD = int Function(int fd, int request, ffi.Pointer<Winsize> argp);
|
||||
|
||||
typedef _FcntlIntC = ffi.Int32 Function(ffi.Int32 fd, ffi.Int32 cmd, ffi.Int32 arg);
|
||||
typedef _FcntlIntD = int Function(int fd, int cmd, int arg);
|
||||
|
||||
typedef _ErrnoLocationC = ffi.Pointer<ffi.Int32> Function();
|
||||
typedef _ErrnoLocationD = ffi.Pointer<ffi.Int32> Function();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// POSIX `struct iovec`.
|
||||
final class Iovec extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Uint8> iov_base;
|
||||
@ffi.IntPtr()
|
||||
external int iov_len;
|
||||
}
|
||||
|
||||
/// Linux `struct msghdr`. msg_iovlen/msg_controllen are size_t (8 bytes
|
||||
/// on 64-bit). macOS uses int/socklen_t (4 bytes) — see MsghdrDarwin.
|
||||
final class Msghdr extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> msg_name;
|
||||
@ffi.Uint32()
|
||||
external int msg_namelen;
|
||||
external ffi.Pointer<Iovec> msg_iov;
|
||||
@ffi.IntPtr()
|
||||
external int msg_iovlen;
|
||||
external ffi.Pointer<ffi.Void> msg_control;
|
||||
@ffi.IntPtr()
|
||||
external int msg_controllen;
|
||||
@ffi.Int32()
|
||||
external int msg_flags;
|
||||
}
|
||||
|
||||
/// macOS `struct msghdr`. msg_iovlen is int (4 bytes), msg_controllen
|
||||
/// is socklen_t (4 bytes) — smaller than Linux's size_t fields.
|
||||
final class MsghdrDarwin extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> msg_name;
|
||||
@ffi.Uint32()
|
||||
external int msg_namelen;
|
||||
external ffi.Pointer<Iovec> msg_iov;
|
||||
@ffi.Int32()
|
||||
external int msg_iovlen;
|
||||
external ffi.Pointer<ffi.Void> msg_control;
|
||||
@ffi.Uint32()
|
||||
external int msg_controllen;
|
||||
@ffi.Int32()
|
||||
external int msg_flags;
|
||||
}
|
||||
|
||||
/// POSIX `struct cmsghdr` prefix. We treat the rest of the control
|
||||
/// buffer as a raw byte region and compute offsets by hand.
|
||||
// On Linux, cmsg_len is size_t (8 bytes on 64-bit).
|
||||
// On macOS, cmsg_len is socklen_t (4 bytes, always).
|
||||
// Use platform-specific structs.
|
||||
final class CmsghdrLinux extends ffi.Struct {
|
||||
@ffi.IntPtr()
|
||||
external int cmsg_len;
|
||||
@ffi.Int32()
|
||||
external int cmsg_level;
|
||||
@ffi.Int32()
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
final class CmsghdrDarwin extends ffi.Struct {
|
||||
@ffi.Uint32()
|
||||
external int cmsg_len;
|
||||
@ffi.Int32()
|
||||
external int cmsg_level;
|
||||
@ffi.Int32()
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
/// POSIX `struct winsize` for `TIOCSWINSZ`.
|
||||
final class Winsize extends ffi.Struct {
|
||||
@ffi.Uint16()
|
||||
external int ws_row;
|
||||
@ffi.Uint16()
|
||||
external int ws_col;
|
||||
@ffi.Uint16()
|
||||
external int ws_xpixel;
|
||||
@ffi.Uint16()
|
||||
external int ws_ypixel;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library handle + lazy-resolved function pointers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -184,20 +71,8 @@ ffi.DynamicLibrary _openLibc() {
|
||||
|
||||
final _SocketpairD socketpair = _libc.lookupFunction<_SocketpairC, _SocketpairD>('socketpair');
|
||||
|
||||
final _RecvmsgD recvmsgLinux = _libc.lookupFunction<_RecvmsgC, _RecvmsgD>('recvmsg');
|
||||
|
||||
final _RecvmsgDarwinD recvmsgDarwin = _libc.lookupFunction<_RecvmsgDarwinC, _RecvmsgDarwinD>('recvmsg');
|
||||
|
||||
final _ReadD read = _libc.lookupFunction<_ReadC, _ReadD>('read');
|
||||
|
||||
final _WriteD write = _libc.lookupFunction<_WriteC, _WriteD>('write');
|
||||
|
||||
final _CloseD close = _libc.lookupFunction<_CloseC, _CloseD>('close');
|
||||
|
||||
final _IoctlPtrD ioctlWinsize = _libc.lookupFunction<_IoctlPtrC, _IoctlPtrD>('ioctl');
|
||||
|
||||
final _FcntlIntD fcntlInt = _libc.lookupFunction<_FcntlIntC, _FcntlIntD>('fcntl');
|
||||
|
||||
/// Resolve `errno` through the platform-appropriate thread-local
|
||||
/// accessor. glibc exposes `__errno_location`, musl the same, macOS
|
||||
/// uses `__error`.
|
||||
@@ -211,39 +86,3 @@ int get errno {
|
||||
final fn = _libc.lookupFunction<_ErrnoLocationC, _ErrnoLocationD>('__error');
|
||||
return fn().value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience — scoped allocations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Allocate a typed native block, run [action], free. Frees even if
|
||||
/// [action] throws.
|
||||
T withBuffer<T>(int bytes, T Function(ffi.Pointer<ffi.Uint8>) action) {
|
||||
final p = pkg_ffi.calloc<ffi.Uint8>(bytes);
|
||||
try {
|
||||
return action(p);
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set [fd] non-blocking. Returns whether the flag was changed.
|
||||
bool setNonBlocking(int fd) {
|
||||
final flags = fcntlInt(fd, fGetFl, 0);
|
||||
if (flags < 0) return false;
|
||||
if ((flags & oNonblock) != 0) return false;
|
||||
fcntlInt(fd, fSetFl, flags | oNonblock);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Apply `TIOCSWINSZ` to the master PTY fd.
|
||||
int setWinsize(int fd, int cols, int rows) {
|
||||
final ws = pkg_ffi.calloc<Winsize>();
|
||||
try {
|
||||
ws.ref.ws_col = cols;
|
||||
ws.ref.ws_row = rows;
|
||||
return ioctlWinsize(fd, tiocswinsz, ws);
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(ws);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,11 @@ class NativePty {
|
||||
void _reap() {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
// The reader isolate sends EOF only after exiting its poll loop, so
|
||||
// nothing touches the master fd anymore. Release it here — close()
|
||||
// short-circuits on _dead, so skipping this leaks the fd and its pty
|
||||
// device for the life of the app on every natural child exit (T-360).
|
||||
_nativeClose(_fd);
|
||||
final s = calloc<ffi.Int32>();
|
||||
_waitpid(pid, s, _kWnohang);
|
||||
calloc.free(s);
|
||||
|
||||
@@ -56,11 +56,11 @@ Stream<List<SearchMatch>> grepWorkspace({
|
||||
final walk = await walkFiles(root: root, ignore: ignore);
|
||||
if (cancel?.isCancelled ?? false) return;
|
||||
|
||||
final includes = [for (final g in query.include) _globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) _globToRegExp(g)];
|
||||
final includes = [for (final g in query.include) globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) globToRegExp(g)];
|
||||
final candidates = <String>[];
|
||||
for (final e in walk.files) {
|
||||
if (_acceptGlobs(e.path, includes, excludes)) candidates.add(e.path);
|
||||
if (acceptGlobs(e.path, includes, excludes)) candidates.add(e.path);
|
||||
}
|
||||
if (candidates.isEmpty) return;
|
||||
|
||||
@@ -193,7 +193,10 @@ class CompiledQuery {
|
||||
|
||||
// -- Glob filtering ----------------------------------------------------------
|
||||
|
||||
bool _acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
/// Whether [path] passes the compiled include/exclude filters. Shared with
|
||||
/// the replace engine so search and replace can never disagree on scope
|
||||
/// (T-364).
|
||||
bool acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
if (includes.isNotEmpty && !includes.any((r) => r.hasMatch(path))) return false;
|
||||
if (excludes.any((r) => r.hasMatch(path))) return false;
|
||||
return true;
|
||||
@@ -202,7 +205,7 @@ bool _acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
/// Compile a gitignore-flavoured glob to a full-path regex. A `/` in
|
||||
/// the glob anchors it to the workspace root; otherwise it may match at
|
||||
/// any depth (basename-style). Supports `*`, `**`, `?`.
|
||||
RegExp _globToRegExp(String glob) {
|
||||
RegExp globToRegExp(String glob) {
|
||||
final anchored = glob.contains('/');
|
||||
final b = StringBuffer('^');
|
||||
if (!anchored) b.write(r'(?:.*/)?');
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'dart:io';
|
||||
|
||||
import '../files/ignore.dart';
|
||||
import '../files/listing.dart';
|
||||
import 'grep_engine.dart' show acceptGlobs, globToRegExp;
|
||||
import 'match.dart';
|
||||
|
||||
/// One changed line within a file.
|
||||
@@ -133,9 +134,14 @@ Future<List<FileReplacement>> computeReplacements({
|
||||
|
||||
final walk = await walkFiles(root: root, ignore: ignore);
|
||||
final rootPath = root.absolute.path;
|
||||
// Same compiled glob filters as the grep engine — replace must never
|
||||
// touch a file the equivalent search wouldn't have matched (T-364).
|
||||
final includes = [for (final g in query.include) globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) globToRegExp(g)];
|
||||
final out = <FileReplacement>[];
|
||||
for (final entry in walk.files) {
|
||||
if (out.length >= maxFiles) break;
|
||||
if (!acceptGlobs(entry.path, includes, excludes)) continue;
|
||||
final fr = _replaceInFile(rootPath, entry.path, query, replacement);
|
||||
if (fr != null) out.add(fr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/// The top window-chrome bar (D-57): drag region, menu bar, project
|
||||
/// switcher, window controls. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/project_switcher.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class HatBar extends StatelessWidget {
|
||||
const HatBar({super.key, required this.kernel, required this.menuBar});
|
||||
final KernelServices kernel;
|
||||
final MenuBarController menuBar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => kernel.window.startDrag(),
|
||||
child: Container(
|
||||
height: hatHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.chromeBackground,
|
||||
border: Border(bottom: BorderSide(color: tokens.chromeBorder, width: 1)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_LeftHatContent(tokens: tokens, wc: kernel.window),
|
||||
MenuBar(controller: menuBar),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ProjectSwitcherButton(kernel: kernel, tokens: tokens),
|
||||
),
|
||||
),
|
||||
_RightHatContent(tokens: tokens, wc: kernel.window),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LeftHatContent extends StatelessWidget {
|
||||
const _LeftHatContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
// On macOS the native titlebar draws traffic lights; skip duplicates.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _RightHatContent extends StatelessWidget {
|
||||
const _RightHatContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
if (!kIsWeb && Platform.isMacOS) return const SizedBox.shrink();
|
||||
return Row(
|
||||
children: [
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WinBtn extends StatelessWidget {
|
||||
const _WinBtn({required this.icon, required this.onTap, required this.tokens, this.isClose = false});
|
||||
final ClideIconPainter icon;
|
||||
final VoidCallback onTap;
|
||||
final SurfaceTokens tokens;
|
||||
final bool isClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 36,
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/// The root three-column layout grid, the status bar, and its
|
||||
/// collapse toggles + bottom icon rails. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/slot_host.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class RootLayout extends StatelessWidget {
|
||||
const RootLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.arrangement]),
|
||||
builder: (ctx, _) {
|
||||
final a = kernel.arrangement;
|
||||
final sidebarVisible = a.isVisible(Slots.sidebar);
|
||||
final sidebarCollapsed = a.isCollapsed(Slots.sidebar);
|
||||
final contextVisible = a.isVisible(Slots.contextPanel);
|
||||
final contextCollapsed = a.isCollapsed(Slots.contextPanel);
|
||||
final statusVisible = a.isVisible(Slots.statusbar);
|
||||
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 400;
|
||||
final contextSize = a.sizeOf(Slots.contextPanel) ?? 420;
|
||||
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
|
||||
// Bottom output dock (T-54 / D-87): pushes the workspace up when open,
|
||||
// capped at half the window so Claude stays the largest surface (the
|
||||
// D-47 amendment).
|
||||
final dockVisible = a.isVisible(Slots.dock);
|
||||
final dockMax = (((MediaQuery.of(ctx).size.height) - statusHeight) * 0.5).clamp(80.0, double.infinity).toDouble();
|
||||
final dockHeight = dockVisible ? ((a.sizeOf(Slots.dock) ?? 200).clamp(0.0, dockMax)).toDouble() : 0.0;
|
||||
|
||||
final column = Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (sidebarVisible && sidebarCollapsed)
|
||||
ClideSpine(label: _sidebarSpineLabel(kernel), side: SpineSide.left, onExpand: () => a.setCollapsed(Slots.sidebar, false))
|
||||
else if (sidebarVisible) ...[
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: SlotHost(slot: Slots.sidebar),
|
||||
),
|
||||
DragResizeHandle(arrangement: a, slot: Slots.sidebar, axis: Axis.horizontal),
|
||||
],
|
||||
const Expanded(child: SlotHost(slot: Slots.workspace)),
|
||||
if (contextVisible && contextCollapsed)
|
||||
ClideSpine(label: 'context', side: SpineSide.right, onExpand: () => a.setCollapsed(Slots.contextPanel, false))
|
||||
else if (contextVisible) ...[
|
||||
DragResizeHandle(arrangement: a, slot: Slots.contextPanel, axis: Axis.horizontal),
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: SlotHost(slot: Slots.contextPanel),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (dockVisible)
|
||||
SizedBox(
|
||||
height: dockHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: SlotHost(slot: Slots.dock),
|
||||
),
|
||||
),
|
||||
if (statusVisible)
|
||||
Container(
|
||||
height: statusHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Collapse toggles are pinned to the screen edges (outermost
|
||||
// children) so they never shift when a pane collapses (T-294).
|
||||
StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible),
|
||||
if (sidebarVisible && !sidebarCollapsed)
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: _BottomRail(slot: Slots.sidebar),
|
||||
)
|
||||
else if (sidebarVisible && sidebarCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
const Expanded(child: StatusbarHost()),
|
||||
if (contextVisible && !contextCollapsed)
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: _BottomRail(slot: Slots.contextPanel),
|
||||
)
|
||||
else if (contextVisible && contextCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
// When the status bar is hidden it no longer occupies the window's
|
||||
// bottom edge, so the bottom-most content (the Claude composer, an
|
||||
// editor, a terminal) would otherwise run flush into the resize-drag
|
||||
// strip and look jammed against the window bottom (T-298). Reserve a
|
||||
// matching inset so the interaction zone bottom-anchors consistently,
|
||||
// independent of status-bar visibility.
|
||||
if (statusVisible) return column;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: ClideResizeBorder.edgeThickness),
|
||||
child: column,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _sidebarSpineLabel(KernelServices kernel) {
|
||||
final activeTab = kernel.panels.activeTabIn(Slots.sidebar);
|
||||
if (activeTab == null) return 'overview';
|
||||
final tabs = kernel.panels.tabsFor(Slots.sidebar);
|
||||
for (final t in tabs) {
|
||||
if (t.id == activeTab) return t.title.toLowerCase();
|
||||
}
|
||||
return 'overview';
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomRail extends StatelessWidget {
|
||||
const _BottomRail({required this.slot});
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(slot);
|
||||
if (tabs.isEmpty) return Container(color: tokens.chromeBackground);
|
||||
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
child: ClideIconRail(
|
||||
items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: resolveTabTitle(ctx, t))],
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static ClideIconPainter _iconFor(SlotId slot, TabContribution t) {
|
||||
if (t.icon is ClideIconPainter) return t.icon as ClideIconPainter;
|
||||
if (slot == Slots.sidebar) {
|
||||
return switch (t.id) {
|
||||
'files.tree' => PhosphorIcons.byName('folder'),
|
||||
'git.panel' => PhosphorIcons.byName('git-branch'),
|
||||
'pql.panel' => PhosphorIcons.byName('magnifying-glass'),
|
||||
'problems.panel' => PhosphorIcons.byName('warning-circle'),
|
||||
'decisions.panel' => PhosphorIcons.byName('lightbulb'),
|
||||
'tickets.panel' => PhosphorIcons.byName('ticket'),
|
||||
_ => PhosphorIcons.byName('circles-four'),
|
||||
};
|
||||
}
|
||||
return switch (t.id) {
|
||||
'markdown.viewer' => PhosphorIcons.byName('eye'),
|
||||
'graph.view' => PhosphorIcons.byName('graph'),
|
||||
'pql.backlinks' => PhosphorIcons.byName('link'),
|
||||
_ => PhosphorIcons.byName('circles-four'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed-position collapse/expand toggle bookending the status bar (T-294).
|
||||
/// The left cell controls the sidebar, the right cell the context pane; both
|
||||
/// fire the existing `sidebar.collapse` / `context.collapse` commands and flip a
|
||||
/// caret-line chevron per `arrangement.isCollapsed` (outward = expand, inward =
|
||||
/// collapse). The collapse behaviour itself lives in the commands (D-51/D-54);
|
||||
/// this is the mouse affordance for the keyboard/CLI-addressable action (D-6).
|
||||
/// A fixed collapse/expand toggle pinned to a screen edge of the status bar
|
||||
/// (T-294). Lives at the outer ends of the bar — NOT inside the centre
|
||||
/// [StatusbarHost] — so it never shifts when a pane collapses and the centre
|
||||
/// bar resizes. [collapsed]/[visible] are passed in (not read from the
|
||||
/// arrangement here) so the widget varies with state and rebuilds when its
|
||||
/// parent's `ListenableBuilder` fires — a const widget reading the arrangement
|
||||
/// itself is skipped as identical on rebuild, freezing the chevron.
|
||||
class StatusbarCollapseToggle extends StatelessWidget {
|
||||
const StatusbarCollapseToggle({super.key, required this.slot, required this.collapsed, required this.visible});
|
||||
|
||||
final SlotId slot;
|
||||
final bool collapsed;
|
||||
final bool visible;
|
||||
|
||||
bool get _isSidebar => slot == Slots.sidebar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (!visible) return const SizedBox(width: 24);
|
||||
// The chevron points the DIRECTION OF THE ACTION: collapsing tucks the pane
|
||||
// toward its own edge, expanding brings it back toward the centre.
|
||||
final icon = _isSidebar
|
||||
? (collapsed ? PhosphorIcons.byName('caret-line-right') : PhosphorIcons.byName('caret-line-left'))
|
||||
: (collapsed ? PhosphorIcons.byName('caret-line-left') : PhosphorIcons.byName('caret-line-right'));
|
||||
final what = _isSidebar ? 'sidebar' : 'context panel';
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
child: ClideTappable(
|
||||
onTap: () => kernel.commands.execute(_isSidebar ? 'sidebar.collapse' : 'context.collapse'),
|
||||
tooltip: collapsed ? 'Show $what' : 'Hide $what',
|
||||
builder: (ctx, hovered, focused) => Container(
|
||||
alignment: Alignment.center,
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : null,
|
||||
child: ClideIcon(icon, size: 13, color: tokens.statusBarForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusbarHost extends StatelessWidget {
|
||||
const StatusbarHost({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final items = kernel.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>().toList();
|
||||
final left = items.where((i) => i.priority < 100).toList();
|
||||
final right = items.where((i) => i.priority >= 100).toList();
|
||||
// Two explicit columns within the center (workspace) bar: the LEFT
|
||||
// group lives in an Expanded so it absorbs all free space and is
|
||||
// start-aligned, and the RIGHT group (tool status, theme switcher)
|
||||
// trails it at intrinsic width — so it hugs the workspace block's
|
||||
// right edge by construction, no Spacer to fight a flex item (T-239).
|
||||
// Left items with flex > 0 wrap in Flexible(loose) so they yield width
|
||||
// when tight (T-160).
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (final item in left)
|
||||
if (item.flex > 0) Flexible(flex: item.flex, fit: FlexFit.loose, child: item.build(ctx)) else item.build(ctx),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final item in right) item.build(ctx),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/// The hat bar's project switcher: current-project label opening a
|
||||
/// recents + file-actions dropdown. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart' show clideName;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ProjectSwitcherButton extends StatelessWidget {
|
||||
const ProjectSwitcherButton({super.key, required this.kernel, required this.tokens});
|
||||
final KernelServices kernel;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
void _openSwitcher() {
|
||||
kernel.dialog.show<String>((ctx, dismiss) {
|
||||
return _ProjectSwitcherDropdown(kernel: kernel, onDismiss: dismiss);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
final name = kernel.project.current?.path.split('/').last;
|
||||
final label = name != null ? '$clideName > $name' : clideName;
|
||||
return ClideTappable(
|
||||
onTap: _openSwitcher,
|
||||
builder: (context, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(label, fontSize: 12, color: hovered ? tokens.globalForeground : tokens.chromeForeground, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 4),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 8, color: tokens.chromeForeground),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectSwitcherDropdown extends StatefulWidget {
|
||||
const _ProjectSwitcherDropdown({required this.kernel, required this.onDismiss});
|
||||
final KernelServices kernel;
|
||||
final void Function([String?]) onDismiss;
|
||||
|
||||
@override
|
||||
State<_ProjectSwitcherDropdown> createState() => _ProjectSwitcherDropdownState();
|
||||
}
|
||||
|
||||
class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
String _filter = '';
|
||||
late final FocusNode _focus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus = FocusNode()..requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openProject(String path) async {
|
||||
final ok = await widget.kernel.project.open(path);
|
||||
if (ok) {
|
||||
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
widget.onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// File actions now live as commands (file.openFolder / file.newWindow /
|
||||
// file.closeWorkspace) owned by the menu-bar extension (T-48). The switcher
|
||||
// dismisses itself and dispatches the command so both surfaces share one
|
||||
// implementation.
|
||||
void _runFileCommand(String command) {
|
||||
widget.onDismiss();
|
||||
unawaited(widget.kernel.commands.execute(command));
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final recents = widget.kernel.project.recents;
|
||||
final lf = _filter.toLowerCase();
|
||||
final filtered = lf.isEmpty ? recents : recents.where((r) => r.name.toLowerCase().contains(lf) || r.path.toLowerCase().contains(lf)).toList();
|
||||
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: 480,
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
border: Border.all(color: tokens.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Search projects…', onChanged: (v) => setState(() => _filter = v)),
|
||||
if (filtered.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText('Recent Projects', fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) => _RecentProjectRow(project: filtered[i], tokens: tokens, onTap: () => _openProject(filtered[i].path)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: tokens.dividerColor)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_ActionRow(
|
||||
label: 'Open Local Project',
|
||||
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.openFolder'),
|
||||
),
|
||||
_ActionRow(
|
||||
label: 'New Window',
|
||||
shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.newWindow'),
|
||||
),
|
||||
if (widget.kernel.project.isOpen)
|
||||
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: () => _runFileCommand('file.closeWorkspace')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentProjectRow extends StatelessWidget {
|
||||
const _RecentProjectRow({required this.project, required this.tokens, required this.onTap});
|
||||
final RecentProject project;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.byName('folder'), size: 14, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(project.name, fontSize: 14),
|
||||
if (project.branch != null)
|
||||
Row(
|
||||
children: [
|
||||
// Elide a long path instead of overflowing the row
|
||||
// (matches the welcome recents row; T-160 discipline).
|
||||
Flexible(
|
||||
child: ClideText(
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
],
|
||||
)
|
||||
else
|
||||
ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClideText(project.timeAgo, muted: true, fontSize: 11),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatelessWidget {
|
||||
const _ActionRow({required this.label, this.shortcut, required this.tokens, required this.onTap});
|
||||
final String label;
|
||||
final String? shortcut;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(label, fontSize: 14)),
|
||||
if (shortcut != null && shortcut!.isNotEmpty) ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/// The application root shell: global keyboard/intent routing (keymap
|
||||
/// resolution, double-tap modifiers, menu mnemonics), the hat bar, and
|
||||
/// the overlay stack (palette, quick-open, welcome, toasts). Split out
|
||||
/// of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/hat_bar.dart';
|
||||
import 'package:clide/src/shell/layout.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class RootShell extends StatefulWidget {
|
||||
const RootShell({super.key, required this.services});
|
||||
final KernelServices services;
|
||||
|
||||
@override
|
||||
State<RootShell> createState() => RootShellState();
|
||||
}
|
||||
|
||||
class RootShellState extends State<RootShell> {
|
||||
late final FocusNode _keyFocus;
|
||||
final MenuBarController _menuBar = MenuBarController();
|
||||
// Detects double-tapped bare modifiers (e.g. double-Shift → quick-open,
|
||||
// JetBrains "Search Everywhere"). Bare modifiers never resolve as a single
|
||||
// chord, so this is the only path that handles them (T-341).
|
||||
final ModifierTapTracker _modTap = ModifierTapTracker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyFocus = FocusNode()..requestFocus();
|
||||
widget.services.textZoom.addListener(_onZoom);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.services.textZoom.removeListener(_onZoom);
|
||||
_menuBar.dispose();
|
||||
_keyFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onZoom() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 15,
|
||||
height: clideLineHeight,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.increase();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.decrease();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.reset();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
|
||||
onInvoke: (intent) {
|
||||
widget.services.commands.execute(intent.commandId);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
PaletteOpenIntent: CallbackAction<PaletteOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.palette.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.quickOpen.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.arrangement.setVisible(Slots.sidebar, true);
|
||||
widget.services.arrangement.setCollapsed(Slots.sidebar, false);
|
||||
widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles');
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusNextSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusPreviousSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
HatBar(kernel: widget.services, menuBar: _menuBar),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const QuickOpenOverlay(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
const ToastOverlay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
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);
|
||||
if (intent == null) return;
|
||||
_dispatchIntent(intent);
|
||||
}
|
||||
|
||||
void _dispatchIntent(Intent intent) {
|
||||
// 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
|
||||
// Actions for global ones (text scale, generic command bridge).
|
||||
final ctx = FocusManager.instance.primaryFocus?.context ?? context;
|
||||
Actions.maybeInvoke(ctx, intent);
|
||||
}
|
||||
|
||||
/// `Alt+<mnemonic>` opens (or toggles) the matching application menu (T-48).
|
||||
/// Returns true when consumed so it never falls through to keymap resolution.
|
||||
bool _handleMenuMnemonic(KeyEvent event) {
|
||||
if (event is! KeyDownEvent || !HardwareKeyboard.instance.isAltPressed) return false;
|
||||
final label = event.logicalKey.keyLabel.toLowerCase();
|
||||
if (label.length != 1) return false;
|
||||
final idx = _menuBar.indexForMnemonic(label);
|
||||
if (idx == null) return false;
|
||||
_menuBar.toggle(idx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeOverlay extends StatelessWidget {
|
||||
const _WelcomeOverlay();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
if (kernel.project.isOpen) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
return ColoredBox(color: tokens.globalBackground, child: const WelcomeView());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/// Slot hosting: mounts a slot's tab contributions, integrates focus
|
||||
/// scopes, and renders the slot-specific bodies (sidebar / workspace
|
||||
/// split incl. the editor drag handle / context). Split out of
|
||||
/// app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SlotHost extends StatefulWidget {
|
||||
const SlotHost({super.key, required this.slot});
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
State<SlotHost> createState() => _SlotHostState();
|
||||
}
|
||||
|
||||
class _SlotHostState extends State<SlotHost> {
|
||||
late final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'SlotScope:${widget.slot.value}');
|
||||
FocusTracker? _tracker;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (!identical(_tracker, kernel.focus)) {
|
||||
_tracker?.unregisterSlotScope(widget.slot, _scope);
|
||||
_tracker = kernel.focus;
|
||||
_tracker!.registerSlotScope(widget.slot, _scope);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tracker?.unregisterSlotScope(widget.slot, _scope);
|
||||
_scope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange(bool hasFocus) {
|
||||
if (!hasFocus || _tracker == null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot);
|
||||
if (activeId != null) {
|
||||
_tracker!.setActive(slot: widget.slot, contributionId: activeId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return FocusScope(
|
||||
node: _scope,
|
||||
onFocusChange: _onFocusChange,
|
||||
child: FocusTraversalGroup(
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(widget.slot);
|
||||
if (tabs.isEmpty) {
|
||||
return Container(color: tokens.panelBackground);
|
||||
}
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
|
||||
final active = tabs.firstWhere((t) => t.id == activeId, orElse: () => tabs.first);
|
||||
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotBody extends StatelessWidget {
|
||||
const _SlotBody({required this.slot, required this.tabs, required this.active, required this.activeId});
|
||||
final SlotId slot;
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.contextPanel) {
|
||||
return _ContextSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
return _WorkspaceSlot(tabs: tabs, active: active);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: Column(
|
||||
children: [
|
||||
ClideTabBar(
|
||||
items: [for (final t in tabs) ClideTabItem(id: t.id, title: resolveTabTitle(context, t))],
|
||||
activeId: active.id,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
ClideDivider(),
|
||||
Expanded(child: active.build(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarSlot extends StatelessWidget {
|
||||
const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.fromLTRB(2, 2, 0, 0),
|
||||
child: active.build(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stable identity for the workspace's primary pane (Claude). Opening the
|
||||
// editor reparents it from a direct child into a Column/Expanded; without a
|
||||
// stable key Flutter disposes + rebuilds the subtree, and the Claude
|
||||
// conversation's SelectableRegion then runs a pending selection update
|
||||
// against now-inactive elements ("selectable not in this registrar" /
|
||||
// "renderObject of inactive element"). The GlobalKey makes Flutter MOVE the
|
||||
// element instead, preserving the selection subtree.
|
||||
final GlobalKey _kWorkspacePrimary = GlobalKey(debugLabel: 'workspace.primary');
|
||||
|
||||
class _WorkspaceSlot extends StatelessWidget {
|
||||
const _WorkspaceSlot({required this.tabs, required this.active});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
|
||||
static const _editorTabId = 'editor.active';
|
||||
static const _claudeTabId = 'claude.primary';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.arrangement,
|
||||
builder: (ctx, _) {
|
||||
final editorOpen = kernel.arrangement.editorOpen;
|
||||
final editorTab = tabs.where((t) => t.id == _editorTabId).firstOrNull;
|
||||
|
||||
final claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull;
|
||||
final primaryPane = KeyedSubtree(key: _kWorkspacePrimary, child: (claude ?? active).build(ctx));
|
||||
|
||||
// A non-Claude, non-editor workspace tab being the active one (e.g.
|
||||
// diff.view revealed by `clide ui open diff`, T-233) shows in the split
|
||||
// region above Claude — "review alongside the conversation" — with a
|
||||
// close affordance back to full-Claude. Only when Claude exists below
|
||||
// it; with no Claude pane the active tab just takes the whole slot, as
|
||||
// before. The editor keeps its own editorOpen-gated split.
|
||||
final reveal = (claude != null && active.id != _claudeTabId && active.id != _editorTabId) ? active : null;
|
||||
final topTab = reveal ?? (editorOpen ? editorTab : null);
|
||||
|
||||
if (topTab == null) {
|
||||
return Container(color: tokens.panelBackground, child: primaryPane);
|
||||
}
|
||||
|
||||
final ratio = kernel.arrangement.editorRatio;
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final totalHeight = constraints.maxHeight;
|
||||
final topHeight = (totalHeight * ratio).clamp(60.0, totalHeight - 60.0);
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: topHeight,
|
||||
child: reveal != null
|
||||
? _RevealedTab(tab: reveal, onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId))
|
||||
: topTab.build(ctx),
|
||||
),
|
||||
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
|
||||
Expanded(child: primaryPane),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-Claude workspace tab revealed in the split region above Claude
|
||||
/// (T-233): a thin chrome header (title + close) over the tab's body, so the
|
||||
/// user can review it alongside the conversation and dismiss it back to
|
||||
/// full-Claude. The editor uses its own split path and never renders here.
|
||||
class _RevealedTab extends StatelessWidget {
|
||||
const _RevealedTab({required this.tab, required this.onClose});
|
||||
|
||||
final TabContribution tab;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 10, right: 4),
|
||||
color: tokens.panelHeader,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(resolveTabTitle(context, tab), fontSize: clideFontCaption, color: tokens.panelHeaderForeground, maxLines: 1),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Close',
|
||||
excludeSemantics: true,
|
||||
onTap: onClose,
|
||||
child: ClideTappable(
|
||||
onTap: onClose,
|
||||
tooltip: 'Close',
|
||||
builder: (_, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: tab.build(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorDragHandle extends StatefulWidget {
|
||||
const _EditorDragHandle({required this.arrangement, required this.totalHeight});
|
||||
|
||||
final LayoutArrangement arrangement;
|
||||
final double totalHeight;
|
||||
|
||||
@override
|
||||
State<_EditorDragHandle> createState() => _EditorDragHandleState();
|
||||
}
|
||||
|
||||
class _EditorDragHandleState extends State<_EditorDragHandle> {
|
||||
bool _hovered = false;
|
||||
bool _focused = false;
|
||||
double? _dragStartRatio;
|
||||
double? _dragStartY;
|
||||
|
||||
// Editor split is a 0..1 fraction; the kernel clamps to 0.15..0.70.
|
||||
// 2% per fine step, 10% per Shift step keeps keyboard feel close to
|
||||
// the pixel-based DragResizeHandle.
|
||||
static const double _stepFine = 0.02;
|
||||
static const double _stepCoarse = 0.10;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
|
||||
|
||||
final ratio = widget.arrangement.editorRatio;
|
||||
String pct(double r) => '${(r.clamp(0.15, 0.70) * 100).round()}%';
|
||||
return Semantics(
|
||||
container: true,
|
||||
slider: true,
|
||||
label: 'Editor split',
|
||||
value: pct(ratio),
|
||||
// increase/decrease actions require matching increased/decreased
|
||||
// values, or Flutter asserts on every semantics flush.
|
||||
increasedValue: pct(ratio + _stepFine),
|
||||
decreasedValue: pct(ratio - _stepFine),
|
||||
onIncrease: () => _bump(_stepFine),
|
||||
onDecrease: () => _bump(-_stepFine),
|
||||
child: FocusableActionDetector(
|
||||
onShowFocusHighlight: (v) => setState(() => _focused = v),
|
||||
shortcuts: const <ShortcutActivator, Intent>{
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp): _EditorBumpIntent(-_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown): _EditorBumpIntent(_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _EditorBumpIntent(-_stepCoarse),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _EditorBumpIntent(_stepCoarse),
|
||||
},
|
||||
actions: <Type, Action<Intent>>{
|
||||
_EditorBumpIntent: CallbackAction<_EditorBumpIntent>(
|
||||
onInvoke: (intent) {
|
||||
_bump(intent.delta);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: lineColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _bump(double delta) {
|
||||
widget.arrangement.setEditorRatio(widget.arrangement.editorRatio + delta);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorBumpIntent extends Intent {
|
||||
const _EditorBumpIntent(this.delta);
|
||||
final double delta;
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
const _ContextSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a tab's display title through i18n when it carries a key +
|
||||
/// namespace, else its static title. Shared by the slot bodies, the
|
||||
/// revealed-tab header, and the bottom icon rails.
|
||||
String resolveTabTitle(BuildContext context, TabContribution t) {
|
||||
final key = t.titleKey;
|
||||
final ns = t.i18nNamespace;
|
||||
if (key == null || ns == null) return t.title;
|
||||
return ClideKernel.of(context).i18n.string(key, namespace: ns, placeholder: t.title);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// CSI handlers: cursor movement, erase/scroll/line/char ops, device
|
||||
// attributes + status reports, margins, tab clear, repeat, and window
|
||||
// manipulation. Split out of parser.dart (T-123); dispatched from the
|
||||
// _csiHandlers table in the EscapeParser core.
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _CsiHandlers on _EscapeParserBase {
|
||||
/// `ESC [ Ps a` Cursor Horizontal Position Relative (HPR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sa/
|
||||
// void _csiHandleCursorHorizontalRelative() {
|
||||
// if (_csi.params.isEmpty) {
|
||||
// handler.cursorHorizontal(1);
|
||||
// } else {
|
||||
// handler.cursorHorizontal(_csi.params[0]);
|
||||
// }
|
||||
// }
|
||||
/// `ESC [ Ps b` Repeat Previous Character (REP)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sb/
|
||||
void _csiHandleRepeatPreviousCharacter() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.repeatPreviousCharacter(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps c` Device Attributes (DA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sc/
|
||||
void _csiHandleSendDeviceAttributes() {
|
||||
switch (_csi.prefix) {
|
||||
case Ascii.greaterThan:
|
||||
return handler.sendSecondaryDeviceAttributes();
|
||||
case Ascii.equal:
|
||||
return handler.sendTertiaryDeviceAttributes();
|
||||
default:
|
||||
handler.sendPrimaryDeviceAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps d` Cursor Vertical Position Absolute (VPA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sd/
|
||||
void _csiHandleLinePositionAbsolute() {
|
||||
var y = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
y = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.setCursorY(y - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps f` Alias: Set Cursor Position
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sf/
|
||||
void _csiHandleCursorPosition() {
|
||||
var row = 1;
|
||||
var col = 1;
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
row = _csi.params[0];
|
||||
col = _csi.params[1];
|
||||
}
|
||||
|
||||
handler.setCursor(col - 1, row - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps g` Tab Clear (TBC)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sg/
|
||||
void _csiHandelClearTabStop() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.clearTabStopUnderCursor();
|
||||
default:
|
||||
return handler.clearAllTabStops();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps n` Device Status Report [Dispatch] (DSR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sn/
|
||||
void _csiHandleDeviceStatusReport() {
|
||||
if (_csi.params.isEmpty) return;
|
||||
|
||||
switch (_csi.params[0]) {
|
||||
case 5:
|
||||
return handler.sendOperatingStatus();
|
||||
case 6:
|
||||
return handler.sendCursorPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps r` Set Top and Bottom Margins (DECSTBM)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sr/
|
||||
void _csiHandleSetMargins() {
|
||||
var top = 1;
|
||||
int? bottom;
|
||||
|
||||
if (_csi.params.length > 2) return;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
top = _csi.params[0];
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
bottom = _csi.params[1] - 1;
|
||||
}
|
||||
}
|
||||
|
||||
handler.setMargins(top - 1, bottom);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps t` Window operations [DISPATCH]
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_st/
|
||||
void _csiWindowManipulation() {
|
||||
// The sequence needs at least one parameter.
|
||||
if (_csi.params.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// Most the commands in this group are either of the scope of this package,
|
||||
// or should be disabled for security risks.
|
||||
switch (_csi.params.first) {
|
||||
// Window handling is currently not in the scope of the package.
|
||||
case 1: // Restore Terminal Window (show window if minimized)
|
||||
case 2: // Minimize Terminal Window
|
||||
case 3: // Set Terminal Window Position
|
||||
case 4: // Set Terminal Window Size in Pixels
|
||||
case 5: // Raise Terminal Window
|
||||
case 6: // Lower Terminal Window
|
||||
case 7: // Refresh/Redraw Terminal Window
|
||||
return;
|
||||
case 8: // Set Terminal Window Size (in characters)
|
||||
// This CSI contains 2 more parameters: width and height.
|
||||
if (_csi.params.length != 3) {
|
||||
return;
|
||||
}
|
||||
final rows = _csi.params[1];
|
||||
final cols = _csi.params[2];
|
||||
handler.resize(cols, rows);
|
||||
return;
|
||||
// Window handling is currently no in the scope of the package.
|
||||
case 9: // Maximize Terminal Window
|
||||
case 10: // Alias: Maximize Terminal Window
|
||||
case 11: // Report Terminal Window State
|
||||
case 13: // Report Terminal Window Position
|
||||
case 14: // Report Terminal Window Size in Pixels
|
||||
case 15: // Report Screen Size in Pixels
|
||||
case 16: // Report Cell Size in Pixels
|
||||
return;
|
||||
case 18: // Report Terminal Size (in characters)
|
||||
handler.sendSize();
|
||||
return;
|
||||
// Screen handling is currently no in the scope of the package.
|
||||
case 19: // Report Screen Size (in characters)
|
||||
// Disabled as these can a security risk.
|
||||
case 20: // Get Icon Title
|
||||
case 21: // Get Terminal Title
|
||||
// Not implemented.
|
||||
case 22: // Push Terminal Title
|
||||
case 23: // Pop Terminal Title
|
||||
return;
|
||||
// Unknown CSI.
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps A` Cursor Up (CUU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ca/
|
||||
void _csiHandleCursorUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps B` Cursor Down (CUD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cb/
|
||||
void _csiHandleCursorDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps C` Cursor Right (CUF)
|
||||
///
|
||||
/// Cursor Right (CUF)
|
||||
void _csiHandleCursorForward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps D` Cursor Left (CUB)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cd/
|
||||
void _csiHandleCursorBackward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps E` Cursor Next Line (CNL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ce/
|
||||
void _csiHandleCursorNextLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorNextLine(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps F` Cursor Previous Line (CPL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cf/
|
||||
void _csiHandleCursorPrecedingLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorPrecedingLine(amount);
|
||||
}
|
||||
|
||||
void _csiHandleCursorHorizontalAbsolute() {
|
||||
var x = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
x = _csi.params[0];
|
||||
if (x == 0) x = 1;
|
||||
}
|
||||
|
||||
handler.setCursorX(x - 1);
|
||||
}
|
||||
|
||||
/// ESC [ Ps J Erase Display [Dispatch] (ED)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cj/
|
||||
void _csiHandleEraseDisplay() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseDisplayBelow();
|
||||
case 1:
|
||||
return handler.eraseDisplayAbove();
|
||||
case 2:
|
||||
return handler.eraseDisplay();
|
||||
case 3:
|
||||
return handler.eraseScrollbackOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps K` Erase Line [Dispatch] (EL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ck/
|
||||
void _csiHandleEraseLine() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseLineRight();
|
||||
case 1:
|
||||
return handler.eraseLineLeft();
|
||||
case 2:
|
||||
return handler.eraseLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps L` Insert Line (IL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cl/
|
||||
void _csiHandleInsertLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps M Delete Line (DL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cm/
|
||||
void _csiHandleDeleteLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps P Delete Character (DCH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cp/
|
||||
void _csiHandleDelete() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps S` Scroll Up (SU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cs/
|
||||
void _csiHandleScrollUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollUp(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps T `Scroll Down (SD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ct_1param/
|
||||
void _csiHandleScrollDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollDown(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps X` Erase Character (ECH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cx/
|
||||
void _csiHandleEraseCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.eraseChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps @` Insert Blanks (ICH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_x40_at/
|
||||
///
|
||||
/// Inserts amount spaces at current cursor position moving existing cell
|
||||
/// contents to the right. The contents of the amount right-most columns in
|
||||
/// the scroll region are lost. The cursor position is not changed.
|
||||
void _csiHandleInsertBlankCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertBlankChars(amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// ANSI + DEC private mode set/reset (CSI h / CSI l, with and without
|
||||
// the ? prefix). Split out of parser.dart (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _ModeHandlers on _EscapeParserBase {
|
||||
/// - `ESC [ [ Pm ] h Set Mode (SM)` https://terminalguide.namepad.de/seq/csi_sm/
|
||||
/// - `ESC [ ? [ Pm ] h` Set Mode (?) (SM) https://terminalguide.namepad.de/seq/csi_sh__p/
|
||||
/// - `ESC [ [ Pm ] l` Reset Mode (RM) https://terminalguide.namepad.de/seq/csi_rm/
|
||||
/// - `ESC [ ? [ Pm ] l` Reset Mode (?) (RM) https://terminalguide.namepad.de/seq/csi_sl__p/
|
||||
void _csiHandleMode() {
|
||||
final isEnabled = _csi.finalByte == Ascii.h;
|
||||
|
||||
final isDecModes = _csi.prefix == Ascii.questionMark;
|
||||
|
||||
if (isDecModes) {
|
||||
for (var mode in _csi.params) {
|
||||
_setDecMode(mode, isEnabled);
|
||||
}
|
||||
} else {
|
||||
for (var mode in _csi.params) {
|
||||
_setMode(mode, isEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _setMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 4:
|
||||
return handler.setInsertMode(enabled);
|
||||
case 20:
|
||||
return handler.setLineFeedMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
void _setDecMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 1:
|
||||
return handler.setCursorKeysMode(enabled);
|
||||
case 3:
|
||||
return handler.setColumnMode(enabled);
|
||||
case 5:
|
||||
return handler.setReverseDisplayMode(enabled);
|
||||
case 6:
|
||||
return handler.setOriginMode(enabled);
|
||||
case 7:
|
||||
return handler.setAutoWrapMode(enabled);
|
||||
case 9:
|
||||
return enabled ? handler.setMouseMode(MouseMode.clickOnly) : handler.setMouseMode(MouseMode.none);
|
||||
case 12:
|
||||
case 13:
|
||||
return handler.setCursorBlinkMode(enabled);
|
||||
case 25:
|
||||
return handler.setCursorVisibleMode(enabled);
|
||||
case 47:
|
||||
if (enabled) {
|
||||
return handler.useAltBuffer();
|
||||
} else {
|
||||
return handler.useMainBuffer();
|
||||
}
|
||||
case 66:
|
||||
return handler.setAppKeypadMode(enabled);
|
||||
case 1000:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1001:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1002:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollDrag) : handler.setMouseMode(MouseMode.none);
|
||||
case 1003:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollMove) : handler.setMouseMode(MouseMode.none);
|
||||
case 1004:
|
||||
return handler.setReportFocusMode(enabled);
|
||||
case 1005:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.utf) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1006:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.sgr) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1007:
|
||||
return handler.setAltBufferMouseScrollMode(enabled);
|
||||
case 1015:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.urxvt) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1047:
|
||||
if (enabled) {
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.clearAltBuffer();
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 1048:
|
||||
if (enabled) {
|
||||
return handler.saveCursor();
|
||||
} else {
|
||||
return handler.restoreCursor();
|
||||
}
|
||||
case 1049:
|
||||
if (enabled) {
|
||||
handler.saveCursor();
|
||||
handler.clearAltBuffer();
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 2004:
|
||||
return handler.setBracketedPasteMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownDecMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// OSC string parsing + dispatch (title / icon name / private
|
||||
// pass-through), BEL or ST terminated. Split out of parser.dart
|
||||
// (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _OscHandlers on _EscapeParserBase {
|
||||
/// Parse a OSC sequence from the queue. Returns true if a sequence was
|
||||
/// found and handled.
|
||||
bool _escHandleOSC() {
|
||||
final consumed = _consumeOsc();
|
||||
if (!consumed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_osc.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common OSCs
|
||||
if (_osc.length >= 2) {
|
||||
final ps = _osc[0];
|
||||
final pt = _osc[1];
|
||||
|
||||
switch (ps) {
|
||||
case '0':
|
||||
handler.setTitle(pt);
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '1':
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '2':
|
||||
handler.setTitle(pt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Private extensions
|
||||
handler.unknownOSC(_osc[0], _osc.sublist(1));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
final _osc = <String>[];
|
||||
|
||||
bool _consumeOsc() {
|
||||
_osc.clear();
|
||||
final param = StringBuffer();
|
||||
|
||||
while (true) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final char = _queue.consume();
|
||||
|
||||
// OSC terminates with BEL
|
||||
if (char == Ascii.BEL) {
|
||||
_osc.add(param.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// OSC terminates with ST
|
||||
if (char == Ascii.ESC) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queue.consume() == Ascii.backslash) {
|
||||
_osc.add(param.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Parse next parameter
|
||||
if (char == Ascii.semicolon) {
|
||||
_osc.add(param.toString());
|
||||
param.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
param.writeCharCode(char);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,18 @@ import 'package:clide/src/terminal/src/utils/byte_consumer.dart';
|
||||
import 'package:clide/src/terminal/src/utils/char_code.dart';
|
||||
import 'package:clide/src/terminal/src/utils/lookup_table.dart';
|
||||
|
||||
/// [EscapeParser] translates control characters and escape sequences into
|
||||
/// function calls that the terminal can handle.
|
||||
///
|
||||
/// Design goals:
|
||||
/// * Zero object allocation during processing.
|
||||
/// * No internal state. Same input will always produce same output.
|
||||
class EscapeParser {
|
||||
final EscapeHandler handler;
|
||||
part 'csi_handlers.dart';
|
||||
part 'mode_handlers.dart';
|
||||
part 'osc_handlers.dart';
|
||||
part 'sgr_handlers.dart';
|
||||
|
||||
EscapeParser(this.handler);
|
||||
/// Shared parser state the handler mixins operate on: the escape
|
||||
/// handler sink, the byte queue, token bookkeeping, and the reusable
|
||||
/// CSI scratch object (zero-allocation design — see [EscapeParser]).
|
||||
abstract class _EscapeParserBase {
|
||||
_EscapeParserBase(this.handler);
|
||||
|
||||
final EscapeHandler handler;
|
||||
|
||||
final _queue = ByteConsumer();
|
||||
|
||||
@@ -27,6 +29,24 @@ class EscapeParser {
|
||||
/// End of sequence or character being processed. Useful for debugging.
|
||||
int get tokenEnd => _queue.totalConsumed;
|
||||
|
||||
/// The last parsed [_Csi]. This is a mutable singletion by design to reduce
|
||||
/// object allocations.
|
||||
final _csi = _Csi(finalByte: 0, params: []);
|
||||
}
|
||||
|
||||
/// [EscapeParser] translates control characters and escape sequences into
|
||||
/// function calls that the terminal can handle.
|
||||
///
|
||||
/// Design goals:
|
||||
/// * Zero object allocation during processing.
|
||||
/// * No internal state. Same input will always produce same output.
|
||||
///
|
||||
/// The handler groups live as mixins in this library's part files
|
||||
/// (csi/sgr/mode/osc handlers, T-123); this core owns the byte queue,
|
||||
/// the dispatch tables, and the ESC/CSI consumers.
|
||||
class EscapeParser extends _EscapeParserBase with _CsiHandlers, _ModeHandlers, _OscHandlers, _SgrHandlers {
|
||||
EscapeParser(super.handler);
|
||||
|
||||
void write(String chunk) {
|
||||
_queue.unrefConsumedBlocks();
|
||||
_queue.add(chunk);
|
||||
@@ -197,7 +217,11 @@ class EscapeParser {
|
||||
final consumed = _consumeCsi();
|
||||
if (!consumed) return false;
|
||||
|
||||
final csiHandler = _csiHandlers[_csi.finalByte];
|
||||
// An intermediate byte changes the meaning of the final byte
|
||||
// (`CSI 5 SP @` is scroll-left, not insert-blank). None of the
|
||||
// intermediate forms are implemented, so report them as unknown
|
||||
// rather than mis-dispatching on the bare final byte.
|
||||
final csiHandler = _csi.intermediates.isEmpty ? _csiHandlers[_csi.finalByte] : null;
|
||||
|
||||
if (csiHandler == null) {
|
||||
handler.unknownCSI(_csi.finalByte);
|
||||
@@ -208,10 +232,6 @@ class EscapeParser {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The last parsed [_Csi]. This is a mutable singletion by design to reduce
|
||||
/// object allocations.
|
||||
final _csi = _Csi(finalByte: 0, params: []);
|
||||
|
||||
/// Parse a CSI from the head of the queue. Return false if the CSI isn't
|
||||
/// complete. After a CSI is successfully parsed, [_csi] is updated.
|
||||
bool _consumeCsi() {
|
||||
@@ -220,6 +240,8 @@ class EscapeParser {
|
||||
}
|
||||
|
||||
_csi.params.clear();
|
||||
_csi.subParam.clear();
|
||||
_csi.intermediates.clear();
|
||||
|
||||
// test whether the csi is a `CSI ? Ps ...` or `CSI Ps ...`
|
||||
final prefix = _queue.peek();
|
||||
@@ -232,6 +254,11 @@ class EscapeParser {
|
||||
|
||||
var param = 0;
|
||||
var hasParam = false;
|
||||
// Whether the value being accumulated was attached to its predecessor
|
||||
// with a colon (ECMA-48 sub-parameter separator, ITU T.416 SGR colors).
|
||||
// Before T-369 colons were silently dropped mid-sequence, fusing
|
||||
// `38:2:255:0:0` into one bogus parameter.
|
||||
var linkedToPrev = false;
|
||||
while (true) {
|
||||
// The sequence isn't completed, just ignore it.
|
||||
if (_queue.isEmpty) {
|
||||
@@ -243,8 +270,21 @@ class EscapeParser {
|
||||
if (char == Ascii.semicolon) {
|
||||
if (hasParam) {
|
||||
_csi.params.add(param);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
}
|
||||
param = 0;
|
||||
linkedToPrev = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == Ascii.colon) {
|
||||
// Push the current value even when empty — `38:2::r:g:b` carries an
|
||||
// empty colorspace slot that must keep its position in the group.
|
||||
_csi.params.add(hasParam ? param : 0);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
hasParam = true;
|
||||
param = 0;
|
||||
linkedToPrev = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -255,14 +295,20 @@ class EscapeParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char >= Ascii.space && char <= Ascii.slash) {
|
||||
_csi.intermediates.add(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char > Ascii.NULL && char < Ascii.num0) {
|
||||
// intermediates.add(char);
|
||||
// Other C0 controls embedded in a CSI: ignore, as before.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char >= Ascii.atSign && char <= Ascii.tilde) {
|
||||
if (hasParam) {
|
||||
_csi.params.add(param);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
}
|
||||
|
||||
_csi.finalByte = char;
|
||||
@@ -302,827 +348,26 @@ class EscapeParser {
|
||||
'X'.codeUnitAt(0): _csiHandleEraseCharacters,
|
||||
'@'.codeUnitAt(0): _csiHandleInsertBlankCharacters,
|
||||
});
|
||||
|
||||
/// `ESC [ Ps a` Cursor Horizontal Position Relative (HPR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sa/
|
||||
// void _csiHandleCursorHorizontalRelative() {
|
||||
// if (_csi.params.isEmpty) {
|
||||
// handler.cursorHorizontal(1);
|
||||
// } else {
|
||||
// handler.cursorHorizontal(_csi.params[0]);
|
||||
// }
|
||||
// }
|
||||
|
||||
/// `ESC [ Ps b` Repeat Previous Character (REP)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sb/
|
||||
void _csiHandleRepeatPreviousCharacter() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.repeatPreviousCharacter(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps c` Device Attributes (DA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sc/
|
||||
void _csiHandleSendDeviceAttributes() {
|
||||
switch (_csi.prefix) {
|
||||
case Ascii.greaterThan:
|
||||
return handler.sendSecondaryDeviceAttributes();
|
||||
case Ascii.equal:
|
||||
return handler.sendTertiaryDeviceAttributes();
|
||||
default:
|
||||
handler.sendPrimaryDeviceAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps d` Cursor Vertical Position Absolute (VPA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sd/
|
||||
void _csiHandleLinePositionAbsolute() {
|
||||
var y = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
y = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.setCursorY(y - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps f` Alias: Set Cursor Position
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sf/
|
||||
void _csiHandleCursorPosition() {
|
||||
var row = 1;
|
||||
var col = 1;
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
row = _csi.params[0];
|
||||
col = _csi.params[1];
|
||||
}
|
||||
|
||||
handler.setCursor(col - 1, row - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps g` Tab Clear (TBC)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sg/
|
||||
void _csiHandelClearTabStop() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.clearTabStopUnderCursor();
|
||||
default:
|
||||
return handler.clearAllTabStops();
|
||||
}
|
||||
}
|
||||
|
||||
/// - `ESC [ [ Pm ] h Set Mode (SM)` https://terminalguide.namepad.de/seq/csi_sm/
|
||||
/// - `ESC [ ? [ Pm ] h` Set Mode (?) (SM) https://terminalguide.namepad.de/seq/csi_sh__p/
|
||||
/// - `ESC [ [ Pm ] l` Reset Mode (RM) https://terminalguide.namepad.de/seq/csi_rm/
|
||||
/// - `ESC [ ? [ Pm ] l` Reset Mode (?) (RM) https://terminalguide.namepad.de/seq/csi_sl__p/
|
||||
void _csiHandleMode() {
|
||||
final isEnabled = _csi.finalByte == Ascii.h;
|
||||
|
||||
final isDecModes = _csi.prefix == Ascii.questionMark;
|
||||
|
||||
if (isDecModes) {
|
||||
for (var mode in _csi.params) {
|
||||
_setDecMode(mode, isEnabled);
|
||||
}
|
||||
} else {
|
||||
for (var mode in _csi.params) {
|
||||
_setMode(mode, isEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ [ Ps ] m` Select Graphic Rendition (SGR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sm/
|
||||
void _csiHandleSgr() {
|
||||
final params = _csi.params;
|
||||
|
||||
if (params.isEmpty) {
|
||||
return handler.resetCursorStyle();
|
||||
}
|
||||
|
||||
for (var i = 0; i < _csi.params.length; i++) {
|
||||
final param = params[i];
|
||||
switch (param) {
|
||||
case 0:
|
||||
handler.resetCursorStyle();
|
||||
continue;
|
||||
case 1:
|
||||
handler.setCursorBold();
|
||||
continue;
|
||||
case 2:
|
||||
handler.setCursorFaint();
|
||||
continue;
|
||||
case 3:
|
||||
handler.setCursorItalic();
|
||||
continue;
|
||||
case 4:
|
||||
handler.setCursorUnderline();
|
||||
continue;
|
||||
case 5:
|
||||
handler.setCursorBlink();
|
||||
continue;
|
||||
case 7:
|
||||
handler.setCursorInverse();
|
||||
continue;
|
||||
case 8:
|
||||
handler.setCursorInvisible();
|
||||
continue;
|
||||
case 9:
|
||||
handler.setCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 21:
|
||||
handler.unsetCursorBold();
|
||||
continue;
|
||||
case 22:
|
||||
handler.unsetCursorFaint();
|
||||
continue;
|
||||
case 23:
|
||||
handler.unsetCursorItalic();
|
||||
continue;
|
||||
case 24:
|
||||
handler.unsetCursorUnderline();
|
||||
continue;
|
||||
case 25:
|
||||
handler.unsetCursorBlink();
|
||||
continue;
|
||||
case 27:
|
||||
handler.unsetCursorInverse();
|
||||
continue;
|
||||
case 28:
|
||||
handler.unsetCursorInvisible();
|
||||
continue;
|
||||
case 29:
|
||||
handler.unsetCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 30:
|
||||
handler.setForegroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 31:
|
||||
handler.setForegroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 32:
|
||||
handler.setForegroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 33:
|
||||
handler.setForegroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 34:
|
||||
handler.setForegroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 35:
|
||||
handler.setForegroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 36:
|
||||
handler.setForegroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 37:
|
||||
handler.setForegroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 38:
|
||||
final mode = params[i + 1];
|
||||
switch (mode) {
|
||||
case 2:
|
||||
final r = params[i + 2];
|
||||
final g = params[i + 3];
|
||||
final b = params[i + 4];
|
||||
handler.setForegroundColorRgb(r, g, b);
|
||||
i += 4;
|
||||
break;
|
||||
case 5:
|
||||
final index = params[i + 2];
|
||||
handler.setForegroundColor256(index);
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
case 39:
|
||||
handler.resetForeground();
|
||||
continue;
|
||||
|
||||
case 40:
|
||||
handler.setBackgroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 41:
|
||||
handler.setBackgroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 42:
|
||||
handler.setBackgroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 43:
|
||||
handler.setBackgroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 44:
|
||||
handler.setBackgroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 45:
|
||||
handler.setBackgroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 46:
|
||||
handler.setBackgroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 47:
|
||||
handler.setBackgroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 48:
|
||||
final mode = params[i + 1];
|
||||
switch (mode) {
|
||||
case 2:
|
||||
final r = params[i + 2];
|
||||
final g = params[i + 3];
|
||||
final b = params[i + 4];
|
||||
handler.setBackgroundColorRgb(r, g, b);
|
||||
i += 4;
|
||||
break;
|
||||
case 5:
|
||||
final index = params[i + 2];
|
||||
handler.setBackgroundColor256(index);
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
case 49:
|
||||
handler.resetBackground();
|
||||
continue;
|
||||
|
||||
case 90:
|
||||
handler.setForegroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 91:
|
||||
handler.setForegroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 92:
|
||||
handler.setForegroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 93:
|
||||
handler.setForegroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 94:
|
||||
handler.setForegroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 95:
|
||||
handler.setForegroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 96:
|
||||
handler.setForegroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 97:
|
||||
handler.setForegroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
case 100:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 101:
|
||||
handler.setBackgroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 102:
|
||||
handler.setBackgroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 103:
|
||||
handler.setBackgroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 104:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 105:
|
||||
handler.setBackgroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 106:
|
||||
handler.setBackgroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 107:
|
||||
handler.setBackgroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
default:
|
||||
handler.unsupportedStyle(param);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps n` Device Status Report [Dispatch] (DSR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sn/
|
||||
void _csiHandleDeviceStatusReport() {
|
||||
if (_csi.params.isEmpty) return;
|
||||
|
||||
switch (_csi.params[0]) {
|
||||
case 5:
|
||||
return handler.sendOperatingStatus();
|
||||
case 6:
|
||||
return handler.sendCursorPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps r` Set Top and Bottom Margins (DECSTBM)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sr/
|
||||
void _csiHandleSetMargins() {
|
||||
var top = 1;
|
||||
int? bottom;
|
||||
|
||||
if (_csi.params.length > 2) return;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
top = _csi.params[0];
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
bottom = _csi.params[1] - 1;
|
||||
}
|
||||
}
|
||||
|
||||
handler.setMargins(top - 1, bottom);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps t` Window operations [DISPATCH]
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_st/
|
||||
void _csiWindowManipulation() {
|
||||
// The sequence needs at least one parameter.
|
||||
if (_csi.params.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// Most the commands in this group are either of the scope of this package,
|
||||
// or should be disabled for security risks.
|
||||
switch (_csi.params.first) {
|
||||
// Window handling is currently not in the scope of the package.
|
||||
case 1: // Restore Terminal Window (show window if minimized)
|
||||
case 2: // Minimize Terminal Window
|
||||
case 3: // Set Terminal Window Position
|
||||
case 4: // Set Terminal Window Size in Pixels
|
||||
case 5: // Raise Terminal Window
|
||||
case 6: // Lower Terminal Window
|
||||
case 7: // Refresh/Redraw Terminal Window
|
||||
return;
|
||||
case 8: // Set Terminal Window Size (in characters)
|
||||
// This CSI contains 2 more parameters: width and height.
|
||||
if (_csi.params.length != 3) {
|
||||
return;
|
||||
}
|
||||
final rows = _csi.params[1];
|
||||
final cols = _csi.params[2];
|
||||
handler.resize(cols, rows);
|
||||
return;
|
||||
// Window handling is currently no in the scope of the package.
|
||||
case 9: // Maximize Terminal Window
|
||||
case 10: // Alias: Maximize Terminal Window
|
||||
case 11: // Report Terminal Window State
|
||||
case 13: // Report Terminal Window Position
|
||||
case 14: // Report Terminal Window Size in Pixels
|
||||
case 15: // Report Screen Size in Pixels
|
||||
case 16: // Report Cell Size in Pixels
|
||||
return;
|
||||
case 18: // Report Terminal Size (in characters)
|
||||
handler.sendSize();
|
||||
return;
|
||||
// Screen handling is currently no in the scope of the package.
|
||||
case 19: // Report Screen Size (in characters)
|
||||
// Disabled as these can a security risk.
|
||||
case 20: // Get Icon Title
|
||||
case 21: // Get Terminal Title
|
||||
// Not implemented.
|
||||
case 22: // Push Terminal Title
|
||||
case 23: // Pop Terminal Title
|
||||
return;
|
||||
// Unknown CSI.
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps A` Cursor Up (CUU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ca/
|
||||
void _csiHandleCursorUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps B` Cursor Down (CUD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cb/
|
||||
void _csiHandleCursorDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps C` Cursor Right (CUF)
|
||||
///
|
||||
/// Cursor Right (CUF)
|
||||
void _csiHandleCursorForward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps D` Cursor Left (CUB)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cd/
|
||||
void _csiHandleCursorBackward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps E` Cursor Next Line (CNL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ce/
|
||||
void _csiHandleCursorNextLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorNextLine(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps F` Cursor Previous Line (CPL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cf/
|
||||
void _csiHandleCursorPrecedingLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorPrecedingLine(amount);
|
||||
}
|
||||
|
||||
void _csiHandleCursorHorizontalAbsolute() {
|
||||
var x = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
x = _csi.params[0];
|
||||
if (x == 0) x = 1;
|
||||
}
|
||||
|
||||
handler.setCursorX(x - 1);
|
||||
}
|
||||
|
||||
/// ESC [ Ps J Erase Display [Dispatch] (ED)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cj/
|
||||
void _csiHandleEraseDisplay() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseDisplayBelow();
|
||||
case 1:
|
||||
return handler.eraseDisplayAbove();
|
||||
case 2:
|
||||
return handler.eraseDisplay();
|
||||
case 3:
|
||||
return handler.eraseScrollbackOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps K` Erase Line [Dispatch] (EL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ck/
|
||||
void _csiHandleEraseLine() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseLineRight();
|
||||
case 1:
|
||||
return handler.eraseLineLeft();
|
||||
case 2:
|
||||
return handler.eraseLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps L` Insert Line (IL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cl/
|
||||
void _csiHandleInsertLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps M Delete Line (DL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cm/
|
||||
void _csiHandleDeleteLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps P Delete Character (DCH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cp/
|
||||
void _csiHandleDelete() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps S` Scroll Up (SU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cs/
|
||||
void _csiHandleScrollUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollUp(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps T `Scroll Down (SD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ct_1param/
|
||||
void _csiHandleScrollDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollDown(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps X` Erase Character (ECH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cx/
|
||||
void _csiHandleEraseCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.eraseChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps @` Insert Blanks (ICH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_x40_at/
|
||||
///
|
||||
/// Inserts amount spaces at current cursor position moving existing cell
|
||||
/// contents to the right. The contents of the amount right-most columns in
|
||||
/// the scroll region are lost. The cursor position is not changed.
|
||||
void _csiHandleInsertBlankCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertBlankChars(amount);
|
||||
}
|
||||
|
||||
void _setMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 4:
|
||||
return handler.setInsertMode(enabled);
|
||||
case 20:
|
||||
return handler.setLineFeedMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
void _setDecMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 1:
|
||||
return handler.setCursorKeysMode(enabled);
|
||||
case 3:
|
||||
return handler.setColumnMode(enabled);
|
||||
case 5:
|
||||
return handler.setReverseDisplayMode(enabled);
|
||||
case 6:
|
||||
return handler.setOriginMode(enabled);
|
||||
case 7:
|
||||
return handler.setAutoWrapMode(enabled);
|
||||
case 9:
|
||||
return enabled ? handler.setMouseMode(MouseMode.clickOnly) : handler.setMouseMode(MouseMode.none);
|
||||
case 12:
|
||||
case 13:
|
||||
return handler.setCursorBlinkMode(enabled);
|
||||
case 25:
|
||||
return handler.setCursorVisibleMode(enabled);
|
||||
case 47:
|
||||
if (enabled) {
|
||||
return handler.useAltBuffer();
|
||||
} else {
|
||||
return handler.useMainBuffer();
|
||||
}
|
||||
case 66:
|
||||
return handler.setAppKeypadMode(enabled);
|
||||
case 1000:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1001:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1002:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollDrag) : handler.setMouseMode(MouseMode.none);
|
||||
case 1003:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollMove) : handler.setMouseMode(MouseMode.none);
|
||||
case 1004:
|
||||
return handler.setReportFocusMode(enabled);
|
||||
case 1005:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.utf) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1006:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.sgr) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1007:
|
||||
return handler.setAltBufferMouseScrollMode(enabled);
|
||||
case 1015:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.urxvt) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1047:
|
||||
if (enabled) {
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.clearAltBuffer();
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 1048:
|
||||
if (enabled) {
|
||||
return handler.saveCursor();
|
||||
} else {
|
||||
return handler.restoreCursor();
|
||||
}
|
||||
case 1049:
|
||||
if (enabled) {
|
||||
handler.saveCursor();
|
||||
handler.clearAltBuffer();
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 2004:
|
||||
return handler.setBracketedPasteMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownDecMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a OSC sequence from the queue. Returns true if a sequence was
|
||||
/// found and handled.
|
||||
bool _escHandleOSC() {
|
||||
final consumed = _consumeOsc();
|
||||
if (!consumed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_osc.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common OSCs
|
||||
if (_osc.length >= 2) {
|
||||
final ps = _osc[0];
|
||||
final pt = _osc[1];
|
||||
|
||||
switch (ps) {
|
||||
case '0':
|
||||
handler.setTitle(pt);
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '1':
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '2':
|
||||
handler.setTitle(pt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Private extensions
|
||||
handler.unknownOSC(_osc[0], _osc.sublist(1));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
final _osc = <String>[];
|
||||
|
||||
bool _consumeOsc() {
|
||||
_osc.clear();
|
||||
final param = StringBuffer();
|
||||
|
||||
while (true) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final char = _queue.consume();
|
||||
|
||||
// OSC terminates with BEL
|
||||
if (char == Ascii.BEL) {
|
||||
_osc.add(param.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// OSC terminates with ST
|
||||
if (char == Ascii.ESC) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queue.consume() == Ascii.backslash) {
|
||||
_osc.add(param.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Parse next parameter
|
||||
if (char == Ascii.semicolon) {
|
||||
_osc.add(param.toString());
|
||||
param.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
param.writeCharCode(char);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Csi {
|
||||
_Csi({
|
||||
required this.params,
|
||||
required this.finalByte,
|
||||
// required this.intermediates,
|
||||
});
|
||||
_Csi({required this.params, required this.finalByte});
|
||||
|
||||
int? prefix;
|
||||
|
||||
List<int> params;
|
||||
|
||||
/// Parallel to [params]: true when that parameter was attached to its
|
||||
/// predecessor with a colon (ECMA-48 sub-parameter, ITU T.416 — T-369).
|
||||
final List<bool> subParam = [];
|
||||
|
||||
int finalByte;
|
||||
// final List<int> intermediates;
|
||||
|
||||
/// Intermediate bytes (0x20–0x2f) between the parameters and the final
|
||||
/// byte — `SP` in `CSI Ps SP q` (DECSCUSR), `!` in `CSI ! p` (DECSTR).
|
||||
/// They change the meaning of the final byte, so dispatch must not fall
|
||||
/// through to the bare-final handler when any are present.
|
||||
final List<int> intermediates = [];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// SGR (Select Graphic Rendition) handling, including the guarded
|
||||
// extended-color (38/48) path with ITU T.416 colon sub-parameters
|
||||
// (T-369). Split out of parser.dart (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _SgrHandlers on _EscapeParserBase {
|
||||
/// `ESC [ [ Ps ] m` Select Graphic Rendition (SGR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sm/
|
||||
void _csiHandleSgr() {
|
||||
final params = _csi.params;
|
||||
|
||||
if (params.isEmpty) {
|
||||
return handler.resetCursorStyle();
|
||||
}
|
||||
|
||||
for (var i = 0; i < _csi.params.length; i++) {
|
||||
final param = params[i];
|
||||
switch (param) {
|
||||
case 0:
|
||||
handler.resetCursorStyle();
|
||||
continue;
|
||||
case 1:
|
||||
handler.setCursorBold();
|
||||
continue;
|
||||
case 2:
|
||||
handler.setCursorFaint();
|
||||
continue;
|
||||
case 3:
|
||||
handler.setCursorItalic();
|
||||
continue;
|
||||
case 4:
|
||||
handler.setCursorUnderline();
|
||||
continue;
|
||||
case 5:
|
||||
handler.setCursorBlink();
|
||||
continue;
|
||||
case 7:
|
||||
handler.setCursorInverse();
|
||||
continue;
|
||||
case 8:
|
||||
handler.setCursorInvisible();
|
||||
continue;
|
||||
case 9:
|
||||
handler.setCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 21:
|
||||
handler.unsetCursorBold();
|
||||
continue;
|
||||
case 22:
|
||||
handler.unsetCursorFaint();
|
||||
continue;
|
||||
case 23:
|
||||
handler.unsetCursorItalic();
|
||||
continue;
|
||||
case 24:
|
||||
handler.unsetCursorUnderline();
|
||||
continue;
|
||||
case 25:
|
||||
handler.unsetCursorBlink();
|
||||
continue;
|
||||
case 27:
|
||||
handler.unsetCursorInverse();
|
||||
continue;
|
||||
case 28:
|
||||
handler.unsetCursorInvisible();
|
||||
continue;
|
||||
case 29:
|
||||
handler.unsetCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 30:
|
||||
handler.setForegroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 31:
|
||||
handler.setForegroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 32:
|
||||
handler.setForegroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 33:
|
||||
handler.setForegroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 34:
|
||||
handler.setForegroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 35:
|
||||
handler.setForegroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 36:
|
||||
handler.setForegroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 37:
|
||||
handler.setForegroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 38:
|
||||
i = _csiHandleExtendedColor(i, foreground: true);
|
||||
continue;
|
||||
case 39:
|
||||
handler.resetForeground();
|
||||
continue;
|
||||
|
||||
case 40:
|
||||
handler.setBackgroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 41:
|
||||
handler.setBackgroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 42:
|
||||
handler.setBackgroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 43:
|
||||
handler.setBackgroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 44:
|
||||
handler.setBackgroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 45:
|
||||
handler.setBackgroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 46:
|
||||
handler.setBackgroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 47:
|
||||
handler.setBackgroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 48:
|
||||
i = _csiHandleExtendedColor(i, foreground: false);
|
||||
continue;
|
||||
case 49:
|
||||
handler.resetBackground();
|
||||
continue;
|
||||
|
||||
case 90:
|
||||
handler.setForegroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 91:
|
||||
handler.setForegroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 92:
|
||||
handler.setForegroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 93:
|
||||
handler.setForegroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 94:
|
||||
handler.setForegroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 95:
|
||||
handler.setForegroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 96:
|
||||
handler.setForegroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 97:
|
||||
handler.setForegroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
case 100:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 101:
|
||||
handler.setBackgroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 102:
|
||||
handler.setBackgroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 103:
|
||||
handler.setBackgroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 104:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 105:
|
||||
handler.setBackgroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 106:
|
||||
handler.setBackgroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 107:
|
||||
handler.setBackgroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
default:
|
||||
handler.unsupportedStyle(param);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extended fg/bg color (SGR 38/48), semicolon or colon form.
|
||||
///
|
||||
/// Returns the index of the last parameter consumed. Never reads past the
|
||||
/// end of the parameter list — a truncated sequence (`ESC [38m`,
|
||||
/// `ESC [38;2;255m`) is ignored instead of throwing; an emulator must never
|
||||
/// throw on hostile bytes (T-369). Colon-form sub-parameters per ITU T.416
|
||||
/// (`38:2:r:g:b`, `38:2:<colorspace>:r:g:b`, `38:5:n`) are treated as one
|
||||
/// logical group: parsed equivalently to the semicolon form, and dropped
|
||||
/// whole when malformed so they never spill into neighbouring parameters.
|
||||
int _csiHandleExtendedColor(int i, {required bool foreground}) {
|
||||
final params = _csi.params;
|
||||
final sub = _csi.subParam;
|
||||
|
||||
// End of the colon-linked group starting at params[i] (exclusive).
|
||||
var end = i + 1;
|
||||
while (end < params.length && sub[end]) {
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end > i + 1) {
|
||||
// Colon form. Group is params[i..end-1]; n includes the 38/48 itself.
|
||||
final n = end - i;
|
||||
final mode = params[i + 1];
|
||||
if (mode == 5 && n >= 3) {
|
||||
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
|
||||
} else if (mode == 2) {
|
||||
// A 6+ element group carries the T.416 colorspace id slot — skip it.
|
||||
final base = n >= 6 ? i + 3 : i + 2;
|
||||
if (base + 2 < end) {
|
||||
foreground
|
||||
? handler.setForegroundColorRgb(params[base], params[base + 1], params[base + 2])
|
||||
: handler.setBackgroundColorRgb(params[base], params[base + 1], params[base + 2]);
|
||||
}
|
||||
}
|
||||
return end - 1;
|
||||
}
|
||||
|
||||
// Semicolon form (legacy).
|
||||
if (i + 1 >= params.length) return i; // bare 38/48 — ignore
|
||||
switch (params[i + 1]) {
|
||||
case 2:
|
||||
if (i + 4 >= params.length) return params.length - 1; // truncated — ignore
|
||||
foreground
|
||||
? handler.setForegroundColorRgb(params[i + 2], params[i + 3], params[i + 4])
|
||||
: handler.setBackgroundColorRgb(params[i + 2], params[i + 3], params[i + 4]);
|
||||
return i + 4;
|
||||
case 5:
|
||||
if (i + 2 >= params.length) return params.length - 1; // truncated — ignore
|
||||
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
|
||||
return i + 2;
|
||||
}
|
||||
// Unknown mode — consume it so it isn't re-interpreted as an SGR code.
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'dart:convert' show ByteConversionSink, Utf8Decoder;
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:clide/src/terminal/src/base/observable.dart';
|
||||
@@ -215,11 +216,28 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
|
||||
/// Writes the data from the underlying program to the terminal. Calling this
|
||||
/// updates the states of the terminal and emits events such as [onBell] or
|
||||
/// [onTitleChange] when the escape sequences in [data] request it.
|
||||
///
|
||||
/// Byte-stream consumers (PTY output, file tails) should use [writeBytes]
|
||||
/// instead — decoding per-chunk corrupts a multi-byte rune split across
|
||||
/// reads (T-373). This String entry point stays for tests and
|
||||
/// programmatic writes.
|
||||
void write(String data) {
|
||||
_parser.write(data);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Persistent chunked UTF-8 decoder feeding [write] — carries partial
|
||||
/// rune state across [writeBytes] calls so a glyph split across two PTY
|
||||
/// reads still renders as one glyph (T-373).
|
||||
late final ByteConversionSink _byteSink = const Utf8Decoder(allowMalformed: true).startChunkedConversion(_WriteSink(this));
|
||||
|
||||
/// Byte-stream twin of [write]: decodes UTF-8 with state retained across
|
||||
/// calls, so chunk boundaries can never split a rune into U+FFFD garbage.
|
||||
void writeBytes(List<int> bytes) {
|
||||
if (bytes.isEmpty) return;
|
||||
_byteSink.add(bytes);
|
||||
}
|
||||
|
||||
/// Sends a key event to the underlying program.
|
||||
///
|
||||
/// See also:
|
||||
@@ -863,3 +881,15 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
|
||||
onPrivateOSC?.call(ps, pt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes the chunked UTF-8 decoder's output into [Terminal.write] (T-373).
|
||||
class _WriteSink implements Sink<String> {
|
||||
_WriteSink(this._terminal);
|
||||
final Terminal _terminal;
|
||||
|
||||
@override
|
||||
void add(String data) => _terminal.write(data);
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Replay-latest broadcast value holder (T-386).
|
||||
///
|
||||
/// Broadcast streams drop the current value for late subscribers — the
|
||||
/// recurring bug factory behind T-274 (status bar blank because the
|
||||
/// `system/init` event fired before the pane subscribed) and the
|
||||
/// per-site `initialData` workarounds. A [ValueStream] carries STATE,
|
||||
/// not events: every new subscriber immediately receives the latest
|
||||
/// value (when one exists), then live updates.
|
||||
///
|
||||
/// Pure Dart — usable from the IPC/daemon layer and under `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
class ValueStream<T> {
|
||||
ValueStream();
|
||||
|
||||
ValueStream.seeded(T value) : _value = value, _hasValue = true;
|
||||
|
||||
final StreamController<T> _ctl = StreamController<T>.broadcast();
|
||||
T? _value;
|
||||
bool _hasValue = false;
|
||||
|
||||
/// Whether a value has been added (or seeded) yet. A fresh, unseeded
|
||||
/// holder replays nothing — subscribers wait for the first [add].
|
||||
bool get hasValue => _hasValue;
|
||||
|
||||
/// The latest value, or null before the first [add]. For a nullable
|
||||
/// [T], disambiguate with [hasValue].
|
||||
T? get valueOrNull => _value;
|
||||
|
||||
/// The latest value. Throws [StateError] before the first [add] —
|
||||
/// callers that can race the first value should use [valueOrNull].
|
||||
T get value {
|
||||
if (!_hasValue) throw StateError('ValueStream has no value yet');
|
||||
return _value as T;
|
||||
}
|
||||
|
||||
void add(T value) {
|
||||
_value = value;
|
||||
_hasValue = true;
|
||||
if (!_ctl.isClosed) _ctl.add(value);
|
||||
}
|
||||
|
||||
/// A stream that replays the latest value (if any) to its subscriber,
|
||||
/// then follows live updates. Each access returns a fresh
|
||||
/// single-subscription stream, so every listener gets its own replay.
|
||||
Stream<T> get stream {
|
||||
late StreamController<T> out;
|
||||
StreamSubscription<T>? sub;
|
||||
out = StreamController<T>(
|
||||
onListen: () {
|
||||
if (_hasValue) out.add(_value as T);
|
||||
if (_ctl.isClosed) {
|
||||
out.close();
|
||||
return;
|
||||
}
|
||||
sub = _ctl.stream.listen(out.add, onError: out.addError, onDone: out.close);
|
||||
},
|
||||
onPause: () => sub?.pause(),
|
||||
onResume: () => sub?.resume(),
|
||||
onCancel: () => sub?.cancel(),
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
bool get isClosed => _ctl.isClosed;
|
||||
|
||||
Future<void> close() => _ctl.close();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// Shared window-chrome metrics.
|
||||
///
|
||||
/// `hatHeight` used to live in clide_column_hat.dart; the per-column
|
||||
/// `ColumnHat` widget there was dead (duplicated by the hat bar in
|
||||
/// app.dart, kept alive only by a zero-coverage test) and was removed
|
||||
/// in the T-385 sweep — the constant is the part the live chrome
|
||||
/// (app.dart hat bar, menu bar) actually consumes (D-57).
|
||||
library;
|
||||
|
||||
/// Height of the per-column 24px window hats (D-57).
|
||||
const double hatHeight = 24;
|
||||
@@ -88,16 +88,24 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: kClideCardGap),
|
||||
child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens),
|
||||
);
|
||||
}
|
||||
|
||||
/// Summarized button semantics for the toggle. Scoped to the HEADER only —
|
||||
/// wrapping the whole card excluded every expanded child from the a11y
|
||||
/// tree, so a screen-reader user could expand a run and hear nothing
|
||||
/// inside it (T-370). Collapsed, the header summary IS the whole card.
|
||||
Widget _headerSemantics({required Widget child}) {
|
||||
final semanticCount = widget.counter == null ? '' : ', ${widget.counter}';
|
||||
return Semantics(
|
||||
button: true,
|
||||
expanded: _expanded,
|
||||
label: '${widget.label}$semanticCount, ${_expanded ? 'expanded' : 'collapsed'}',
|
||||
excludeSemantics: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: kClideCardGap),
|
||||
child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,18 +154,20 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
||||
}
|
||||
|
||||
/// Collapsed: the ticker row IS the toggle, focusable for keyboard/AT.
|
||||
Widget _tickerRow(SurfaceTokens tokens) => ClideTappable(
|
||||
focusNode: _controlFocus,
|
||||
onTap: _toggle,
|
||||
tooltip: 'Expand',
|
||||
builder: (context, hovered, focused) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
||||
decoration: BoxDecoration(
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
||||
border: Border.all(color: widget.color ?? tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(kClideCardRadius),
|
||||
Widget _tickerRow(SurfaceTokens tokens) => _headerSemantics(
|
||||
child: ClideTappable(
|
||||
focusNode: _controlFocus,
|
||||
onTap: _toggle,
|
||||
tooltip: 'Expand',
|
||||
builder: (context, hovered, focused) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
||||
decoration: BoxDecoration(
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
||||
border: Border.all(color: widget.color ?? tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(kClideCardRadius),
|
||||
),
|
||||
child: _headerContent(tokens, expanded: false),
|
||||
),
|
||||
child: _headerContent(tokens, expanded: false),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -172,17 +182,19 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background toggle: behind the items, not a whole-card overlay, so
|
||||
// item taps are never intercepted. Excluded from focus traversal —
|
||||
// the header caret is the single keyboard stop.
|
||||
// item taps are never intercepted. Excluded from focus traversal AND
|
||||
// semantics — the header caret is the single keyboard/AT stop.
|
||||
Positioned.fill(
|
||||
child: ExcludeFocus(
|
||||
child: ClideTappable(onTap: _toggle, tooltip: 'Collapse', builder: (_, _, _) => const SizedBox.expand()),
|
||||
child: ExcludeSemantics(
|
||||
child: ClideTappable(onTap: _toggle, tooltip: 'Collapse', builder: (_, _, _) => const SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_headerRow(tokens),
|
||||
_headerSemantics(child: _headerRow(tokens)),
|
||||
// Even padding around the inner item canvas (T-305): the sides +
|
||||
// top match, and each inner item carries a matching bottom margin
|
||||
// (so the last item's margin is the bottom inset and items in a
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart' show clideName;
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/kernel/src/window_controls.dart';
|
||||
import 'package:clide/widgets/src/clide_icon.dart';
|
||||
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:clide/widgets/src/clide_text.dart';
|
||||
import 'package:clide/widgets/src/icons/phosphor.dart';
|
||||
import 'package:clide/widgets/src/typography.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const double hatHeight = 24;
|
||||
|
||||
class ColumnHat extends StatelessWidget {
|
||||
const ColumnHat._({required this.position, required this.windowControls, this.projectLabel, this.branchLabel});
|
||||
|
||||
final HatPosition position;
|
||||
final WindowControls windowControls;
|
||||
final String? projectLabel;
|
||||
final String? branchLabel;
|
||||
|
||||
factory ColumnHat.left({required WindowControls windowControls}) => ColumnHat._(position: HatPosition.left, windowControls: windowControls);
|
||||
|
||||
factory ColumnHat.center({required WindowControls windowControls, String? project, String? branch}) =>
|
||||
ColumnHat._(position: HatPosition.center, windowControls: windowControls, projectLabel: project, branchLabel: branch);
|
||||
|
||||
factory ColumnHat.right({required WindowControls windowControls}) => ColumnHat._(position: HatPosition.right, windowControls: windowControls);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => windowControls.startDrag(),
|
||||
child: Container(
|
||||
height: hatHeight,
|
||||
color: tokens.panelHeader,
|
||||
child: switch (position) {
|
||||
HatPosition.left => _LeftContent(tokens: tokens, wc: windowControls),
|
||||
HatPosition.center => _CenterContent(tokens: tokens, project: projectLabel, branch: branchLabel),
|
||||
HatPosition.right => _RightContent(tokens: tokens, wc: windowControls),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum HatPosition { left, center, right }
|
||||
|
||||
class _LeftContent extends StatelessWidget {
|
||||
const _LeftContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// On macOS the native titlebar draws traffic lights; skip duplicates.
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
}
|
||||
|
||||
class _CenterContent extends StatelessWidget {
|
||||
const _CenterContent({required this.tokens, this.project, this.branch});
|
||||
final SurfaceTokens tokens;
|
||||
final String? project;
|
||||
final String? branch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parts = <String>[];
|
||||
if (project != null) parts.add(project!);
|
||||
if (branch != null) parts.add(branch!);
|
||||
final label = parts.isEmpty ? clideName : parts.join(' > ');
|
||||
return Center(
|
||||
child: ClideText(label, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RightContent extends StatelessWidget {
|
||||
const _RightContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.expand();
|
||||
final isMac = !kIsWeb && Platform.isMacOS;
|
||||
if (isMac) return const SizedBox.expand();
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
_WinButton(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinButton(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinButton(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WinButton extends StatelessWidget {
|
||||
const _WinButton({required this.icon, required this.onTap, required this.tokens, this.isClose = false});
|
||||
final ClideIconPainter icon;
|
||||
final VoidCallback onTap;
|
||||
final SurfaceTokens tokens;
|
||||
final bool isClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 36,
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.globalTextMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -405,6 +405,22 @@ class ClideMarkdown extends StatelessWidget {
|
||||
text: _unescapeHtml(el.textContent),
|
||||
style: TextStyle(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
|
||||
);
|
||||
case 'br':
|
||||
// A hard break has no textContent — the default branch rendered it
|
||||
// as an empty span and glued the surrounding words together (T-379).
|
||||
return const TextSpan(text: '\n');
|
||||
case 'img':
|
||||
// No inline image loading (network fetch in a text span is not the
|
||||
// owned-renderer way; live-pane images go through `clide image
|
||||
// show`) — render a visible alt-text placeholder instead of
|
||||
// disappearing (T-379).
|
||||
final alt = _unescapeHtml(el.attributes['alt'] ?? '');
|
||||
final src = el.attributes['src'] ?? '';
|
||||
final label = alt.isNotEmpty ? alt : src;
|
||||
return TextSpan(
|
||||
text: label.isEmpty ? '[image]' : '[image: $label]',
|
||||
style: TextStyle(color: tokens.globalTextMuted, fontStyle: FontStyle.italic),
|
||||
);
|
||||
default:
|
||||
return TextSpan(text: _unescapeHtml(el.textContent));
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
/// Flutter chrome widgets directly.
|
||||
library;
|
||||
|
||||
export 'src/chrome_metrics.dart';
|
||||
export 'src/clide_accordion.dart';
|
||||
export 'src/clide_anchored.dart';
|
||||
export 'src/clide_button.dart';
|
||||
export 'src/clide_card_metrics.dart';
|
||||
export 'src/clide_collapser_card.dart';
|
||||
export 'src/clide_column_hat.dart';
|
||||
export 'src/clide_code_block.dart';
|
||||
export 'src/clide_divider.dart';
|
||||
export 'src/clide_file_image.dart';
|
||||
|
||||
Binary file not shown.
@@ -338,14 +338,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mocktail:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: mocktail
|
||||
sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-2
@@ -13,7 +13,7 @@ description: >-
|
||||
subsystem handlers (pane, files, editor, git, pql), and the
|
||||
extension framework.
|
||||
publish_to: none
|
||||
version: 2.3.3
|
||||
version: 2.4.0
|
||||
repository: https://github.com/postmeridiem/clide
|
||||
# Short user-facing tagline (the welcome subtitle, web meta
|
||||
# description, etc.). Baked into lib/src/build_info.g.dart by
|
||||
@@ -69,7 +69,6 @@ dev_dependencies:
|
||||
# Held at 1.31.0: flutter_test SDK-locks the resolvable ceiling here
|
||||
# (1.31.1 is latest but not reachable under our Flutter pin).
|
||||
test: 1.31.0
|
||||
mocktail: 1.0.5
|
||||
# Held at 0.12.1: 0.13.0 disabled anti-aliasing on text painting, which
|
||||
# churns every golden. Dev-only, no advisory — defer the golden re-baseline
|
||||
# to its own change (T-353).
|
||||
|
||||
@@ -155,4 +155,34 @@ void main() {
|
||||
expect(out.map((g) => g.runtimeType.toString()), ['StickyItem', 'StickyItem', 'StickyItem']);
|
||||
});
|
||||
});
|
||||
|
||||
group('agent spawns are their own card (T-342)', () {
|
||||
test('two consecutive Agent spawns yield two separate cards, not one cluster', () {
|
||||
final groups = groupConversation([_tool('1', 'Task'), _tool('2', 'Task')], FoldLevel.tools);
|
||||
expect(groups, hasLength(2));
|
||||
expect(groups.every((g) => g is StickyItem), isTrue);
|
||||
});
|
||||
|
||||
test('an agent spawn breaks an Activity cluster of sibling tools', () {
|
||||
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), _tool('2', 'Task'), _tool('3', 'Bash'), _result('3')], FoldLevel.tools);
|
||||
expect(groups.map((g) => g.runtimeType.toString()), ['FoldedCluster', 'StickyItem', 'FoldedCluster']);
|
||||
expect(((groups[1] as StickyItem).item as AssistantToolUse).name, 'Task');
|
||||
});
|
||||
|
||||
test("the SDK 'Agent' tool is treated as an agent spawn too", () {
|
||||
expect(groupConversation([_tool('1', 'Agent')], FoldLevel.tools).single, isA<StickyItem>());
|
||||
});
|
||||
|
||||
test('agents stay first-class even at L3 (everything), so parallel agents never merge', () {
|
||||
final groups = groupConversation([_tool('1', 'Task'), _tool('2', 'Task')], FoldLevel.everything);
|
||||
expect(groups, hasLength(2));
|
||||
expect(groups.every((g) => g is StickyItem), isTrue);
|
||||
});
|
||||
|
||||
test('regression: consecutive Bash calls still form one Activity cluster', () {
|
||||
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), _tool('2', 'Bash'), _result('2')], FoldLevel.tools);
|
||||
expect(groups, hasLength(1));
|
||||
expect((groups.single as FoldedCluster).items, hasLength(4));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/// Unit tests for the Bash live-tail source parser (T-325).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
// resolveUnderRoot is pure string normalisation — the dir need not exist.
|
||||
final root = Directory('/repo');
|
||||
String? detect(String cmd) => detectBashTailSource(cmd, workspaceRoot: root);
|
||||
|
||||
group('detectBashTailSource — followable file sources (T-325)', () {
|
||||
test('tail -f a relative file', () {
|
||||
expect(detect('tail -f app.log'), '/repo/app.log');
|
||||
});
|
||||
|
||||
test('tail -f a nested file', () {
|
||||
expect(detect('tail -f logs/build.log'), '/repo/logs/build.log');
|
||||
});
|
||||
|
||||
test('tail with -n N before the file', () {
|
||||
expect(detect('tail -n 200 -f logs/build.log'), '/repo/logs/build.log');
|
||||
});
|
||||
|
||||
test('tail -F (retry-follow)', () {
|
||||
expect(detect('tail -F server.log'), '/repo/server.log');
|
||||
});
|
||||
|
||||
test('cat a file', () {
|
||||
expect(detect('cat notes.txt'), '/repo/notes.txt');
|
||||
});
|
||||
|
||||
test('less a file', () {
|
||||
expect(detect('less README.md'), '/repo/README.md');
|
||||
});
|
||||
|
||||
test('an absolute path INSIDE the workspace is followed', () {
|
||||
expect(detect('tail -f /repo/sub/x.log'), '/repo/sub/x.log');
|
||||
});
|
||||
|
||||
test('a quoted path with a space', () {
|
||||
expect(detect('cat "my file.log"'), '/repo/my file.log');
|
||||
});
|
||||
|
||||
test('a redirect after the file is ignored', () {
|
||||
expect(detect('tail -f app.log 2>/dev/null'), '/repo/app.log');
|
||||
});
|
||||
|
||||
test('a downstream pipe stage is ignored; the tail still has its file', () {
|
||||
expect(detect('tail -f logs/app.log | grep ERROR'), '/repo/logs/app.log');
|
||||
});
|
||||
|
||||
test('two segments naming the SAME file resolve to one source', () {
|
||||
expect(detect('cat a.txt && tail -f a.txt'), '/repo/a.txt');
|
||||
});
|
||||
});
|
||||
|
||||
group('detectBashTailSource — no followable source (T-325)', () {
|
||||
test('a pipe INTO tail (reads stdin, no file)', () {
|
||||
expect(detect('git push origin main | tail -25'), isNull);
|
||||
});
|
||||
|
||||
test('tail -f reading a pipe (no file arg)', () {
|
||||
expect(detect('cmd | tail -f'), isNull);
|
||||
});
|
||||
|
||||
test('a non-follow command', () {
|
||||
expect(detect('echo hi'), isNull);
|
||||
});
|
||||
|
||||
test('an absolute path OUTSIDE the workspace', () {
|
||||
expect(detect('tail -f /etc/passwd'), isNull);
|
||||
});
|
||||
|
||||
test('a traversal escaping the workspace', () {
|
||||
expect(detect('tail -f ../secrets.txt'), isNull);
|
||||
});
|
||||
|
||||
test('two distinct files are ambiguous', () {
|
||||
expect(detect('tail -f a.log b.log'), isNull);
|
||||
});
|
||||
|
||||
test('two segments naming DIFFERENT files are ambiguous', () {
|
||||
expect(detect('cat a.txt && tail -f b.txt'), isNull);
|
||||
});
|
||||
|
||||
test('empty command', () {
|
||||
expect(detect(''), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('bashHasTailIntent — when to surface the segment (T-325)', () {
|
||||
test('a tail command has tail intent (even into a pipe → "nothing to follow")', () {
|
||||
expect(bashHasTailIntent('tail -f app.log'), isTrue);
|
||||
expect(bashHasTailIntent('tail -100 app.log'), isTrue);
|
||||
expect(bashHasTailIntent('git push | tail -25'), isTrue);
|
||||
});
|
||||
|
||||
test('a bare follow flag counts', () {
|
||||
expect(bashHasTailIntent('some-cmd --follow build.log'), isTrue);
|
||||
});
|
||||
|
||||
test('ordinary commands have no tail intent (no segment)', () {
|
||||
expect(bashHasTailIntent('ls -la'), isFalse);
|
||||
expect(bashHasTailIntent('git status'), isFalse);
|
||||
expect(bashHasTailIntent('cat README.md'), isFalse); // cat is detectable but not a v1 trigger
|
||||
expect(bashHasTailIntent('grep -rn foo lib/'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import '../../helpers/widget_harness.dart';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fake process so orchestrator tests don't need a real `claude` binary.
|
||||
// ---------------------------------------------------------------------------
|
||||
class _FakeProc implements StreamJsonProcess {
|
||||
class _FakeProc extends StreamJsonProcess {
|
||||
final _ctl = StreamController<String>.broadcast();
|
||||
final List<String> writes = [];
|
||||
bool killed = false;
|
||||
|
||||
@@ -25,7 +25,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
class _FakeProc implements StreamJsonProcess {
|
||||
class _FakeProc extends StreamJsonProcess {
|
||||
final _ctl = StreamController<String>.broadcast();
|
||||
final List<String> writes = [];
|
||||
bool killed = false;
|
||||
@@ -52,15 +52,18 @@ void main() {
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
late String root;
|
||||
final created = <_FakeProc>[];
|
||||
final spawnArgs = <List<String>>[];
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
created.clear();
|
||||
spawnArgs.clear();
|
||||
root = '/repo-a';
|
||||
orch = ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
final p = _FakeProc();
|
||||
created.add(p);
|
||||
spawnArgs.add(sessionArgs);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
@@ -175,6 +178,20 @@ void main() {
|
||||
expect(orch.byId('primary')!.sessionId, id);
|
||||
});
|
||||
|
||||
testWidgets('/clear in a fork pane clears instead of re-forking (T-375)', (tester) async {
|
||||
await mount(tester, const ClaudePane(showChrome: false, isPrimary: false, secondaryIndex: 1, forkSourceId: 'source-session-uuid'));
|
||||
// First bind forks from the source.
|
||||
expect(spawnArgs.single, containsAll(['--fork-session', 'source-session-uuid']));
|
||||
|
||||
await act(tester, () => composer(tester).onSubmit('/clear'));
|
||||
|
||||
// The respawn must NOT fork the original again — the fork source is a
|
||||
// one-shot spawn parameter consumed by the first bind.
|
||||
expect(spawnArgs, hasLength(2));
|
||||
expect(spawnArgs.last, isNot(contains('--fork-session')));
|
||||
expect(spawnArgs.last, isNot(contains('source-session-uuid')));
|
||||
});
|
||||
|
||||
testWidgets('/fork delegates to the onFork callback with the session id', (tester) async {
|
||||
String? forkedWith;
|
||||
await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid));
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/// T-297: when the bottom interaction zone resizes, the conversation re-anchors
|
||||
/// to the tail (if pinned there) so content isn't left hidden behind the taller
|
||||
/// box — and leaves a scrolled-up reader undisturbed.
|
||||
///
|
||||
/// T-368: the same gate applies to NEW ITEMS — they arrive on every streamed
|
||||
/// token, and following the tail unconditionally yanked a scrolled-up reader
|
||||
/// to the bottom for the whole reply.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -24,7 +28,7 @@ void main() {
|
||||
|
||||
ScrollPosition scrollPos(WidgetTester tester) => tester.state<ScrollableState>(find.byType(Scrollable).first).position;
|
||||
|
||||
Future<ConversationController> pump(WidgetTester tester, ValueNotifier<double> bottomH) async {
|
||||
Future<(ConversationController, StreamController<ConversationItem>)> pump(WidgetTester tester, ValueNotifier<double> bottomH) async {
|
||||
tester.view.physicalSize = const Size(600, 600);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
@@ -58,7 +62,7 @@ void main() {
|
||||
stream.add(_asst('conversation line number $i', i));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
return c;
|
||||
return (c, stream);
|
||||
}
|
||||
|
||||
testWidgets('a growing bottom zone re-anchors the tail when pinned to bottom', (tester) async {
|
||||
@@ -95,4 +99,42 @@ void main() {
|
||||
expect(after.pixels, closeTo(before, 1), reason: 'offset preserved; not re-anchored to bottom');
|
||||
expect(after.pixels, lessThan(after.maxScrollExtent - 8), reason: 'still not at the tail');
|
||||
});
|
||||
|
||||
testWidgets('new streamed items keep following the tail when pinned', (tester) async {
|
||||
final bottomH = ValueNotifier<double>(40);
|
||||
addTearDown(bottomH.dispose);
|
||||
final (_, stream) = await pump(tester, bottomH);
|
||||
|
||||
final p = scrollPos(tester);
|
||||
expect(p.pixels, closeTo(p.maxScrollExtent, 1), reason: 'starts pinned to the tail');
|
||||
|
||||
for (var i = 40; i < 60; i++) {
|
||||
stream.add(_asst('streamed delta number $i', i));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final p2 = scrollPos(tester);
|
||||
expect(p2.pixels, closeTo(p2.maxScrollExtent, 1), reason: 'still pinned after new items streamed in');
|
||||
});
|
||||
|
||||
testWidgets('new streamed items do not yank a scrolled-up reader (T-368)', (tester) async {
|
||||
final bottomH = ValueNotifier<double>(40);
|
||||
addTearDown(bottomH.dispose);
|
||||
final (_, stream) = await pump(tester, bottomH);
|
||||
|
||||
// Scroll up, away from the tail.
|
||||
scrollPos(tester).jumpTo(30);
|
||||
await tester.pump();
|
||||
final before = scrollPos(tester).pixels;
|
||||
expect(before, closeTo(30, 1));
|
||||
|
||||
for (var i = 40; i < 60; i++) {
|
||||
stream.add(_asst('streamed delta number $i', i));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final after = scrollPos(tester);
|
||||
expect(after.pixels, closeTo(before, 1), reason: 'reading position preserved while the reply streams');
|
||||
expect(after.pixels, lessThan(after.maxScrollExtent - 8), reason: 'still not at the tail');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -533,6 +533,36 @@ void main() {
|
||||
expect(find.bySemanticsLabel('agent run, 2 steps, collapsed'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('parallel agents: interleaved run items route by parentToolUseId to their own card (T-342)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'A'}),
|
||||
AssistantToolUse(uuid: 'mB', timestamp: _t, isSidechain: false, toolUseId: 'tB', name: 'Task', input: const {'description': 'B'}),
|
||||
// Sidechain prose for the two agents, interleaved + tagged with the
|
||||
// owning agent's tool-use id (T-338 direct route).
|
||||
AssistantTextMessage(uuid: 'rB', timestamp: _t, isSidechain: true, parentToolUseId: 'tB', text: 'FROM B'),
|
||||
AssistantTextMessage(uuid: 'rA', timestamp: _t, isSidechain: true, parentToolUseId: 'tA', text: 'FROM A'),
|
||||
]);
|
||||
// Each agent gets its own 1-step run, not one pooled 2-step run under the
|
||||
// last-emitted agent — proof the interleaved items routed by their own
|
||||
// parentToolUseId (pooling would show one "2 steps" run, zero "1 step").
|
||||
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsNWidgets(2));
|
||||
expect(find.bySemanticsLabel('agent run, 2 steps, collapsed'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('parallel agents: an unattributable sidechain item orphans, not swept into the last agent (T-342)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'A'}),
|
||||
AssistantToolUse(uuid: 'mB', timestamp: _t, isSidechain: false, toolUseId: 'tB', name: 'Task', input: const {'description': 'B'}),
|
||||
// No parentToolUseId and no rooted parentUuid chain — unattributable.
|
||||
AssistantTextMessage(uuid: 'lost', timestamp: _t, isSidechain: true, text: 'UNROUTED PROSE'),
|
||||
]);
|
||||
// With >1 agent the nearest-agent fallback is dropped, so this orphans and
|
||||
// renders inline as "agent" prose instead of being filed under agent B.
|
||||
expect(find.text('UNROUTED PROSE'), findsOneWidget); // visible inline, not hidden in a collapsed run
|
||||
expect(find.text('agent'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsNothing); // neither agent gained a run from it
|
||||
});
|
||||
|
||||
testWidgets('a successful sidechain result folds into its run tool card, not a separate step (T-264)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'x'}),
|
||||
@@ -788,6 +818,29 @@ void main() {
|
||||
expect(copied, contains('question text'));
|
||||
expect(copied, contains('answer text'));
|
||||
});
|
||||
|
||||
testWidgets('a tail Bash card shows a live-tail segment; no workspace source → muted note (T-325)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'b1', timestamp: _t, isSidechain: false, toolUseId: 'tb', name: 'Bash', input: const {'command': 'tail -f app.log'}),
|
||||
]);
|
||||
// Collapsed by default — the segment only builds (and connects) on expand.
|
||||
expect(find.text('live tail'), findsNothing);
|
||||
await tester.tap(find.bySemanticsLabel('Bash, 1 step, collapsed'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('live tail'), findsOneWidget); // segment surfaced for a tail command
|
||||
// No project open in the fixture → no resolvable source → the muted note,
|
||||
// never a broken/empty terminal.
|
||||
expect(find.text('no independent source to follow'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an ordinary Bash card has no live-tail segment (T-325)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'b2', timestamp: _t, isSidechain: false, toolUseId: 'tb2', name: 'Bash', input: const {'command': 'ls -la'}),
|
||||
]);
|
||||
await tester.tap(find.bySemanticsLabel('Bash, 1 step, collapsed'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('live tail'), findsNothing); // no tail intent → no segment
|
||||
});
|
||||
});
|
||||
|
||||
group('ClaudeBanner', () {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/// T-391: the claude builtin's command handlers must honor the D-6
|
||||
/// exit-code contract — a failure is an ERROR envelope (non-zero CLI
|
||||
/// exit), never `ok` with an `error` field a script can't detect.
|
||||
/// `clide claude.agent.set-permission-mode bogus` exited 0 before this.
|
||||
/// Plus the activation lifecycle + command success paths.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show kActivityFoldLevelKey;
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart' show activeClaudeConfig;
|
||||
import 'package:clide/builtin/claude/src/extension.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart' show activeSessionOrchestrator;
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized(); // GlobalKey lookups in handlers
|
||||
final ext = ClaudeExtension();
|
||||
CommandContribution cmd(String id) => ext.contributions.whereType<CommandContribution>().firstWhere((c) => c.id == id);
|
||||
|
||||
group('failure paths return error envelopes (T-391, D-6)', () {
|
||||
// (command id, args, expected error kind)
|
||||
final cases = <(String, List<String>, String)>[
|
||||
('claude.agent.show', [], IpcErrorKind.userError),
|
||||
('claude.agent.hide', [], IpcErrorKind.userError),
|
||||
('claude.agent.close', [], IpcErrorKind.userError),
|
||||
('claude.agent.mute', [], IpcErrorKind.userError),
|
||||
('claude.agent.unmute', [], IpcErrorKind.userError),
|
||||
('claude.agent.inject-message', [], IpcErrorKind.userError),
|
||||
('claude.agent.inject-message', ['some-id'], IpcErrorKind.userError),
|
||||
('claude.agent.set-permission-mode', [], IpcErrorKind.userError),
|
||||
('claude.agent.set-permission-mode', ['some-id'], IpcErrorKind.userError),
|
||||
('claude.agent.set-permission-mode', ['some-id', 'bogus'], IpcErrorKind.userError),
|
||||
('claude.mode.cycle', [], IpcErrorKind.notFound),
|
||||
('claude.task.reassign', [], IpcErrorKind.userError),
|
||||
('claude.team-chat.post', [], IpcErrorKind.userError),
|
||||
('claude.agent.fork', [], IpcErrorKind.userError),
|
||||
// No orchestrator is wired in this test (extension not activated),
|
||||
// so a fork with a source id fails as unavailable tooling.
|
||||
('claude.agent.fork', ['some-id'], IpcErrorKind.toolError),
|
||||
];
|
||||
|
||||
for (final (id, args, kind) in cases) {
|
||||
test('$id ${args.isEmpty ? '(no args)' : args.join(' ')} → $kind', () async {
|
||||
final r = await cmd(id).run(args);
|
||||
expect(r.ok, isFalse, reason: 'a failure must not report ok');
|
||||
expect(r.error!.kind, kind);
|
||||
expect(r.error!.code, isNot(0), reason: 'the CLI must exit non-zero');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('activated lifecycle + success paths', () {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.services.extensions.register(ClaudeExtension());
|
||||
await f.services.extensions.activate('builtin.claude');
|
||||
expect(f.services.extensions.isActivated('builtin.claude'), isTrue, reason: f.services.extensions.failedExtensions.toString());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await f.services.extensions.deactivate('builtin.claude');
|
||||
await f.dispose();
|
||||
});
|
||||
|
||||
Future<IpcResponse> run(String command, [List<String> args = const []]) {
|
||||
final c = f.services.commands.get(command);
|
||||
expect(c, isNotNull, reason: '$command should be registered after activation');
|
||||
return c!.run(args);
|
||||
}
|
||||
|
||||
test('roster verbs succeed once the orchestrator is wired (no-op on unknown ids)', () async {
|
||||
for (final verb in ['claude.agent.show', 'claude.agent.hide', 'claude.agent.close', 'claude.agent.mute', 'claude.agent.unmute']) {
|
||||
final r = await run(verb, ['no-such-session']);
|
||||
expect(r.ok, isTrue, reason: '$verb is idempotent on unknown ids');
|
||||
}
|
||||
final inject = await run('claude.agent.inject-message', ['no-such-session', 'hello']);
|
||||
expect(inject.ok, isTrue);
|
||||
final mode = await run('claude.agent.set-permission-mode', ['no-such-session', 'plan']);
|
||||
expect(mode.ok, isTrue);
|
||||
expect(mode.data['mode'], 'plan');
|
||||
});
|
||||
|
||||
test('claude.new-secondary and kill-all-sessions succeed with no live panes', () async {
|
||||
expect((await run('claude.new-secondary')).ok, isTrue);
|
||||
final killed = await run('claude.kill-all-sessions');
|
||||
expect(killed.ok, isTrue);
|
||||
expect(killed.data['status'], 'killed');
|
||||
});
|
||||
|
||||
test('claude.activity.fold-level cycles and persists the setting (T-235)', () async {
|
||||
final r1 = await run('claude.activity.fold-level');
|
||||
expect(r1.ok, isTrue);
|
||||
final first = r1.data['foldLevel'] as String;
|
||||
expect(f.services.settings.get<String>(kActivityFoldLevelKey), first);
|
||||
final r2 = await run('claude.activity.fold-level');
|
||||
expect(r2.data['foldLevel'], isNot(first), reason: 'the level advances each call');
|
||||
});
|
||||
|
||||
test('claude.team-chat.open and .post succeed', () async {
|
||||
expect((await run('claude.team-chat.open')).ok, isTrue);
|
||||
final broadcast = await run('claude.team-chat.post', ['hello', 'team']);
|
||||
expect(broadcast.ok, isTrue);
|
||||
final directed = await run('claude.team-chat.post', ['@tyre', 'hello', 'you']);
|
||||
expect(directed.ok, isTrue);
|
||||
expect(directed.data['to'], 'tyre');
|
||||
});
|
||||
|
||||
test('claude.session-storage degrades cleanly when files.root is unavailable', () async {
|
||||
// The fixture IPC has no files.root stub → the handler bails out ok
|
||||
// without opening the dialog.
|
||||
final r = await run('claude.session-storage');
|
||||
expect(r.ok, isTrue);
|
||||
});
|
||||
|
||||
test('an image-show message with no live session is dropped silently (T-249)', () async {
|
||||
f.services.messages.publish('test', imageShowChannel, {'path': '/tmp/x.png'});
|
||||
f.services.messages.publish('test', imageShowChannel, {'path': ''});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
// Nothing to assert beyond "no throw" — there is no conversation to
|
||||
// receive the card and the CLI already acked at publish time.
|
||||
});
|
||||
|
||||
test('a project switch closes sessions that belong to the old root (T-269)', () async {
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-two'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
// No live sessions in this fixture — the sweep runs over an empty set.
|
||||
expect(activeSessionOrchestrator!.sessions, isEmpty);
|
||||
});
|
||||
|
||||
test('deactivate clears the builtin-owned singletons', () async {
|
||||
expect(activeSessionOrchestrator, isNotNull);
|
||||
await f.services.extensions.deactivate('builtin.claude');
|
||||
expect(activeSessionOrchestrator, isNull);
|
||||
expect(activeClaudeConfig, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/// Unit tests for the read-only file tail follower (T-325).
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory dir;
|
||||
late File file;
|
||||
late List<String> chunks;
|
||||
FileTailFollower follower(File f) => FileTailFollower(f.path, tailBytes: 8, onData: (b) => chunks.add(utf8.decode(b)));
|
||||
|
||||
setUp(() async {
|
||||
dir = await Directory.systemTemp.createTemp('clide-tail-test-');
|
||||
file = File('${dir.path}/app.log');
|
||||
chunks = [];
|
||||
});
|
||||
tearDown(() async => dir.existsSync() ? dir.delete(recursive: true) : null);
|
||||
|
||||
test('emits the trailing window on the first read, not the whole file', () async {
|
||||
file.writeAsStringSync('0123456789ABCDEF'); // 16 bytes, tailBytes=8
|
||||
final f = follower(file);
|
||||
await f.pollOnce();
|
||||
expect(chunks, ['89ABCDEF']); // last 8 bytes only
|
||||
f.stop();
|
||||
});
|
||||
|
||||
test('emits only newly-appended bytes on subsequent reads', () async {
|
||||
file.writeAsStringSync('start');
|
||||
final f = follower(file);
|
||||
await f.pollOnce(); // primes at the tail
|
||||
chunks.clear();
|
||||
file.writeAsStringSync(' MORE', mode: FileMode.append);
|
||||
await f.pollOnce();
|
||||
expect(chunks, [' MORE']); // only the appended delta
|
||||
f.stop();
|
||||
});
|
||||
|
||||
test('a missing file is tolerated until it appears', () async {
|
||||
final f = follower(File('${dir.path}/not-yet.log'));
|
||||
await f.pollOnce(); // no file → no emit, no throw
|
||||
expect(chunks, isEmpty);
|
||||
f.stop();
|
||||
});
|
||||
|
||||
test('truncation/rotation re-reads from the top', () async {
|
||||
file.writeAsStringSync('aaaaaaaaaaaa'); // 12 bytes
|
||||
final f = follower(file);
|
||||
await f.pollOnce();
|
||||
chunks.clear();
|
||||
file.writeAsStringSync('XY'); // shrink to 2 bytes (rotated)
|
||||
await f.pollOnce();
|
||||
expect(chunks, ['XY']);
|
||||
f.stop();
|
||||
});
|
||||
|
||||
test('start() emits the initial window and arms the poll', () async {
|
||||
file.writeAsStringSync('hello world'); // 11 bytes, tailBytes=8
|
||||
final f = follower(file);
|
||||
await f.start(); // awaits the initial pollOnce before arming the timer
|
||||
f.stop(); // tear the timer down before it fires
|
||||
expect(chunks, ['lo world']); // last 8 bytes
|
||||
});
|
||||
|
||||
test('stop() makes further polls no-ops', () async {
|
||||
file.writeAsStringSync('hello');
|
||||
final f = follower(file);
|
||||
f.stop();
|
||||
await f.pollOnce();
|
||||
expect(chunks, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import 'package:test/test.dart';
|
||||
// Minimal fake process — same as session_orchestrator_test.dart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeProc implements StreamJsonProcess {
|
||||
class _FakeProc extends StreamJsonProcess {
|
||||
final _ctl = StreamController<String>.broadcast();
|
||||
final List<String> writes = [];
|
||||
bool killed = false;
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class _FakeProc implements StreamJsonProcess {
|
||||
class _FakeProc extends StreamJsonProcess {
|
||||
final _ctl = StreamController<String>.broadcast();
|
||||
final List<String> writes = [];
|
||||
bool killed = false;
|
||||
@@ -60,6 +60,32 @@ void main() {
|
||||
expect(created, hasLength(1));
|
||||
});
|
||||
|
||||
// T-374: spawn() check-then-acts across awaits; without the in-flight
|
||||
// map, two CONCURRENT spawns both passed the registry check and the
|
||||
// loser's live claude process was orphaned.
|
||||
test('two concurrent spawns for one id share one session and one process (T-374)', () async {
|
||||
final (a, b) = await (orch.spawn(spec('primary')), orch.spawn(spec('primary'))).wait;
|
||||
expect(identical(a, b), isTrue);
|
||||
expect(created, hasLength(1));
|
||||
});
|
||||
|
||||
test('a failed spawn clears the in-flight entry so a retry can proceed (T-374)', () async {
|
||||
var calls = 0;
|
||||
final flaky = ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
calls++;
|
||||
if (calls == 1) throw StateError('spawn blew up');
|
||||
final p = _FakeProc();
|
||||
created.add(p);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
await expectLater(flaky.spawn(spec('primary')), throwsStateError);
|
||||
final m = await flaky.spawn(spec('primary'));
|
||||
expect(m.id, 'primary');
|
||||
expect(calls, 2);
|
||||
});
|
||||
|
||||
test('hide keeps the process alive and in the registry; show restores it', () async {
|
||||
await orch.spawn(spec('primary'));
|
||||
orch.hide('primary');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user